mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
[Tags] Prevent duplicates (#167072)
This commit is contained in:
parent
6ac03d739a
commit
726f212d0c
28 changed files with 451 additions and 42 deletions
|
@ -357,6 +357,7 @@ enabled:
|
||||||
- x-pack/test/saved_object_api_integration/spaces_only/config.ts
|
- x-pack/test/saved_object_api_integration/spaces_only/config.ts
|
||||||
- x-pack/test/saved_object_tagging/api_integration/security_and_spaces/config.ts
|
- x-pack/test/saved_object_tagging/api_integration/security_and_spaces/config.ts
|
||||||
- x-pack/test/saved_object_tagging/api_integration/tagging_api/config.ts
|
- x-pack/test/saved_object_tagging/api_integration/tagging_api/config.ts
|
||||||
|
- x-pack/test/saved_object_tagging/api_integration/tagging_usage_collection/config.ts
|
||||||
- x-pack/test/saved_object_tagging/functional/config.ts
|
- x-pack/test/saved_object_tagging/functional/config.ts
|
||||||
- x-pack/test/saved_objects_field_count/config.ts
|
- x-pack/test/saved_objects_field_count/config.ts
|
||||||
- x-pack/test/search_sessions_integration/config.ts
|
- x-pack/test/search_sessions_integration/config.ts
|
||||||
|
|
|
@ -35,6 +35,7 @@ export interface ITagsClient {
|
||||||
create(attributes: TagAttributes, options?: CreateTagOptions): Promise<Tag>;
|
create(attributes: TagAttributes, options?: CreateTagOptions): Promise<Tag>;
|
||||||
get(id: string): Promise<Tag>;
|
get(id: string): Promise<Tag>;
|
||||||
getAll(options?: GetAllTagsOptions): Promise<Tag[]>;
|
getAll(options?: GetAllTagsOptions): Promise<Tag[]>;
|
||||||
|
findByName(name: string, options?: { exact?: boolean }): Promise<Tag | null>;
|
||||||
delete(id: string): Promise<void>;
|
delete(id: string): Promise<void>;
|
||||||
update(id: string, attributes: TagAttributes): Promise<Tag>;
|
update(id: string, attributes: TagAttributes): Promise<Tag>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,7 @@ const createClientMock = () => {
|
||||||
getAll: jest.fn(),
|
getAll: jest.fn(),
|
||||||
delete: jest.fn(),
|
delete: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
|
findByName: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return mock;
|
return mock;
|
||||||
|
|
|
@ -6,17 +6,22 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { FC, useState, useCallback } from 'react';
|
import React, { FC, useState, useCallback } from 'react';
|
||||||
|
import { first, lastValueFrom } from 'rxjs';
|
||||||
|
import { i18n } from '@kbn/i18n';
|
||||||
|
import type { NotificationsStart } from '@kbn/core/public';
|
||||||
|
|
||||||
import { ITagsClient, Tag, TagAttributes } from '../../../common/types';
|
import { ITagsClient, Tag, TagAttributes } from '../../../common/types';
|
||||||
import { TagValidation } from '../../../common/validation';
|
|
||||||
import { isServerValidationError } from '../../services/tags';
|
import { isServerValidationError } from '../../services/tags';
|
||||||
import { getRandomColor, validateTag } from './utils';
|
import { getRandomColor, validateTag } from './utils';
|
||||||
import { CreateOrEditModal } from './create_or_edit_modal';
|
import { CreateOrEditModal } from './create_or_edit_modal';
|
||||||
|
import { useValidation } from './use_validation';
|
||||||
|
|
||||||
interface CreateTagModalProps {
|
interface CreateTagModalProps {
|
||||||
defaultValues?: Partial<TagAttributes>;
|
defaultValues?: Partial<TagAttributes>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (tag: Tag) => void;
|
onSave: (tag: Tag) => void;
|
||||||
tagClient: ITagsClient;
|
tagClient: ITagsClient;
|
||||||
|
notifications: NotificationsStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDefaultAttributes = (providedDefaults?: Partial<TagAttributes>): TagAttributes => ({
|
const getDefaultAttributes = (providedDefaults?: Partial<TagAttributes>): TagAttributes => ({
|
||||||
|
@ -26,22 +31,21 @@ const getDefaultAttributes = (providedDefaults?: Partial<TagAttributes>): TagAtt
|
||||||
...providedDefaults,
|
...providedDefaults,
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialValidation: TagValidation = {
|
|
||||||
valid: true,
|
|
||||||
warnings: [],
|
|
||||||
errors: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CreateTagModal: FC<CreateTagModalProps> = ({
|
export const CreateTagModal: FC<CreateTagModalProps> = ({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
tagClient,
|
tagClient,
|
||||||
|
notifications,
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
}) => {
|
}) => {
|
||||||
const [validation, setValidation] = useState<TagValidation>(initialValidation);
|
|
||||||
const [tagAttributes, setTagAttributes] = useState<TagAttributes>(
|
const [tagAttributes, setTagAttributes] = useState<TagAttributes>(
|
||||||
getDefaultAttributes(defaultValues)
|
getDefaultAttributes(defaultValues)
|
||||||
);
|
);
|
||||||
|
const { validation, setValidation, onNameChange, validation$, isValidating } = useValidation({
|
||||||
|
tagAttributes,
|
||||||
|
tagClient,
|
||||||
|
validateDuplicateNameOnMount: true,
|
||||||
|
});
|
||||||
|
|
||||||
const setField = useCallback(
|
const setField = useCallback(
|
||||||
<T extends keyof TagAttributes>(field: T) =>
|
<T extends keyof TagAttributes>(field: T) =>
|
||||||
|
@ -55,6 +59,14 @@ export const CreateTagModal: FC<CreateTagModalProps> = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSubmit = useCallback(async () => {
|
const onSubmit = useCallback(async () => {
|
||||||
|
const { hasDuplicateNameError } = await lastValueFrom(
|
||||||
|
validation$.pipe(first((v) => v.isValidating === false))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasDuplicateNameError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const clientValidation = validateTag(tagAttributes);
|
const clientValidation = validateTag(tagAttributes);
|
||||||
setValidation(clientValidation);
|
setValidation(clientValidation);
|
||||||
if (!clientValidation.valid) {
|
if (!clientValidation.valid) {
|
||||||
|
@ -68,18 +80,27 @@ export const CreateTagModal: FC<CreateTagModalProps> = ({
|
||||||
// if e is IHttpFetchError, actual server error payload is in e.body
|
// if e is IHttpFetchError, actual server error payload is in e.body
|
||||||
if (isServerValidationError(e.body)) {
|
if (isServerValidationError(e.body)) {
|
||||||
setValidation(e.body.attributes);
|
setValidation(e.body.attributes);
|
||||||
|
} else {
|
||||||
|
notifications.toasts.addDanger({
|
||||||
|
title: i18n.translate('xpack.savedObjectsTagging.saveTagErrorTitle', {
|
||||||
|
defaultMessage: 'An error occurred creating tag',
|
||||||
|
}),
|
||||||
|
text: e.body.message,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [tagAttributes, tagClient, onSave]);
|
}, [validation$, tagAttributes, setValidation, tagClient, onSave, notifications.toasts]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CreateOrEditModal
|
<CreateOrEditModal
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onNameChange={onNameChange}
|
||||||
mode={'create'}
|
mode={'create'}
|
||||||
tag={tagAttributes}
|
tag={tagAttributes}
|
||||||
setField={setField}
|
setField={setField}
|
||||||
validation={validation}
|
validation={validation}
|
||||||
|
isValidating={isValidating}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
* 2.0.
|
* 2.0.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { FC, useState, useCallback, useMemo } from 'react';
|
import React, { FC, useState, useCallback, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
EuiButtonEmpty,
|
EuiButtonEmpty,
|
||||||
EuiButton,
|
EuiButton,
|
||||||
|
@ -27,6 +27,7 @@ import {
|
||||||
} from '@elastic/eui';
|
} from '@elastic/eui';
|
||||||
import { i18n } from '@kbn/i18n';
|
import { i18n } from '@kbn/i18n';
|
||||||
import { FormattedMessage } from '@kbn/i18n-react';
|
import { FormattedMessage } from '@kbn/i18n-react';
|
||||||
|
import useDebounce from 'react-use/lib/useDebounce';
|
||||||
import {
|
import {
|
||||||
TagAttributes,
|
TagAttributes,
|
||||||
TagValidation,
|
TagValidation,
|
||||||
|
@ -40,16 +41,23 @@ import { getRandomColor, useIfMounted } from './utils';
|
||||||
interface CreateOrEditModalProps {
|
interface CreateOrEditModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: () => Promise<void>;
|
onSubmit: () => Promise<void>;
|
||||||
|
onNameChange: (
|
||||||
|
name: string,
|
||||||
|
options?: { debounced?: boolean; hasBeenModified?: boolean }
|
||||||
|
) => Promise<void>;
|
||||||
mode: 'create' | 'edit';
|
mode: 'create' | 'edit';
|
||||||
tag: TagAttributes;
|
tag: TagAttributes;
|
||||||
validation: TagValidation;
|
validation: TagValidation;
|
||||||
|
isValidating: boolean;
|
||||||
setField: <T extends keyof TagAttributes>(field: T) => (value: TagAttributes[T]) => void;
|
setField: <T extends keyof TagAttributes>(field: T) => (value: TagAttributes[T]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CreateOrEditModal: FC<CreateOrEditModalProps> = ({
|
export const CreateOrEditModal: FC<CreateOrEditModalProps> = ({
|
||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
onNameChange,
|
||||||
validation,
|
validation,
|
||||||
|
isValidating,
|
||||||
setField,
|
setField,
|
||||||
tag,
|
tag,
|
||||||
mode,
|
mode,
|
||||||
|
@ -57,6 +65,7 @@ export const CreateOrEditModal: FC<CreateOrEditModalProps> = ({
|
||||||
const optionalMessageId = htmlIdGenerator()();
|
const optionalMessageId = htmlIdGenerator()();
|
||||||
const ifMounted = useIfMounted();
|
const ifMounted = useIfMounted();
|
||||||
const [submitting, setSubmitting] = useState<boolean>(false);
|
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||||
|
const lastNameValue = useRef(tag.name);
|
||||||
|
|
||||||
// we don't want this value to change when the user edits the tag
|
// we don't want this value to change when the user edits the tag
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
@ -68,6 +77,8 @@ export const CreateOrEditModal: FC<CreateOrEditModalProps> = ({
|
||||||
tag.description !== initialTag.description,
|
tag.description !== initialTag.description,
|
||||||
[initialTag, tag]
|
[initialTag, tag]
|
||||||
);
|
);
|
||||||
|
const nameHasBeenModified = tag.name !== lastNameValue.current;
|
||||||
|
|
||||||
const setName = useMemo(() => setField('name'), [setField]);
|
const setName = useMemo(() => setField('name'), [setField]);
|
||||||
const setColor = useMemo(() => setField('color'), [setField]);
|
const setColor = useMemo(() => setField('color'), [setField]);
|
||||||
const setDescription = useMemo(() => setField('description'), [setField]);
|
const setDescription = useMemo(() => setField('description'), [setField]);
|
||||||
|
@ -91,6 +102,15 @@ export const CreateOrEditModal: FC<CreateOrEditModalProps> = ({
|
||||||
});
|
});
|
||||||
}, [ifMounted, onSubmit]);
|
}, [ifMounted, onSubmit]);
|
||||||
|
|
||||||
|
useDebounce(
|
||||||
|
() => {
|
||||||
|
onNameChange(tag.name, { debounced: true, hasBeenModified: nameHasBeenModified });
|
||||||
|
lastNameValue.current = tag.name;
|
||||||
|
},
|
||||||
|
300,
|
||||||
|
[tag.name, nameHasBeenModified]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EuiModal onClose={onClose} initialFocus="[name=name]" style={{ minWidth: '600px' }}>
|
<EuiModal onClose={onClose} initialFocus="[name=name]" style={{ minWidth: '600px' }}>
|
||||||
<EuiModalHeader>
|
<EuiModalHeader>
|
||||||
|
@ -130,6 +150,7 @@ export const CreateOrEditModal: FC<CreateOrEditModalProps> = ({
|
||||||
maxLength={tagNameMaxLength}
|
maxLength={tagNameMaxLength}
|
||||||
value={tag.name}
|
value={tag.name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
isLoading={isValidating}
|
||||||
data-test-subj="createModalField-name"
|
data-test-subj="createModalField-name"
|
||||||
/>
|
/>
|
||||||
</EuiFormRow>
|
</EuiFormRow>
|
||||||
|
@ -238,6 +259,7 @@ export const CreateOrEditModal: FC<CreateOrEditModalProps> = ({
|
||||||
fill
|
fill
|
||||||
data-test-subj="createModalConfirmButton"
|
data-test-subj="createModalConfirmButton"
|
||||||
onClick={onFormSubmit}
|
onClick={onFormSubmit}
|
||||||
|
isLoading={submitting}
|
||||||
isDisabled={submitting || (isEdit && !tagHasBeenModified)}
|
isDisabled={submitting || (isEdit && !tagHasBeenModified)}
|
||||||
>
|
>
|
||||||
{isEdit ? (
|
{isEdit ? (
|
||||||
|
|
|
@ -6,33 +6,40 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { FC, useState, useCallback } from 'react';
|
import React, { FC, useState, useCallback } from 'react';
|
||||||
|
import { i18n } from '@kbn/i18n';
|
||||||
|
import type { NotificationsStart } from '@kbn/core/public';
|
||||||
|
import { first, lastValueFrom } from 'rxjs';
|
||||||
import { ITagsClient, Tag, TagAttributes } from '../../../common/types';
|
import { ITagsClient, Tag, TagAttributes } from '../../../common/types';
|
||||||
import { TagValidation } from '../../../common/validation';
|
|
||||||
import { isServerValidationError } from '../../services/tags';
|
import { isServerValidationError } from '../../services/tags';
|
||||||
import { CreateOrEditModal } from './create_or_edit_modal';
|
import { CreateOrEditModal } from './create_or_edit_modal';
|
||||||
import { validateTag } from './utils';
|
import { validateTag } from './utils';
|
||||||
|
import { useValidation } from './use_validation';
|
||||||
|
|
||||||
interface EditTagModalProps {
|
interface EditTagModalProps {
|
||||||
tag: Tag;
|
tag: Tag;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (tag: Tag) => void;
|
onSave: (tag: Tag) => void;
|
||||||
tagClient: ITagsClient;
|
tagClient: ITagsClient;
|
||||||
|
notifications: NotificationsStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialValidation: TagValidation = {
|
|
||||||
valid: true,
|
|
||||||
warnings: [],
|
|
||||||
errors: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAttributes = (tag: Tag): TagAttributes => {
|
const getAttributes = (tag: Tag): TagAttributes => {
|
||||||
const { id, ...attributes } = tag;
|
const { id, ...attributes } = tag;
|
||||||
return attributes;
|
return attributes;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EditTagModal: FC<EditTagModalProps> = ({ tag, onSave, onClose, tagClient }) => {
|
export const EditTagModal: FC<EditTagModalProps> = ({
|
||||||
const [validation, setValidation] = useState<TagValidation>(initialValidation);
|
tag,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
tagClient,
|
||||||
|
notifications,
|
||||||
|
}) => {
|
||||||
const [tagAttributes, setTagAttributes] = useState<TagAttributes>(getAttributes(tag));
|
const [tagAttributes, setTagAttributes] = useState<TagAttributes>(getAttributes(tag));
|
||||||
|
const { validation, setValidation, onNameChange, isValidating, validation$ } = useValidation({
|
||||||
|
tagAttributes,
|
||||||
|
tagClient,
|
||||||
|
});
|
||||||
|
|
||||||
const setField = useCallback(
|
const setField = useCallback(
|
||||||
<T extends keyof TagAttributes>(field: T) =>
|
<T extends keyof TagAttributes>(field: T) =>
|
||||||
|
@ -46,8 +53,17 @@ export const EditTagModal: FC<EditTagModalProps> = ({ tag, onSave, onClose, tagC
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSubmit = useCallback(async () => {
|
const onSubmit = useCallback(async () => {
|
||||||
|
const { hasDuplicateNameError } = await lastValueFrom(
|
||||||
|
validation$.pipe(first((v) => v.isValidating === false))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasDuplicateNameError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const clientValidation = validateTag(tagAttributes);
|
const clientValidation = validateTag(tagAttributes);
|
||||||
setValidation(clientValidation);
|
setValidation(clientValidation);
|
||||||
|
|
||||||
if (!clientValidation.valid) {
|
if (!clientValidation.valid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -59,18 +75,27 @@ export const EditTagModal: FC<EditTagModalProps> = ({ tag, onSave, onClose, tagC
|
||||||
// if e is IHttpFetchError, actual server error payload is in e.body
|
// if e is IHttpFetchError, actual server error payload is in e.body
|
||||||
if (isServerValidationError(e.body)) {
|
if (isServerValidationError(e.body)) {
|
||||||
setValidation(e.body.attributes);
|
setValidation(e.body.attributes);
|
||||||
|
} else {
|
||||||
|
notifications.toasts.addDanger({
|
||||||
|
title: i18n.translate('xpack.savedObjectsTagging.editTagErrorTitle', {
|
||||||
|
defaultMessage: 'An error occurred editing tag',
|
||||||
|
}),
|
||||||
|
text: e.body.message,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [tagAttributes, tagClient, onSave, tag]);
|
}, [validation$, tagAttributes, setValidation, tagClient, tag.id, onSave, notifications.toasts]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CreateOrEditModal
|
<CreateOrEditModal
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onNameChange={onNameChange}
|
||||||
mode={'edit'}
|
mode={'edit'}
|
||||||
tag={tagAttributes}
|
tag={tagAttributes}
|
||||||
setField={setField}
|
setField={setField}
|
||||||
validation={validation}
|
validation={validation}
|
||||||
|
isValidating={isValidating}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -7,13 +7,19 @@
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { EuiDelayRender, EuiLoadingSpinner } from '@elastic/eui';
|
import { EuiDelayRender, EuiLoadingSpinner } from '@elastic/eui';
|
||||||
import { OverlayStart, OverlayRef, ThemeServiceStart } from '@kbn/core/public';
|
import type {
|
||||||
|
OverlayStart,
|
||||||
|
OverlayRef,
|
||||||
|
ThemeServiceStart,
|
||||||
|
NotificationsStart,
|
||||||
|
} from '@kbn/core/public';
|
||||||
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
|
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
|
||||||
import { Tag, TagAttributes } from '../../../common/types';
|
import { Tag, TagAttributes } from '../../../common/types';
|
||||||
import { ITagInternalClient } from '../../services';
|
import { ITagInternalClient } from '../../services';
|
||||||
|
|
||||||
interface GetModalOpenerOptions {
|
interface GetModalOpenerOptions {
|
||||||
overlays: OverlayStart;
|
overlays: OverlayStart;
|
||||||
|
notifications: NotificationsStart;
|
||||||
theme: ThemeServiceStart;
|
theme: ThemeServiceStart;
|
||||||
tagClient: ITagInternalClient;
|
tagClient: ITagInternalClient;
|
||||||
}
|
}
|
||||||
|
@ -40,7 +46,7 @@ const LazyEditTagModal = React.lazy(() =>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getCreateModalOpener =
|
export const getCreateModalOpener =
|
||||||
({ overlays, theme, tagClient }: GetModalOpenerOptions): CreateModalOpener =>
|
({ overlays, theme, tagClient, notifications }: GetModalOpenerOptions): CreateModalOpener =>
|
||||||
async ({ onCreate, defaultValues }: OpenCreateModalOptions) => {
|
async ({ onCreate, defaultValues }: OpenCreateModalOptions) => {
|
||||||
const modal = overlays.openModal(
|
const modal = overlays.openModal(
|
||||||
toMountPoint(
|
toMountPoint(
|
||||||
|
@ -55,6 +61,7 @@ export const getCreateModalOpener =
|
||||||
onCreate(tag);
|
onCreate(tag);
|
||||||
}}
|
}}
|
||||||
tagClient={tagClient}
|
tagClient={tagClient}
|
||||||
|
notifications={notifications}
|
||||||
/>
|
/>
|
||||||
</React.Suspense>,
|
</React.Suspense>,
|
||||||
{ theme$: theme.theme$ }
|
{ theme$: theme.theme$ }
|
||||||
|
@ -69,7 +76,7 @@ interface OpenEditModalOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getEditModalOpener =
|
export const getEditModalOpener =
|
||||||
({ overlays, theme, tagClient }: GetModalOpenerOptions) =>
|
({ overlays, theme, tagClient, notifications }: GetModalOpenerOptions) =>
|
||||||
async ({ tagId, onUpdate }: OpenEditModalOptions) => {
|
async ({ tagId, onUpdate }: OpenEditModalOptions) => {
|
||||||
const tag = await tagClient.get(tagId);
|
const tag = await tagClient.get(tagId);
|
||||||
|
|
||||||
|
@ -86,6 +93,7 @@ export const getEditModalOpener =
|
||||||
onUpdate(saved);
|
onUpdate(saved);
|
||||||
}}
|
}}
|
||||||
tagClient={tagClient}
|
tagClient={tagClient}
|
||||||
|
notifications={notifications}
|
||||||
/>
|
/>
|
||||||
</React.Suspense>,
|
</React.Suspense>,
|
||||||
{ theme$: theme.theme$ }
|
{ theme$: theme.theme$ }
|
||||||
|
|
|
@ -0,0 +1,143 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
import useObservable from 'react-use/lib/useObservable';
|
||||||
|
|
||||||
|
import { type TagValidation, validateTagName } from '../../../common';
|
||||||
|
import type { ITagsClient, TagAttributes } from '../../../common/types';
|
||||||
|
import { duplicateTagNameErrorMessage, validateTag } from './utils';
|
||||||
|
|
||||||
|
const initialValidation: TagValidation = {
|
||||||
|
valid: true,
|
||||||
|
warnings: [],
|
||||||
|
errors: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useValidation = ({
|
||||||
|
tagAttributes,
|
||||||
|
tagClient,
|
||||||
|
validateDuplicateNameOnMount = false,
|
||||||
|
}: {
|
||||||
|
tagAttributes: TagAttributes;
|
||||||
|
tagClient: ITagsClient;
|
||||||
|
validateDuplicateNameOnMount?: boolean;
|
||||||
|
}) => {
|
||||||
|
const isMounted = useRef(false);
|
||||||
|
const [validation, setValidation] = useState<TagValidation>(initialValidation);
|
||||||
|
const {
|
||||||
|
errors: { name: nameError },
|
||||||
|
} = validation;
|
||||||
|
|
||||||
|
const validation$ = useMemo(
|
||||||
|
() =>
|
||||||
|
new BehaviorSubject({
|
||||||
|
isValidating: false,
|
||||||
|
hasDuplicateNameError: false,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { isValidating = false } = useObservable(validation$) ?? {};
|
||||||
|
|
||||||
|
const setIsValidating = useCallback(
|
||||||
|
(value: boolean) => {
|
||||||
|
validation$.next({
|
||||||
|
...validation$.value,
|
||||||
|
isValidating: value,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[validation$]
|
||||||
|
);
|
||||||
|
|
||||||
|
const validateDuplicateTagName = useCallback(
|
||||||
|
async (name: string) => {
|
||||||
|
const error = validateTagName(name);
|
||||||
|
if (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingTag = await tagClient.findByName(name, { exact: true });
|
||||||
|
|
||||||
|
if (existingTag) {
|
||||||
|
setValidation((prev) => ({
|
||||||
|
...prev,
|
||||||
|
valid: false,
|
||||||
|
errors: {
|
||||||
|
...prev.errors,
|
||||||
|
name: duplicateTagNameErrorMessage,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsValidating(false);
|
||||||
|
},
|
||||||
|
[tagClient, setIsValidating]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onNameChange = useCallback(
|
||||||
|
async (
|
||||||
|
name: string,
|
||||||
|
{
|
||||||
|
debounced = false,
|
||||||
|
hasBeenModified = true,
|
||||||
|
}: { debounced?: boolean; hasBeenModified?: boolean } = {}
|
||||||
|
) => {
|
||||||
|
setIsValidating(true);
|
||||||
|
|
||||||
|
if (debounced) {
|
||||||
|
if (hasBeenModified) {
|
||||||
|
await validateDuplicateTagName(name);
|
||||||
|
}
|
||||||
|
setIsValidating(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setIsValidating, validateDuplicateTagName]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMounted.current) {
|
||||||
|
onNameChange(tagAttributes.name);
|
||||||
|
}
|
||||||
|
}, [onNameChange, tagAttributes.name]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMounted.current) {
|
||||||
|
setValidation(validateTag(tagAttributes));
|
||||||
|
}
|
||||||
|
}, [tagAttributes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (validateDuplicateNameOnMount && tagAttributes.name && !isMounted.current) {
|
||||||
|
setIsValidating(true);
|
||||||
|
validateDuplicateTagName(tagAttributes.name);
|
||||||
|
}
|
||||||
|
isMounted.current = true;
|
||||||
|
}, [
|
||||||
|
validateDuplicateNameOnMount,
|
||||||
|
tagAttributes.name,
|
||||||
|
validateDuplicateTagName,
|
||||||
|
validation$,
|
||||||
|
setIsValidating,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
validation$.next({
|
||||||
|
...validation$.value,
|
||||||
|
hasDuplicateNameError: nameError === duplicateTagNameErrorMessage,
|
||||||
|
});
|
||||||
|
}, [nameError, validation$]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
validation,
|
||||||
|
setValidation,
|
||||||
|
isValidating,
|
||||||
|
validation$,
|
||||||
|
onNameChange,
|
||||||
|
};
|
||||||
|
};
|
|
@ -6,6 +6,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { i18n } from '@kbn/i18n';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
TagAttributes,
|
TagAttributes,
|
||||||
TagValidation,
|
TagValidation,
|
||||||
|
@ -21,6 +23,13 @@ export const getRandomColor = (): string => {
|
||||||
return '#' + String(Math.floor(Math.random() * 16777215).toString(16)).padStart(6, '0');
|
return '#' + String(Math.floor(Math.random() * 16777215).toString(16)).padStart(6, '0');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const duplicateTagNameErrorMessage = i18n.translate(
|
||||||
|
'xpack.savedObjectsTagging.validation.name.duplicateError',
|
||||||
|
{
|
||||||
|
defaultMessage: 'Name has already been taken.',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const validateTag = (tag: TagAttributes): TagValidation => {
|
export const validateTag = (tag: TagAttributes): TagValidation => {
|
||||||
const validation: TagValidation = {
|
const validation: TagValidation = {
|
||||||
valid: true,
|
valid: true,
|
||||||
|
|
|
@ -27,7 +27,7 @@ export const getEditAction = ({
|
||||||
tagClient,
|
tagClient,
|
||||||
fetchTags,
|
fetchTags,
|
||||||
}: GetEditActionOptions): TagAction => {
|
}: GetEditActionOptions): TagAction => {
|
||||||
const editModalOpener = getEditModalOpener({ overlays, theme, tagClient });
|
const editModalOpener = getEditModalOpener({ overlays, theme, tagClient, notifications });
|
||||||
return {
|
return {
|
||||||
id: 'edit',
|
id: 'edit',
|
||||||
name: ({ name }) =>
|
name: ({ name }) =>
|
||||||
|
|
|
@ -75,8 +75,8 @@ export const TagManagementPage: FC<TagManagementPageParams> = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
const createModalOpener = useMemo(
|
const createModalOpener = useMemo(
|
||||||
() => getCreateModalOpener({ overlays, theme, tagClient }),
|
() => getCreateModalOpener({ overlays, theme, tagClient, notifications }),
|
||||||
[overlays, theme, tagClient]
|
[overlays, theme, tagClient, notifications]
|
||||||
);
|
);
|
||||||
|
|
||||||
const tableActions = useMemo(() => {
|
const tableActions = useMemo(() => {
|
||||||
|
|
|
@ -67,7 +67,7 @@ export class SavedObjectTaggingPlugin
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
public start({ http, application, overlays, theme, analytics }: CoreStart) {
|
public start({ http, application, overlays, theme, analytics, notifications }: CoreStart) {
|
||||||
this.tagCache = new TagsCache({
|
this.tagCache = new TagsCache({
|
||||||
refreshHandler: () => this.tagClient!.getAll({ asSystemRequest: true }),
|
refreshHandler: () => this.tagClient!.getAll({ asSystemRequest: true }),
|
||||||
refreshInterval: this.config.cacheRefreshInterval,
|
refreshInterval: this.config.cacheRefreshInterval,
|
||||||
|
@ -92,6 +92,7 @@ export class SavedObjectTaggingPlugin
|
||||||
capabilities: getTagsCapabilities(application.capabilities),
|
capabilities: getTagsCapabilities(application.capabilities),
|
||||||
overlays,
|
overlays,
|
||||||
theme,
|
theme,
|
||||||
|
notifications,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ const createInternalClientMock = () => {
|
||||||
delete: jest.fn(),
|
delete: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
find: jest.fn(),
|
find: jest.fn(),
|
||||||
|
findByName: jest.fn(),
|
||||||
bulkDelete: jest.fn(),
|
bulkDelete: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -173,6 +173,15 @@ export class TagsClient implements ITagInternalClient {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async findByName(name: string, { exact }: { exact?: boolean } = { exact: false }) {
|
||||||
|
const { tags = [] } = await this.find({ page: 1, perPage: 10000, search: name });
|
||||||
|
if (exact) {
|
||||||
|
const tag = tags.find((t) => t.name.toLocaleLowerCase() === name.toLocaleLowerCase());
|
||||||
|
return tag ?? null;
|
||||||
|
}
|
||||||
|
return tags.length > 0 ? tags[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
public async bulkDelete(tagIds: string[]) {
|
public async bulkDelete(tagIds: string[]) {
|
||||||
const startTime = window.performance.now();
|
const startTime = window.performance.now();
|
||||||
await this.http.post<{}>('/internal/saved_objects_tagging/tags/_bulk_delete', {
|
await this.http.post<{}>('/internal/saved_objects_tagging/tags/_bulk_delete', {
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
* 2.0.
|
* 2.0.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { OverlayStart, ThemeServiceStart } from '@kbn/core/public';
|
import { NotificationsStart, OverlayStart, ThemeServiceStart } from '@kbn/core/public';
|
||||||
import { SavedObjectsTaggingApiUiComponent } from '@kbn/saved-objects-tagging-oss-plugin/public';
|
import { SavedObjectsTaggingApiUiComponent } from '@kbn/saved-objects-tagging-oss-plugin/public';
|
||||||
import { TagsCapabilities } from '../../common';
|
import { TagsCapabilities } from '../../common';
|
||||||
import { ITagInternalClient, ITagsCache } from '../services';
|
import { ITagInternalClient, ITagsCache } from '../services';
|
||||||
|
@ -22,6 +22,7 @@ export interface GetComponentsOptions {
|
||||||
overlays: OverlayStart;
|
overlays: OverlayStart;
|
||||||
theme: ThemeServiceStart;
|
theme: ThemeServiceStart;
|
||||||
tagClient: ITagInternalClient;
|
tagClient: ITagInternalClient;
|
||||||
|
notifications: NotificationsStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getComponents = ({
|
export const getComponents = ({
|
||||||
|
@ -30,8 +31,9 @@ export const getComponents = ({
|
||||||
overlays,
|
overlays,
|
||||||
theme,
|
theme,
|
||||||
tagClient,
|
tagClient,
|
||||||
|
notifications,
|
||||||
}: GetComponentsOptions): SavedObjectsTaggingApiUiComponent => {
|
}: GetComponentsOptions): SavedObjectsTaggingApiUiComponent => {
|
||||||
const openCreateModal = getCreateModalOpener({ overlays, theme, tagClient });
|
const openCreateModal = getCreateModalOpener({ overlays, theme, tagClient, notifications });
|
||||||
return {
|
return {
|
||||||
TagList: getConnectedTagListComponent({ cache }),
|
TagList: getConnectedTagListComponent({ cache }),
|
||||||
TagSelector: getConnectedTagSelectorComponent({ cache, capabilities, openCreateModal }),
|
TagSelector: getConnectedTagSelectorComponent({ cache, capabilities, openCreateModal }),
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
* 2.0.
|
* 2.0.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { OverlayStart, ThemeServiceStart } from '@kbn/core/public';
|
import type { NotificationsStart, OverlayStart, ThemeServiceStart } from '@kbn/core/public';
|
||||||
import { SavedObjectsTaggingApiUi } from '@kbn/saved-objects-tagging-oss-plugin/public';
|
import { SavedObjectsTaggingApiUi } from '@kbn/saved-objects-tagging-oss-plugin/public';
|
||||||
import { TagsCapabilities } from '../../common';
|
import { TagsCapabilities } from '../../common';
|
||||||
import { ITagsCache, ITagInternalClient } from '../services';
|
import { ITagsCache, ITagInternalClient } from '../services';
|
||||||
|
@ -29,6 +29,7 @@ interface GetUiApiOptions {
|
||||||
capabilities: TagsCapabilities;
|
capabilities: TagsCapabilities;
|
||||||
cache: ITagsCache;
|
cache: ITagsCache;
|
||||||
client: ITagInternalClient;
|
client: ITagInternalClient;
|
||||||
|
notifications: NotificationsStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getUiApi = ({
|
export const getUiApi = ({
|
||||||
|
@ -37,8 +38,16 @@ export const getUiApi = ({
|
||||||
client,
|
client,
|
||||||
overlays,
|
overlays,
|
||||||
theme,
|
theme,
|
||||||
|
notifications,
|
||||||
}: GetUiApiOptions): SavedObjectsTaggingApiUi => {
|
}: GetUiApiOptions): SavedObjectsTaggingApiUi => {
|
||||||
const components = getComponents({ cache, capabilities, overlays, theme, tagClient: client });
|
const components = getComponents({
|
||||||
|
cache,
|
||||||
|
capabilities,
|
||||||
|
overlays,
|
||||||
|
theme,
|
||||||
|
tagClient: client,
|
||||||
|
notifications,
|
||||||
|
});
|
||||||
|
|
||||||
const getTagList = buildGetTagList(cache);
|
const getTagList = buildGetTagList(cache);
|
||||||
|
|
||||||
|
|
|
@ -33,7 +33,7 @@ export const registerInternalFindTagsRoute = (router: TagsPluginRouter) => {
|
||||||
perPage: query.perPage,
|
perPage: query.perPage,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
type: [tagSavedObjectTypeName],
|
type: [tagSavedObjectTypeName],
|
||||||
searchFields: ['title', 'description'],
|
searchFields: ['name', 'description'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const tags = findResponse.saved_objects.map(savedObjectToTag);
|
const tags = findResponse.saved_objects.map(savedObjectToTag);
|
||||||
|
|
|
@ -24,6 +24,14 @@ export const registerCreateTagRoute = (router: TagsPluginRouter) => {
|
||||||
router.handleLegacyErrors(async (ctx, req, res) => {
|
router.handleLegacyErrors(async (ctx, req, res) => {
|
||||||
try {
|
try {
|
||||||
const { tagsClient } = await ctx.tags;
|
const { tagsClient } = await ctx.tags;
|
||||||
|
|
||||||
|
const existingTag = await tagsClient.findByName(req.body.name, { exact: true });
|
||||||
|
if (existingTag) {
|
||||||
|
return res.conflict({
|
||||||
|
body: `A tag with the name "${req.body.name}" already exists.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const tag = await tagsClient.create(req.body);
|
const tag = await tagsClient.create(req.body);
|
||||||
return res.ok({
|
return res.ok({
|
||||||
body: {
|
body: {
|
||||||
|
|
|
@ -28,6 +28,14 @@ export const registerUpdateTagRoute = (router: TagsPluginRouter) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
try {
|
try {
|
||||||
const { tagsClient } = await ctx.tags;
|
const { tagsClient } = await ctx.tags;
|
||||||
|
|
||||||
|
const existingTag = await tagsClient.findByName(req.body.name, { exact: true });
|
||||||
|
if (existingTag && existingTag.id !== id) {
|
||||||
|
return res.conflict({
|
||||||
|
body: `A tag with the name "${req.body.name}" already exists.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const tag = await tagsClient.update(id, req.body);
|
const tag = await tagsClient.update(id, req.body);
|
||||||
return res.ok({
|
return res.ok({
|
||||||
body: {
|
body: {
|
||||||
|
|
|
@ -14,6 +14,7 @@ const createClientMock = () => {
|
||||||
getAll: jest.fn(),
|
getAll: jest.fn(),
|
||||||
delete: jest.fn(),
|
delete: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
|
findByName: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return mock;
|
return mock;
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { SavedObjectsClientContract } from '@kbn/core/server';
|
import { SavedObjectsClientContract } from '@kbn/core/server';
|
||||||
import { CreateTagOptions } from '@kbn/saved-objects-tagging-oss-plugin/common/types';
|
import { CreateTagOptions, Tag } from '@kbn/saved-objects-tagging-oss-plugin/common/types';
|
||||||
import { TagSavedObject, TagAttributes, ITagsClient } from '../../../common/types';
|
import { TagSavedObject, TagAttributes, ITagsClient } from '../../../common/types';
|
||||||
import { tagSavedObjectTypeName } from '../../../common/constants';
|
import { tagSavedObjectTypeName } from '../../../common/constants';
|
||||||
import { TagValidationError } from './errors';
|
import { TagValidationError } from './errors';
|
||||||
|
@ -63,6 +63,28 @@ export class TagsClient implements ITagsClient {
|
||||||
return results.map(savedObjectToTag);
|
return results.map(savedObjectToTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async findByName(
|
||||||
|
name: string,
|
||||||
|
{ exact = false }: { exact?: boolean | undefined } = {}
|
||||||
|
): Promise<Tag | null> {
|
||||||
|
const response = await this.soClient.find<TagAttributes>({
|
||||||
|
type: this.type,
|
||||||
|
search: name,
|
||||||
|
searchFields: ['name'],
|
||||||
|
perPage: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.total === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = exact
|
||||||
|
? response.saved_objects.find((t) => t.attributes.name.toLowerCase() === name.toLowerCase())
|
||||||
|
: response.saved_objects[0];
|
||||||
|
|
||||||
|
return tag ? savedObjectToTag(tag) : null;
|
||||||
|
}
|
||||||
|
|
||||||
public async delete(id: string) {
|
public async delete(id: string) {
|
||||||
// `removeReferencesTo` security check is the same as a `delete` operation's, so we can use the scoped client here.
|
// `removeReferencesTo` security check is the same as a `delete` operation's, so we can use the scoped client here.
|
||||||
// If that was to change, we would need to use the internal client instead. A FTR test is ensuring
|
// If that was to change, we would need to use the internal client instead. A FTR test is ensuring
|
||||||
|
|
|
@ -40,11 +40,23 @@ export const createTags = async ({ getService }: FtrProviderContext) => {
|
||||||
|
|
||||||
export const deleteTags = async ({ getService }: FtrProviderContext) => {
|
export const deleteTags = async ({ getService }: FtrProviderContext) => {
|
||||||
const kibanaServer = getService('kibanaServer');
|
const kibanaServer = getService('kibanaServer');
|
||||||
await kibanaServer.importExport.unload(
|
while (true) {
|
||||||
'x-pack/test/saved_object_tagging/common/fixtures/es_archiver/rbac_tags/default_space.json'
|
const defaultTags = await kibanaServer.savedObjects.find({ type: 'tag', space: 'default' });
|
||||||
);
|
const spaceTags = await kibanaServer.savedObjects.find({ type: 'tag', space: 'space_1' });
|
||||||
await kibanaServer.importExport.unload(
|
if (defaultTags.saved_objects.length === 0 && spaceTags.saved_objects.length === 0) {
|
||||||
'x-pack/test/saved_object_tagging/common/fixtures/es_archiver/rbac_tags/space_1.json',
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
{ space: 'space_1' }
|
break;
|
||||||
);
|
}
|
||||||
|
if (defaultTags.saved_objects.length !== 0) {
|
||||||
|
await kibanaServer.savedObjects.bulkDelete({
|
||||||
|
objects: defaultTags.saved_objects.map(({ type, id }) => ({ type, id })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (spaceTags.saved_objects.length !== 0) {
|
||||||
|
await kibanaServer.savedObjects.bulkDelete({
|
||||||
|
objects: spaceTags.saved_objects.map(({ type, id }) => ({ type, id })),
|
||||||
|
space: 'space_1',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -59,6 +59,8 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await supertest.delete(`/api/saved_objects_tagging/tags/${newTagId}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return an error with details when validation failed', async () => {
|
it('should return an error with details when validation failed', async () => {
|
||||||
|
@ -86,5 +88,24 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('cannot create a new tag with existing name', async () => {
|
||||||
|
const existingName = 'tag-1';
|
||||||
|
await supertest
|
||||||
|
.post(`/api/saved_objects_tagging/tags/create`)
|
||||||
|
.send({
|
||||||
|
name: existingName,
|
||||||
|
description: 'some desc',
|
||||||
|
color: '#000000',
|
||||||
|
})
|
||||||
|
.expect(409)
|
||||||
|
.then(({ body }) => {
|
||||||
|
expect(body).to.eql({
|
||||||
|
statusCode: 409,
|
||||||
|
error: 'Conflict',
|
||||||
|
message: `A tag with the name "${existingName}" already exists.`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,5 @@ export default function ({ loadTestFile }: FtrProviderContext) {
|
||||||
loadTestFile(require.resolve('./create'));
|
loadTestFile(require.resolve('./create'));
|
||||||
loadTestFile(require.resolve('./update'));
|
loadTestFile(require.resolve('./update'));
|
||||||
loadTestFile(require.resolve('./bulk_assign'));
|
loadTestFile(require.resolve('./bulk_assign'));
|
||||||
loadTestFile(require.resolve('./usage_collection'));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -61,6 +61,39 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not allow updating tag name to an existing name', async () => {
|
||||||
|
const existingName = 'tag-3';
|
||||||
|
await supertest
|
||||||
|
.post(`/api/saved_objects_tagging/tags/tag-2`)
|
||||||
|
.send({
|
||||||
|
name: existingName,
|
||||||
|
description: 'updated desc',
|
||||||
|
color: '#123456',
|
||||||
|
})
|
||||||
|
.expect(409)
|
||||||
|
.then(({ body }) => {
|
||||||
|
expect(body).to.eql({
|
||||||
|
statusCode: 409,
|
||||||
|
error: 'Conflict',
|
||||||
|
message: `A tag with the name "${existingName}" already exists.`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await supertest
|
||||||
|
.get(`/api/saved_objects_tagging/tags/tag-3`)
|
||||||
|
.expect(200)
|
||||||
|
.then(({ body }) => {
|
||||||
|
expect(body).to.eql({
|
||||||
|
tag: {
|
||||||
|
id: 'tag-3',
|
||||||
|
name: 'tag-3',
|
||||||
|
description: 'Last but not least',
|
||||||
|
color: '#000000',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should return a 404 when trying to update a non existing tag', async () => {
|
it('should return a 404 when trying to update a non existing tag', async () => {
|
||||||
await supertest
|
await supertest
|
||||||
.post(`/api/saved_objects_tagging/tags/unknown-tag-id`)
|
.post(`/api/saved_objects_tagging/tags/unknown-tag-id`)
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FtrConfigProviderContext } from '@kbn/test';
|
||||||
|
import { services } from './services';
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-default-export
|
||||||
|
export default async function ({ readConfigFile }: FtrConfigProviderContext) {
|
||||||
|
const apiIntegrationConfig = await readConfigFile(
|
||||||
|
require.resolve('../../../api_integration/config.ts')
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
testFiles: [require.resolve('./tests')],
|
||||||
|
servers: apiIntegrationConfig.get('servers'),
|
||||||
|
services,
|
||||||
|
junit: {
|
||||||
|
reportName: 'X-Pack Saved Object Tagging Usage Collection',
|
||||||
|
},
|
||||||
|
esTestCluster: {
|
||||||
|
...apiIntegrationConfig.get('esTestCluster'),
|
||||||
|
license: 'trial',
|
||||||
|
},
|
||||||
|
kbnTestServer: {
|
||||||
|
...apiIntegrationConfig.get('kbnTestServer'),
|
||||||
|
serverArgs: [
|
||||||
|
...apiIntegrationConfig.get('kbnTestServer.serverArgs'),
|
||||||
|
'--server.xsrf.disableProtection=true',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { GenericFtrProviderContext } from '@kbn/test';
|
||||||
|
import { services as apiIntegrationServices } from '../../../api_integration/services';
|
||||||
|
|
||||||
|
export const services = {
|
||||||
|
...apiIntegrationServices,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FtrProviderContext = GenericFtrProviderContext<typeof services, {}>;
|
|
@ -6,7 +6,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import expect from '@kbn/expect';
|
import expect from '@kbn/expect';
|
||||||
import { FtrProviderContext } from '../services';
|
import { FtrProviderContext } from './services';
|
||||||
|
|
||||||
// eslint-disable-next-line import/no-default-export
|
// eslint-disable-next-line import/no-default-export
|
||||||
export default function ({ getService }: FtrProviderContext) {
|
export default function ({ getService }: FtrProviderContext) {
|
||||||
|
@ -15,6 +15,7 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
|
|
||||||
describe('saved_object_tagging usage collector data', () => {
|
describe('saved_object_tagging usage collector data', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
await kibanaServer.savedObjects.cleanStandardList();
|
||||||
await kibanaServer.importExport.load(
|
await kibanaServer.importExport.load(
|
||||||
'x-pack/test/saved_object_tagging/common/fixtures/es_archiver/usage_collection/data.json'
|
'x-pack/test/saved_object_tagging/common/fixtures/es_archiver/usage_collection/data.json'
|
||||||
);
|
);
|
Loading…
Add table
Add a link
Reference in a new issue