mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -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_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_usage_collection/config.ts
|
||||
- x-pack/test/saved_object_tagging/functional/config.ts
|
||||
- x-pack/test/saved_objects_field_count/config.ts
|
||||
- x-pack/test/search_sessions_integration/config.ts
|
||||
|
|
|
@ -35,6 +35,7 @@ export interface ITagsClient {
|
|||
create(attributes: TagAttributes, options?: CreateTagOptions): Promise<Tag>;
|
||||
get(id: string): Promise<Tag>;
|
||||
getAll(options?: GetAllTagsOptions): Promise<Tag[]>;
|
||||
findByName(name: string, options?: { exact?: boolean }): Promise<Tag | null>;
|
||||
delete(id: string): Promise<void>;
|
||||
update(id: string, attributes: TagAttributes): Promise<Tag>;
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ const createClientMock = () => {
|
|||
getAll: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
update: jest.fn(),
|
||||
findByName: jest.fn(),
|
||||
};
|
||||
|
||||
return mock;
|
||||
|
|
|
@ -6,17 +6,22 @@
|
|||
*/
|
||||
|
||||
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 { TagValidation } from '../../../common/validation';
|
||||
import { isServerValidationError } from '../../services/tags';
|
||||
import { getRandomColor, validateTag } from './utils';
|
||||
import { CreateOrEditModal } from './create_or_edit_modal';
|
||||
import { useValidation } from './use_validation';
|
||||
|
||||
interface CreateTagModalProps {
|
||||
defaultValues?: Partial<TagAttributes>;
|
||||
onClose: () => void;
|
||||
onSave: (tag: Tag) => void;
|
||||
tagClient: ITagsClient;
|
||||
notifications: NotificationsStart;
|
||||
}
|
||||
|
||||
const getDefaultAttributes = (providedDefaults?: Partial<TagAttributes>): TagAttributes => ({
|
||||
|
@ -26,22 +31,21 @@ const getDefaultAttributes = (providedDefaults?: Partial<TagAttributes>): TagAtt
|
|||
...providedDefaults,
|
||||
});
|
||||
|
||||
const initialValidation: TagValidation = {
|
||||
valid: true,
|
||||
warnings: [],
|
||||
errors: {},
|
||||
};
|
||||
|
||||
export const CreateTagModal: FC<CreateTagModalProps> = ({
|
||||
defaultValues,
|
||||
tagClient,
|
||||
notifications,
|
||||
onClose,
|
||||
onSave,
|
||||
}) => {
|
||||
const [validation, setValidation] = useState<TagValidation>(initialValidation);
|
||||
const [tagAttributes, setTagAttributes] = useState<TagAttributes>(
|
||||
getDefaultAttributes(defaultValues)
|
||||
);
|
||||
const { validation, setValidation, onNameChange, validation$, isValidating } = useValidation({
|
||||
tagAttributes,
|
||||
tagClient,
|
||||
validateDuplicateNameOnMount: true,
|
||||
});
|
||||
|
||||
const setField = useCallback(
|
||||
<T extends keyof TagAttributes>(field: T) =>
|
||||
|
@ -55,6 +59,14 @@ export const CreateTagModal: FC<CreateTagModalProps> = ({
|
|||
);
|
||||
|
||||
const onSubmit = useCallback(async () => {
|
||||
const { hasDuplicateNameError } = await lastValueFrom(
|
||||
validation$.pipe(first((v) => v.isValidating === false))
|
||||
);
|
||||
|
||||
if (hasDuplicateNameError) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientValidation = validateTag(tagAttributes);
|
||||
setValidation(clientValidation);
|
||||
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 (isServerValidationError(e.body)) {
|
||||
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 (
|
||||
<CreateOrEditModal
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
onNameChange={onNameChange}
|
||||
mode={'create'}
|
||||
tag={tagAttributes}
|
||||
setField={setField}
|
||||
validation={validation}
|
||||
isValidating={isValidating}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { FC, useState, useCallback, useMemo } from 'react';
|
||||
import React, { FC, useState, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
EuiButtonEmpty,
|
||||
EuiButton,
|
||||
|
@ -27,6 +27,7 @@ import {
|
|||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import useDebounce from 'react-use/lib/useDebounce';
|
||||
import {
|
||||
TagAttributes,
|
||||
TagValidation,
|
||||
|
@ -40,16 +41,23 @@ import { getRandomColor, useIfMounted } from './utils';
|
|||
interface CreateOrEditModalProps {
|
||||
onClose: () => void;
|
||||
onSubmit: () => Promise<void>;
|
||||
onNameChange: (
|
||||
name: string,
|
||||
options?: { debounced?: boolean; hasBeenModified?: boolean }
|
||||
) => Promise<void>;
|
||||
mode: 'create' | 'edit';
|
||||
tag: TagAttributes;
|
||||
validation: TagValidation;
|
||||
isValidating: boolean;
|
||||
setField: <T extends keyof TagAttributes>(field: T) => (value: TagAttributes[T]) => void;
|
||||
}
|
||||
|
||||
export const CreateOrEditModal: FC<CreateOrEditModalProps> = ({
|
||||
onClose,
|
||||
onSubmit,
|
||||
onNameChange,
|
||||
validation,
|
||||
isValidating,
|
||||
setField,
|
||||
tag,
|
||||
mode,
|
||||
|
@ -57,6 +65,7 @@ export const CreateOrEditModal: FC<CreateOrEditModalProps> = ({
|
|||
const optionalMessageId = htmlIdGenerator()();
|
||||
const ifMounted = useIfMounted();
|
||||
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
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
@ -68,6 +77,8 @@ export const CreateOrEditModal: FC<CreateOrEditModalProps> = ({
|
|||
tag.description !== initialTag.description,
|
||||
[initialTag, tag]
|
||||
);
|
||||
const nameHasBeenModified = tag.name !== lastNameValue.current;
|
||||
|
||||
const setName = useMemo(() => setField('name'), [setField]);
|
||||
const setColor = useMemo(() => setField('color'), [setField]);
|
||||
const setDescription = useMemo(() => setField('description'), [setField]);
|
||||
|
@ -91,6 +102,15 @@ export const CreateOrEditModal: FC<CreateOrEditModalProps> = ({
|
|||
});
|
||||
}, [ifMounted, onSubmit]);
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
onNameChange(tag.name, { debounced: true, hasBeenModified: nameHasBeenModified });
|
||||
lastNameValue.current = tag.name;
|
||||
},
|
||||
300,
|
||||
[tag.name, nameHasBeenModified]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiModal onClose={onClose} initialFocus="[name=name]" style={{ minWidth: '600px' }}>
|
||||
<EuiModalHeader>
|
||||
|
@ -130,6 +150,7 @@ export const CreateOrEditModal: FC<CreateOrEditModalProps> = ({
|
|||
maxLength={tagNameMaxLength}
|
||||
value={tag.name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
isLoading={isValidating}
|
||||
data-test-subj="createModalField-name"
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
@ -238,6 +259,7 @@ export const CreateOrEditModal: FC<CreateOrEditModalProps> = ({
|
|||
fill
|
||||
data-test-subj="createModalConfirmButton"
|
||||
onClick={onFormSubmit}
|
||||
isLoading={submitting}
|
||||
isDisabled={submitting || (isEdit && !tagHasBeenModified)}
|
||||
>
|
||||
{isEdit ? (
|
||||
|
|
|
@ -6,33 +6,40 @@
|
|||
*/
|
||||
|
||||
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 { TagValidation } from '../../../common/validation';
|
||||
import { isServerValidationError } from '../../services/tags';
|
||||
import { CreateOrEditModal } from './create_or_edit_modal';
|
||||
import { validateTag } from './utils';
|
||||
import { useValidation } from './use_validation';
|
||||
|
||||
interface EditTagModalProps {
|
||||
tag: Tag;
|
||||
onClose: () => void;
|
||||
onSave: (tag: Tag) => void;
|
||||
tagClient: ITagsClient;
|
||||
notifications: NotificationsStart;
|
||||
}
|
||||
|
||||
const initialValidation: TagValidation = {
|
||||
valid: true,
|
||||
warnings: [],
|
||||
errors: {},
|
||||
};
|
||||
|
||||
const getAttributes = (tag: Tag): TagAttributes => {
|
||||
const { id, ...attributes } = tag;
|
||||
return attributes;
|
||||
};
|
||||
|
||||
export const EditTagModal: FC<EditTagModalProps> = ({ tag, onSave, onClose, tagClient }) => {
|
||||
const [validation, setValidation] = useState<TagValidation>(initialValidation);
|
||||
export const EditTagModal: FC<EditTagModalProps> = ({
|
||||
tag,
|
||||
onSave,
|
||||
onClose,
|
||||
tagClient,
|
||||
notifications,
|
||||
}) => {
|
||||
const [tagAttributes, setTagAttributes] = useState<TagAttributes>(getAttributes(tag));
|
||||
const { validation, setValidation, onNameChange, isValidating, validation$ } = useValidation({
|
||||
tagAttributes,
|
||||
tagClient,
|
||||
});
|
||||
|
||||
const setField = useCallback(
|
||||
<T extends keyof TagAttributes>(field: T) =>
|
||||
|
@ -46,8 +53,17 @@ export const EditTagModal: FC<EditTagModalProps> = ({ tag, onSave, onClose, tagC
|
|||
);
|
||||
|
||||
const onSubmit = useCallback(async () => {
|
||||
const { hasDuplicateNameError } = await lastValueFrom(
|
||||
validation$.pipe(first((v) => v.isValidating === false))
|
||||
);
|
||||
|
||||
if (hasDuplicateNameError) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientValidation = validateTag(tagAttributes);
|
||||
setValidation(clientValidation);
|
||||
|
||||
if (!clientValidation.valid) {
|
||||
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 (isServerValidationError(e.body)) {
|
||||
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 (
|
||||
<CreateOrEditModal
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
onNameChange={onNameChange}
|
||||
mode={'edit'}
|
||||
tag={tagAttributes}
|
||||
setField={setField}
|
||||
validation={validation}
|
||||
isValidating={isValidating}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -7,13 +7,19 @@
|
|||
|
||||
import React from 'react';
|
||||
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 { Tag, TagAttributes } from '../../../common/types';
|
||||
import { ITagInternalClient } from '../../services';
|
||||
|
||||
interface GetModalOpenerOptions {
|
||||
overlays: OverlayStart;
|
||||
notifications: NotificationsStart;
|
||||
theme: ThemeServiceStart;
|
||||
tagClient: ITagInternalClient;
|
||||
}
|
||||
|
@ -40,7 +46,7 @@ const LazyEditTagModal = React.lazy(() =>
|
|||
);
|
||||
|
||||
export const getCreateModalOpener =
|
||||
({ overlays, theme, tagClient }: GetModalOpenerOptions): CreateModalOpener =>
|
||||
({ overlays, theme, tagClient, notifications }: GetModalOpenerOptions): CreateModalOpener =>
|
||||
async ({ onCreate, defaultValues }: OpenCreateModalOptions) => {
|
||||
const modal = overlays.openModal(
|
||||
toMountPoint(
|
||||
|
@ -55,6 +61,7 @@ export const getCreateModalOpener =
|
|||
onCreate(tag);
|
||||
}}
|
||||
tagClient={tagClient}
|
||||
notifications={notifications}
|
||||
/>
|
||||
</React.Suspense>,
|
||||
{ theme$: theme.theme$ }
|
||||
|
@ -69,7 +76,7 @@ interface OpenEditModalOptions {
|
|||
}
|
||||
|
||||
export const getEditModalOpener =
|
||||
({ overlays, theme, tagClient }: GetModalOpenerOptions) =>
|
||||
({ overlays, theme, tagClient, notifications }: GetModalOpenerOptions) =>
|
||||
async ({ tagId, onUpdate }: OpenEditModalOptions) => {
|
||||
const tag = await tagClient.get(tagId);
|
||||
|
||||
|
@ -86,6 +93,7 @@ export const getEditModalOpener =
|
|||
onUpdate(saved);
|
||||
}}
|
||||
tagClient={tagClient}
|
||||
notifications={notifications}
|
||||
/>
|
||||
</React.Suspense>,
|
||||
{ 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 { i18n } from '@kbn/i18n';
|
||||
|
||||
import {
|
||||
TagAttributes,
|
||||
TagValidation,
|
||||
|
@ -21,6 +23,13 @@ export const getRandomColor = (): string => {
|
|||
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 => {
|
||||
const validation: TagValidation = {
|
||||
valid: true,
|
||||
|
|
|
@ -27,7 +27,7 @@ export const getEditAction = ({
|
|||
tagClient,
|
||||
fetchTags,
|
||||
}: GetEditActionOptions): TagAction => {
|
||||
const editModalOpener = getEditModalOpener({ overlays, theme, tagClient });
|
||||
const editModalOpener = getEditModalOpener({ overlays, theme, tagClient, notifications });
|
||||
return {
|
||||
id: 'edit',
|
||||
name: ({ name }) =>
|
||||
|
|
|
@ -75,8 +75,8 @@ export const TagManagementPage: FC<TagManagementPageParams> = ({
|
|||
});
|
||||
|
||||
const createModalOpener = useMemo(
|
||||
() => getCreateModalOpener({ overlays, theme, tagClient }),
|
||||
[overlays, theme, tagClient]
|
||||
() => getCreateModalOpener({ overlays, theme, tagClient, notifications }),
|
||||
[overlays, theme, tagClient, notifications]
|
||||
);
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
|
|
|
@ -67,7 +67,7 @@ export class SavedObjectTaggingPlugin
|
|||
return {};
|
||||
}
|
||||
|
||||
public start({ http, application, overlays, theme, analytics }: CoreStart) {
|
||||
public start({ http, application, overlays, theme, analytics, notifications }: CoreStart) {
|
||||
this.tagCache = new TagsCache({
|
||||
refreshHandler: () => this.tagClient!.getAll({ asSystemRequest: true }),
|
||||
refreshInterval: this.config.cacheRefreshInterval,
|
||||
|
@ -92,6 +92,7 @@ export class SavedObjectTaggingPlugin
|
|||
capabilities: getTagsCapabilities(application.capabilities),
|
||||
overlays,
|
||||
theme,
|
||||
notifications,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ const createInternalClientMock = () => {
|
|||
delete: jest.fn(),
|
||||
update: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findByName: jest.fn(),
|
||||
bulkDelete: jest.fn(),
|
||||
};
|
||||
|
||||
|
|
|
@ -173,6 +173,15 @@ export class TagsClient implements ITagInternalClient {
|
|||
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[]) {
|
||||
const startTime = window.performance.now();
|
||||
await this.http.post<{}>('/internal/saved_objects_tagging/tags/_bulk_delete', {
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 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 { TagsCapabilities } from '../../common';
|
||||
import { ITagInternalClient, ITagsCache } from '../services';
|
||||
|
@ -22,6 +22,7 @@ export interface GetComponentsOptions {
|
|||
overlays: OverlayStart;
|
||||
theme: ThemeServiceStart;
|
||||
tagClient: ITagInternalClient;
|
||||
notifications: NotificationsStart;
|
||||
}
|
||||
|
||||
export const getComponents = ({
|
||||
|
@ -30,8 +31,9 @@ export const getComponents = ({
|
|||
overlays,
|
||||
theme,
|
||||
tagClient,
|
||||
notifications,
|
||||
}: GetComponentsOptions): SavedObjectsTaggingApiUiComponent => {
|
||||
const openCreateModal = getCreateModalOpener({ overlays, theme, tagClient });
|
||||
const openCreateModal = getCreateModalOpener({ overlays, theme, tagClient, notifications });
|
||||
return {
|
||||
TagList: getConnectedTagListComponent({ cache }),
|
||||
TagSelector: getConnectedTagSelectorComponent({ cache, capabilities, openCreateModal }),
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 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 { TagsCapabilities } from '../../common';
|
||||
import { ITagsCache, ITagInternalClient } from '../services';
|
||||
|
@ -29,6 +29,7 @@ interface GetUiApiOptions {
|
|||
capabilities: TagsCapabilities;
|
||||
cache: ITagsCache;
|
||||
client: ITagInternalClient;
|
||||
notifications: NotificationsStart;
|
||||
}
|
||||
|
||||
export const getUiApi = ({
|
||||
|
@ -37,8 +38,16 @@ export const getUiApi = ({
|
|||
client,
|
||||
overlays,
|
||||
theme,
|
||||
notifications,
|
||||
}: 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);
|
||||
|
||||
|
|
|
@ -33,7 +33,7 @@ export const registerInternalFindTagsRoute = (router: TagsPluginRouter) => {
|
|||
perPage: query.perPage,
|
||||
search: query.search,
|
||||
type: [tagSavedObjectTypeName],
|
||||
searchFields: ['title', 'description'],
|
||||
searchFields: ['name', 'description'],
|
||||
});
|
||||
|
||||
const tags = findResponse.saved_objects.map(savedObjectToTag);
|
||||
|
|
|
@ -24,6 +24,14 @@ export const registerCreateTagRoute = (router: TagsPluginRouter) => {
|
|||
router.handleLegacyErrors(async (ctx, req, res) => {
|
||||
try {
|
||||
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);
|
||||
return res.ok({
|
||||
body: {
|
||||
|
|
|
@ -28,6 +28,14 @@ export const registerUpdateTagRoute = (router: TagsPluginRouter) => {
|
|||
const { id } = req.params;
|
||||
try {
|
||||
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);
|
||||
return res.ok({
|
||||
body: {
|
||||
|
|
|
@ -14,6 +14,7 @@ const createClientMock = () => {
|
|||
getAll: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
update: jest.fn(),
|
||||
findByName: jest.fn(),
|
||||
};
|
||||
|
||||
return mock;
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
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 { tagSavedObjectTypeName } from '../../../common/constants';
|
||||
import { TagValidationError } from './errors';
|
||||
|
@ -63,6 +63,28 @@ export class TagsClient implements ITagsClient {
|
|||
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) {
|
||||
// `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
|
||||
|
|
|
@ -40,11 +40,23 @@ export const createTags = async ({ getService }: FtrProviderContext) => {
|
|||
|
||||
export const deleteTags = async ({ getService }: FtrProviderContext) => {
|
||||
const kibanaServer = getService('kibanaServer');
|
||||
await kibanaServer.importExport.unload(
|
||||
'x-pack/test/saved_object_tagging/common/fixtures/es_archiver/rbac_tags/default_space.json'
|
||||
);
|
||||
await kibanaServer.importExport.unload(
|
||||
'x-pack/test/saved_object_tagging/common/fixtures/es_archiver/rbac_tags/space_1.json',
|
||||
{ space: 'space_1' }
|
||||
);
|
||||
while (true) {
|
||||
const defaultTags = await kibanaServer.savedObjects.find({ type: 'tag', space: 'default' });
|
||||
const spaceTags = await kibanaServer.savedObjects.find({ type: 'tag', space: 'space_1' });
|
||||
if (defaultTags.saved_objects.length === 0 && spaceTags.saved_objects.length === 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
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 () => {
|
||||
|
@ -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('./update'));
|
||||
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 () => {
|
||||
await supertest
|
||||
.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 { FtrProviderContext } from '../services';
|
||||
import { FtrProviderContext } from './services';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function ({ getService }: FtrProviderContext) {
|
||||
|
@ -15,6 +15,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
|
||||
describe('saved_object_tagging usage collector data', () => {
|
||||
beforeEach(async () => {
|
||||
await kibanaServer.savedObjects.cleanStandardList();
|
||||
await kibanaServer.importExport.load(
|
||||
'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