[Tags] Prevent duplicates (#167072)

This commit is contained in:
Vadim Kibana 2023-09-28 18:53:31 +02:00 committed by GitHub
parent 6ac03d739a
commit 726f212d0c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 451 additions and 42 deletions

View file

@ -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

View file

@ -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>;
} }

View file

@ -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;

View file

@ -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}
/> />
); );
}; };

View file

@ -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 ? (

View file

@ -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}
/> />
); );
}; };

View file

@ -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$ }

View file

@ -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,
};
};

View file

@ -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,

View file

@ -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 }) =>

View file

@ -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(() => {

View file

@ -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,
}), }),
}; };
} }

View file

@ -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(),
}; };

View file

@ -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', {

View file

@ -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 }),

View file

@ -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);

View file

@ -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);

View file

@ -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: {

View file

@ -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: {

View file

@ -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;

View file

@ -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

View file

@ -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',
});
}
}
}; };

View file

@ -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.`,
});
});
});
}); });
} }

View file

@ -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'));
}); });
} }

View file

@ -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`)

View file

@ -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',
],
},
};
}

View file

@ -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, {}>;

View file

@ -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'
); );