diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/document_entry_editor.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/document_entry_editor.tsx index 11d9ac2d62289..a48010f088c42 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/document_entry_editor.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/document_entry_editor.tsx @@ -14,18 +14,28 @@ import { EuiIcon, EuiText, } from '@elastic/eui'; -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { DocumentEntry } from '@kbn/elastic-assistant-common'; import * as i18n from './translations'; +import { isGlobalEntry } from './helpers'; interface Props { entry?: DocumentEntry; + originalEntry?: DocumentEntry; setEntry: React.Dispatch>>; hasManageGlobalKnowledgeBase: boolean; } export const DocumentEntryEditor: React.FC = React.memo( - ({ entry, setEntry, hasManageGlobalKnowledgeBase }) => { + ({ entry, setEntry, hasManageGlobalKnowledgeBase, originalEntry }) => { + const privateUsers = useMemo(() => { + const originalUsers = originalEntry?.users; + if (originalEntry && !isGlobalEntry(originalEntry)) { + return originalUsers; + } + return undefined; + }, [originalEntry]); + // Name const setName = useCallback( (e: React.ChangeEvent) => @@ -38,12 +48,13 @@ export const DocumentEntryEditor: React.FC = React.memo( (value: string) => setEntry((prevEntry) => ({ ...prevEntry, - users: value === i18n.SHARING_GLOBAL_OPTION_LABEL ? [] : undefined, + users: value === i18n.SHARING_GLOBAL_OPTION_LABEL ? [] : privateUsers, })), - [setEntry] + [privateUsers, setEntry] ); const sharingOptions = [ { + 'data-test-subj': 'sharing-private-option', value: i18n.SHARING_PRIVATE_OPTION_LABEL, inputDisplay: ( @@ -57,6 +68,7 @@ export const DocumentEntryEditor: React.FC = React.memo( ), }, { + 'data-test-subj': 'sharing-global-option', value: i18n.SHARING_GLOBAL_OPTION_LABEL, inputDisplay: ( @@ -111,6 +123,7 @@ export const DocumentEntryEditor: React.FC = React.memo( fullWidth > ( ); describe('KnowledgeBaseSettingsManagement', () => { + const mockCreateEntry = jest.fn(); + const mockUpdateEntry = jest.fn(); + const mockDeleteEntry = jest.fn(); const mockData = [ - { id: '1', name: 'Test Entry 1', type: 'document', kbResource: 'user', users: [{ id: 'hi' }] }, + { + id: '1', + createdAt: '2024-10-21T18:54:14.773Z', + createdBy: 'u_user_id_1', + updatedAt: '2024-10-23T17:33:15.933Z', + updatedBy: 'u_user_id_1', + users: [{ name: 'Test User 1' }], + name: 'Test Entry 1', + namespace: 'default', + type: 'document', + kbResource: 'user', + source: 'user', + text: 'Very nice text', + }, { id: '2', + createdAt: '2024-10-25T09:55:56.596Z', + createdBy: 'u_user_id_2', + updatedAt: '2024-10-25T09:55:56.596Z', + updatedBy: 'u_user_id_2', + users: [], name: 'Test Entry 2', + namespace: 'default', type: 'index', - kbResource: 'global', - users: [], - index: 'missing-index', + index: 'index-1', + field: 'semantic_field1', + description: 'Test description', + queryDescription: 'Test query instruction', }, { id: '3', + createdAt: '2024-10-25T09:55:56.596Z', + createdBy: 'u_user_id_1', + updatedAt: '2024-10-25T09:55:56.596Z', + updatedBy: 'u_user_id_1', + users: [{ name: 'Test User 1' }], name: 'Test Entry 3', + namespace: 'default', type: 'index', - kbResource: 'private', - users: [{ id: 'fake-user' }], index: 'index-2', + field: 'semantic_field2', + description: 'Test description', + queryDescription: 'Test query instruction', + }, + { + id: '4', + createdAt: '2024-10-21T18:54:14.773Z', + createdBy: 'u_user_id_3', + updatedAt: '2024-10-23T17:33:15.933Z', + updatedBy: 'u_user_id_3', + users: [], + name: 'Test Entry 4', + namespace: 'default', + type: 'document', + kbResource: 'user', + source: 'user', + text: 'Very nice text', }, ]; @@ -114,15 +159,15 @@ describe('KnowledgeBaseSettingsManagement', () => { closeFlyout: jest.fn(), }); (useCreateKnowledgeBaseEntry as jest.Mock).mockReturnValue({ - mutateAsync: jest.fn(), + mutateAsync: mockCreateEntry, isLoading: false, }); (useUpdateKnowledgeBaseEntries as jest.Mock).mockReturnValue({ - mutateAsync: jest.fn(), + mutateAsync: mockUpdateEntry, isLoading: false, }); (useDeleteKnowledgeBaseEntries as jest.Mock).mockReturnValue({ - mutateAsync: jest.fn(), + mutateAsync: mockDeleteEntry, isLoading: false, }); }); @@ -258,6 +303,124 @@ describe('KnowledgeBaseSettingsManagement', () => { expect(screen.queryByTestId('delete-entry-confirmation')).not.toBeInTheDocument(); }); + it('does not create a duplicate document entry when switching sharing option twice', async () => { + (useFlyoutModalVisibility as jest.Mock).mockReturnValue({ + isFlyoutOpen: true, + openFlyout: jest.fn(), + closeFlyout: jest.fn(), + }); + render(, { + wrapper, + }); + + await waitFor(() => { + fireEvent.click(screen.getAllByTestId('edit-button')[0]); + }); + expect(screen.getByTestId('flyout')).toBeVisible(); + + await waitFor(() => { + expect(screen.getByText('Edit document entry')).toBeInTheDocument(); + }); + + const updatedName = 'New Entry Name'; + await waitFor(() => { + const nameInput = screen.getByTestId('entryNameInput'); + fireEvent.change(nameInput, { target: { value: updatedName } }); + }); + + await waitFor(() => { + fireEvent.click(screen.getByTestId('sharing-select')); + fireEvent.click(screen.getByTestId('sharing-global-option')); + fireEvent.click(screen.getByTestId('sharing-select')); + fireEvent.click(screen.getByTestId('sharing-private-option')); + fireEvent.click(screen.getByTestId('save-button')); + }); + + await waitFor(() => { + expect(mockUpdateEntry).toHaveBeenCalledTimes(1); + }); + expect(mockCreateEntry).toHaveBeenCalledTimes(0); + expect(mockUpdateEntry).toHaveBeenCalledWith([{ ...mockData[0], name: updatedName }]); + }); + + it('does not create a duplicate index entry when switching sharing option twice', async () => { + (useFlyoutModalVisibility as jest.Mock).mockReturnValue({ + isFlyoutOpen: true, + openFlyout: jest.fn(), + closeFlyout: jest.fn(), + }); + render(, { + wrapper, + }); + + await waitFor(() => { + fireEvent.click(screen.getAllByTestId('edit-button')[2]); + }); + expect(screen.getByTestId('flyout')).toBeVisible(); + + await waitFor(() => { + expect(screen.getByText('Edit index entry')).toBeInTheDocument(); + }); + + const updatedName = 'New Entry Name'; + await waitFor(() => { + const nameInput = screen.getByTestId('entry-name'); + fireEvent.change(nameInput, { target: { value: updatedName } }); + }); + + await waitFor(() => { + fireEvent.click(screen.getByTestId('sharing-select')); + fireEvent.click(screen.getByTestId('sharing-global-option')); + fireEvent.click(screen.getByTestId('sharing-select')); + fireEvent.click(screen.getByTestId('sharing-private-option')); + fireEvent.click(screen.getByTestId('save-button')); + }); + + await waitFor(() => { + expect(mockUpdateEntry).toHaveBeenCalledTimes(1); + }); + expect(mockCreateEntry).toHaveBeenCalledTimes(0); + expect(mockUpdateEntry).toHaveBeenCalledWith([{ ...mockData[2], name: updatedName }]); + }); + + it('shows duplicate entry modal when making global to private entry update', async () => { + (useFlyoutModalVisibility as jest.Mock).mockReturnValue({ + isFlyoutOpen: true, + openFlyout: jest.fn(), + closeFlyout: jest.fn(), + }); + render(, { + wrapper, + }); + + await waitFor(() => { + fireEvent.click(screen.getAllByTestId('edit-button')[3]); + }); + expect(screen.getByTestId('flyout')).toBeVisible(); + + await waitFor(() => { + expect(screen.getByText('Edit document entry')).toBeInTheDocument(); + }); + + await waitFor(() => { + fireEvent.click(screen.getByTestId('sharing-select')); + fireEvent.click(screen.getByTestId('sharing-private-option')); + fireEvent.click(screen.getByTestId('save-button')); + }); + + expect(screen.getByTestId('create-duplicate-entry-modal')).toBeInTheDocument(); + await waitFor(() => { + fireEvent.click(screen.getByTestId('confirmModalConfirmButton')); + }); + expect(screen.queryByTestId('create-duplicate-entry-modal')).not.toBeInTheDocument(); + + await waitFor(() => { + expect(mockCreateEntry).toHaveBeenCalledTimes(1); + }); + expect(mockUpdateEntry).toHaveBeenCalledTimes(0); + expect(mockCreateEntry).toHaveBeenCalledWith({ ...mockData[3], users: undefined }); + }); + it('shows warning icon for index entries with missing indices', async () => { render(, { wrapper, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.tsx index 3633a935a3bea..54ea159ff0589 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.tsx @@ -83,6 +83,12 @@ export const KnowledgeBaseSettingsManagement: React.FC = React.memo(({ d const isKbSetup = isKnowledgeBaseSetup(kbStatus); const [deleteKBItem, setDeleteKBItem] = useState(null); + const [duplicateKBItem, setDuplicateKBItem] = useState( + null + ); + const [originalEntry, setOriginalEntry] = useState( + undefined + ); // Only needed for legacy settings management const { knowledgeBase, setUpdatedKnowledgeBaseSettings, resetSettings, saveSettings } = @@ -146,35 +152,37 @@ export const KnowledgeBaseSettingsManagement: React.FC = React.memo(({ d }); const isModifyingEntry = isCreatingEntry || isUpdatingEntries || isDeletingEntries; + const { + data: entries, + isFetching: isFetchingEntries, + refetch: refetchEntries, + } = useKnowledgeBaseEntries({ + http, + toasts, + enabled: enableKnowledgeBaseByDefault, + }); + // Flyout Save/Cancel Actions const onSaveConfirmed = useCallback(async () => { if (isKnowledgeBaseEntryResponse(selectedEntry)) { await updateEntries([selectedEntry]); closeFlyout(); } else if (isKnowledgeBaseEntryCreateProps(selectedEntry)) { + if (originalEntry) { + setDuplicateKBItem(selectedEntry); + return; + } await createEntry(selectedEntry); closeFlyout(); - } else if (isKnowledgeBaseEntryCreateProps(selectedEntry)) { - createEntry(selectedEntry); - closeFlyout(); } - }, [closeFlyout, selectedEntry, createEntry, updateEntries]); + }, [selectedEntry, originalEntry, updateEntries, closeFlyout, createEntry]); const onSaveCancelled = useCallback(() => { + setOriginalEntry(undefined); setSelectedEntry(undefined); closeFlyout(); }, [closeFlyout]); - const { - data: entries, - isFetching: isFetchingEntries, - refetch: refetchEntries, - } = useKnowledgeBaseEntries({ - http, - toasts, - enabled: enableKnowledgeBaseByDefault, - }); - const { value: existingIndices } = useAsync(() => { const indices: string[] = []; entries.data.forEach((entry) => { @@ -206,6 +214,7 @@ export const KnowledgeBaseSettingsManagement: React.FC = React.memo(({ d }, onEditActionClicked: ({ id }: KnowledgeBaseEntryResponse) => { const entry = entries.data.find((e) => e.id === id); + setOriginalEntry(entry); setSelectedEntry(entry); openFlyout(); }, @@ -294,6 +303,18 @@ export const KnowledgeBaseSettingsManagement: React.FC = React.memo(({ d } }, [deleteEntry, deleteKBItem, setDeleteKBItem]); + const handleCancelDuplicateEntry = useCallback(() => { + setDuplicateKBItem(null); + }, [setDuplicateKBItem]); + + const handleDuplicateEntry = useCallback(async () => { + if (duplicateKBItem) { + await createEntry(duplicateKBItem); + closeFlyout(); + setDuplicateKBItem(null); + } + }, [closeFlyout, createEntry, duplicateKBItem]); + if (!enableKnowledgeBaseByDefault) { return ( <> @@ -392,6 +413,7 @@ export const KnowledgeBaseSettingsManagement: React.FC = React.memo(({ d {selectedEntry?.type === DocumentEntryType.value ? ( >> } @@ -400,6 +422,7 @@ export const KnowledgeBaseSettingsManagement: React.FC = React.memo(({ d ) : ( >> @@ -425,6 +448,19 @@ export const KnowledgeBaseSettingsManagement: React.FC = React.memo(({ d

{i18n.DELETE_ENTRY_CONFIRMATION_CONTENT}

)} + {duplicateKBItem && ( + +

{i18n.DUPLICATE_ENTRY_CONFIRMATION_CONTENT}

+
+ )} ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.tsx index b5e1c278e2ddb..ff61c61ed7423 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.tsx @@ -21,16 +21,26 @@ import React, { useCallback, useMemo } from 'react'; import { IndexEntry } from '@kbn/elastic-assistant-common'; import { DataViewsContract } from '@kbn/data-views-plugin/public'; import * as i18n from './translations'; +import { isGlobalEntry } from './helpers'; interface Props { dataViews: DataViewsContract; entry?: IndexEntry; + originalEntry?: IndexEntry; setEntry: React.Dispatch>>; hasManageGlobalKnowledgeBase: boolean; } export const IndexEntryEditor: React.FC = React.memo( - ({ dataViews, entry, setEntry, hasManageGlobalKnowledgeBase }) => { + ({ dataViews, entry, setEntry, hasManageGlobalKnowledgeBase, originalEntry }) => { + const privateUsers = useMemo(() => { + const originalUsers = originalEntry?.users; + if (originalEntry && !isGlobalEntry(originalEntry)) { + return originalUsers; + } + return undefined; + }, [originalEntry]); + // Name const setName = useCallback( (e: React.ChangeEvent) => @@ -43,9 +53,9 @@ export const IndexEntryEditor: React.FC = React.memo( (value: string) => setEntry((prevEntry) => ({ ...prevEntry, - users: value === i18n.SHARING_GLOBAL_OPTION_LABEL ? [] : undefined, + users: value === i18n.SHARING_GLOBAL_OPTION_LABEL ? [] : privateUsers, })), - [setEntry] + [privateUsers, setEntry] ); const sharingOptions = [ { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/translations.ts index a1ed74654360a..b311f373c214b 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/translations.ts @@ -336,6 +336,28 @@ export const PRIVATE = i18n.translate( } ); +export const SAVE_BUTTON_TEXT = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.saveButtonText', + { + defaultMessage: 'Save', + } +); + +export const DUPLICATE_ENTRY_CONFIRMATION_TITLE = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.duplicateEntryConfirmationTitle', + { + defaultMessage: 'Duplicate entry?', + } +); + +export const DUPLICATE_ENTRY_CONFIRMATION_CONTENT = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.duplicateEntryConfirmationContent', + { + defaultMessage: + 'Changing a knowledge base entry from global to private will create a private copy of the original global entry. Please delete the global entry if you would like to revoke the content for other users.', + } +); + export const MISSING_INDEX_ERROR = i18n.translate( 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.missingIndexError', {