diff --git a/frontend/app-development/features/appContentLibrary/AppContentLibrary.tsx b/frontend/app-development/features/appContentLibrary/AppContentLibrary.tsx index 92f65b621d1..e9be23e893d 100644 --- a/frontend/app-development/features/appContentLibrary/AppContentLibrary.tsx +++ b/frontend/app-development/features/appContentLibrary/AppContentLibrary.tsx @@ -14,6 +14,7 @@ import { useAddOptionListMutation, useUpdateOptionListMutation, useUpdateOptionListIdMutation, + useDeleteOptionListMutation, } from 'app-shared/hooks/mutations'; import { mapToCodeListsUsage } from './utils/mapToCodeListsUsage'; @@ -24,6 +25,7 @@ export function AppContentLibrary(): React.ReactElement { org, app, ); + const { mutate: deleteOptionList } = useDeleteOptionListMutation(org, app); const { mutate: uploadOptionList } = useAddOptionListMutation(org, app, { hideDefaultError: (error: AxiosError) => isErrorUnknown(error), }); @@ -65,6 +67,7 @@ export function AppContentLibrary(): React.ReactElement { codeList: { props: { codeListsData, + onDeleteCodeList: deleteOptionList, onUpdateCodeListId: handleUpdateCodeListId, onUpdateCodeList: handleUpdate, onUploadCodeList: handleUpload, diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index 1e5274ab6fa..c69e5e5c12b 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -15,6 +15,9 @@ "app_content_library.code_lists.code_list_accordion_title": "Kodelistenavn: {{codeListTitle}}", "app_content_library.code_lists.code_list_accordion_usage_sub_title_plural": "Kodelisten brukes i {{codeListUsagesCount}} komponenter.", "app_content_library.code_lists.code_list_accordion_usage_sub_title_single": "Kodelisten brukes i {{codeListUsagesCount}} komponent.", + "app_content_library.code_lists.code_list_delete": "Slett kodeliste", + "app_content_library.code_lists.code_list_delete_disabled_title": "Før du kan å slette kodelisten, må du fjerne den fra der den er brukt i appen.", + "app_content_library.code_lists.code_list_delete_enabled_title": "Slett kodelisten fra biblioteket.", "app_content_library.code_lists.code_list_edit_id_label": "Navn på kodeliste", "app_content_library.code_lists.code_list_edit_id_title": "Rediger navn på kodelisten {{codeListName}}", "app_content_library.code_lists.code_list_show_usage": "Se hvor kodelisten er tatt i bruk", diff --git a/frontend/libs/studio-content-library/mocks/mockPagesConfig.ts b/frontend/libs/studio-content-library/mocks/mockPagesConfig.ts index 678f509b8ee..801f63b3dda 100644 --- a/frontend/libs/studio-content-library/mocks/mockPagesConfig.ts +++ b/frontend/libs/studio-content-library/mocks/mockPagesConfig.ts @@ -12,6 +12,7 @@ export const mockPagesConfig: PagesConfig = { codeList: { props: { codeListsData: codeListsDataMock, + onDeleteCodeList: () => {}, onUpdateCodeListId: () => {}, onUpdateCodeList: () => {}, onUploadCodeList: () => {}, diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/CodeListPage.test.tsx b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/CodeListPage.test.tsx index e02e6d3ced9..84ee8637c98 100644 --- a/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/CodeListPage.test.tsx +++ b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/CodeListPage.test.tsx @@ -8,6 +8,7 @@ import { textMock } from '@studio/testing/mocks/i18nMock'; import type { CodeList as StudioComponentCodeList } from '@studio/components'; import { codeListsDataMock } from '../../../../../mocks/mockPagesConfig'; +const onDeleteCodeListMock = jest.fn(); const onUpdateCodeListIdMock = jest.fn(); const onUpdateCodeListMock = jest.fn(); const onUploadCodeListMock = jest.fn(); @@ -151,6 +152,7 @@ const uploadCodeList = async (user: UserEvent, fileName: string = uploadedCodeLi const defaultCodeListPageProps: CodeListPageProps = { codeListsData: codeListsDataMock, + onDeleteCodeList: onDeleteCodeListMock, onUpdateCodeListId: onUpdateCodeListIdMock, onUpdateCodeList: onUpdateCodeListMock, onUploadCodeList: onUploadCodeListMock, diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/CodeListPage.tsx b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/CodeListPage.tsx index 9c93970f191..96970562459 100644 --- a/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/CodeListPage.tsx +++ b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/CodeListPage.tsx @@ -22,6 +22,7 @@ export type CodeListData = { export type CodeListPageProps = { codeListsData: CodeListData[]; + onDeleteCodeList: (codeListId: string) => void; onUpdateCodeListId: (codeListId: string, newCodeListId: string) => void; onUpdateCodeList: (updatedCodeList: CodeListWithMetadata) => void; onUploadCodeList: (uploadedCodeList: File) => void; @@ -30,6 +31,7 @@ export type CodeListPageProps = { export function CodeListPage({ codeListsData, + onDeleteCodeList, onUpdateCodeListId, onUpdateCodeList, onUploadCodeList, @@ -61,6 +63,7 @@ export function CodeListPage({ /> { expect(codeListUsagesModalTitle).toBeInTheDocument(); }); + it('renders button to delete code list as disabled when code list is used', async () => { + renderCodeLists({ + codeListsUsages: [ + { + codeListId: codeListName, + codeListIdSources: [ + { layoutSetId: 'layoutSetId', layoutName: 'layoutName', componentIds: ['componentId'] }, + ], + }, + ], + }); + const deleteCodeListButton = screen.getByRole('button', { + name: textMock('app_content_library.code_lists.code_list_delete'), + }); + expect(deleteCodeListButton).toBeDisabled(); + expect(deleteCodeListButton.title).toBe( + textMock('app_content_library.code_lists.code_list_delete_disabled_title'), + ); + }); + it('renders the code list editor', () => { renderCodeLists(); const codeListEditor = screen.getByText(textMock('code_list_editor.legend')); @@ -206,6 +227,20 @@ describe('CodeLists', () => { const errorMessage = screen.getByText(textMock('app_content_library.code_lists.fetch_error')); expect(errorMessage).toBeInTheDocument(); }); + + it('calls onDeleteCodeList when clicking delete button', async () => { + const user = userEvent.setup(); + renderCodeLists(); + const deleteCodeListButton = screen.getByRole('button', { + name: textMock('app_content_library.code_lists.code_list_delete'), + }); + expect(deleteCodeListButton.title).toBe( + textMock('app_content_library.code_lists.code_list_delete_enabled_title'), + ); + await user.click(deleteCodeListButton); + expect(onDeleteCodeListMock).toHaveBeenCalledTimes(1); + expect(onDeleteCodeListMock).toHaveBeenLastCalledWith(codeListName); + }); }); const changeCodeListId = async (user: UserEvent, oldCodeListId: string, newCodeListId: string) => { @@ -227,6 +262,7 @@ const changeCodeListId = async (user: UserEvent, oldCodeListId: string, newCodeL const defaultProps: CodeListsProps = { codeListsData: codeListsDataMock, + onDeleteCodeList: onDeleteCodeListMock, onUpdateCodeListId: onUpdateCodeListIdMock, onUpdateCodeList: onUpdateCodeListMock, codeListInEditMode: undefined, diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/CodeLists/CodeLists.tsx b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/CodeLists/CodeLists.tsx index 67ae8b95eeb..8e9c2a85b1b 100644 --- a/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/CodeLists/CodeLists.tsx +++ b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/CodeLists/CodeLists.tsx @@ -10,6 +10,7 @@ import { getCodeListSourcesById, getCodeListUsageCount } from '../utils'; export type CodeListsProps = { codeListsData: CodeListData[]; + onDeleteCodeList: (codeListId: string) => void; onUpdateCodeListId: (codeListId: string, newCodeListId: string) => void; onUpdateCodeList: (updatedCodeList: CodeListWithMetadata) => void; codeListInEditMode: string | undefined; @@ -19,11 +20,8 @@ export type CodeListsProps = { export function CodeLists({ codeListsData, - onUpdateCodeListId, - onUpdateCodeList, - codeListInEditMode, - codeListNames, codeListsUsages, + ...rest }: CodeListsProps): React.ReactElement[] { return codeListsData.map((codeListData) => { const codeListSources = getCodeListSourcesById(codeListsUsages, codeListData.title); @@ -31,10 +29,7 @@ export function CodeLists({ ); @@ -48,11 +43,9 @@ type CodeListProps = Omit & function CodeList({ codeListData, - onUpdateCodeListId, - onUpdateCodeList, codeListInEditMode, - codeListNames, codeListSources, + ...rest }: CodeListProps): React.ReactElement { return ( @@ -63,10 +56,8 @@ function CodeList({ /> @@ -120,10 +111,7 @@ type CodeListAccordionContentProps = Omit; function CodeListAccordionContent({ codeListData, - onUpdateCodeListId, - onUpdateCodeList, - codeListNames, - codeListSources, + ...rest }: CodeListAccordionContentProps): React.ReactElement { const { t } = useTranslation(); @@ -134,14 +122,7 @@ function CodeListAccordionContent({ {t('app_content_library.code_lists.fetch_error')} ) : ( - + )} ); diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/CodeLists/EditCodeList/EditCodeList.module.css b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/CodeLists/EditCodeList/EditCodeList.module.css index 394b22669bd..06431b07fa7 100644 --- a/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/CodeLists/EditCodeList/EditCodeList.module.css +++ b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/CodeLists/EditCodeList/EditCodeList.module.css @@ -1,7 +1,7 @@ .editCodeList { display: flex; flex-direction: column; - gap: var(--fds-spacing-2); + gap: var(--fds-spacing-3); } .codeListUsageButton { @@ -11,3 +11,9 @@ .seeUsageIcon { font-size: var(--fds-sizing-5); } + +.buttons { + display: flex; + flex-direction: row; + gap: var(--fds-spacing-3); +} diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/CodeLists/EditCodeList/EditCodeList.tsx b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/CodeLists/EditCodeList/EditCodeList.tsx index 0c6b1bffd00..22dff7435d7 100644 --- a/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/CodeLists/EditCodeList/EditCodeList.tsx +++ b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage/CodeLists/EditCodeList/EditCodeList.tsx @@ -1,9 +1,10 @@ -import type { - CodeList as StudioComponentsCodeList, - CodeList, - CodeListEditorTexts, +import type { CodeList, CodeListEditorTexts } from '@studio/components'; +import { + StudioDeleteButton, + StudioModal, + StudioCodeListEditor, + StudioToggleableTextfield, } from '@studio/components'; -import { StudioModal, StudioCodeListEditor, StudioToggleableTextfield } from '@studio/components'; import React from 'react'; import { useTranslation } from 'react-i18next'; import type { CodeListWithMetadata } from '../../CodeListPage'; @@ -18,6 +19,7 @@ import { CodeListUsages } from './CodeListUsages/CodeListUsages'; export type EditCodeListProps = { codeList: CodeList; codeListTitle: string; + onDeleteCodeList: (codeListId: string) => void; onUpdateCodeListId: (codeListId: string, newCodeListId: string) => void; onUpdateCodeList: (updatedCodeList: CodeListWithMetadata) => void; codeListNames: string[]; @@ -27,6 +29,7 @@ export type EditCodeListProps = { export function EditCodeList({ codeList, codeListTitle, + onDeleteCodeList, onUpdateCodeListId, onUpdateCodeList, codeListNames, @@ -54,6 +57,8 @@ export function EditCodeList({ return getInvalidInputFileNameErrorMessage(fileNameError); }; + const handleDeleteCodeList = (): void => onDeleteCodeList(codeListTitle); + const codeListHasUsages = codeListSources.length > 0; return ( @@ -85,18 +90,52 @@ export function EditCodeList({ onBlurAny={handleCodeListChange} texts={editorTexts} /> - {codeListHasUsages && } + ); } export const updateCodeListWithMetadata = ( currentCodeListWithMetadata: CodeListWithMetadata, - updatedCodeList: StudioComponentsCodeList, + updatedCodeList: CodeList, ): CodeListWithMetadata => { return { ...currentCodeListWithMetadata, codeList: updatedCodeList }; }; +type CodeListButtonsProps = { + codeListHasUsages: boolean; + codeListSources: CodeListIdSource[]; + onDeleteCodeList: (codeListId: string) => void; +}; + +function CodeListButtons({ + codeListHasUsages, + codeListSources, + onDeleteCodeList, +}: CodeListButtonsProps): React.ReactElement { + const { t } = useTranslation(); + const deleteButtonTitle = codeListHasUsages + ? t('app_content_library.code_lists.code_list_delete_disabled_title') + : t('app_content_library.code_lists.code_list_delete_enabled_title'); + + return ( +
+ + {t('app_content_library.code_lists.code_list_delete')} + + {codeListHasUsages && } +
+ ); +} + export type ShowCodeListUsagesSourcesModalProps = { codeListSources: CodeListIdSource[]; }; diff --git a/frontend/packages/shared/src/api/mutations.ts b/frontend/packages/shared/src/api/mutations.ts index d96514f18b4..16c2b3fba8a 100644 --- a/frontend/packages/shared/src/api/mutations.ts +++ b/frontend/packages/shared/src/api/mutations.ts @@ -47,6 +47,7 @@ import { selectedMaskinportenScopesPath, createInstancePath, dataTypePath, + optionListPath, } from 'app-shared/api/paths'; import type { AddLanguagePayload } from 'app-shared/types/api/AddLanguagePayload'; import type { AddRepoParams } from 'app-shared/types/api'; @@ -86,6 +87,7 @@ export const addImage = (org: string, app: string, form: FormData) => post del(imagePath(org, app, imageName)); export const deleteLayoutSet = (org: string, app: string, layoutSetIdToUpdate: string) => del(layoutSetPath(org, app, layoutSetIdToUpdate)); +export const deleteOptionList = (org: string, app: string, optionListId: string) => del(optionListPath(org, app, optionListId)); export const updateLayoutSetId = (org: string, app: string, layoutSetIdToUpdate: string, newLayoutSetId: string) => put(layoutSetPath(org, app, layoutSetIdToUpdate), newLayoutSetId, { headers: { 'Content-Type': 'application/json' } }); export const addRepo = (repoToAdd: AddRepoParams) => post(`${createRepoPath()}${buildQueryParams(repoToAdd)}`); export const addXsdFromRepo = (org: string, app: string, modelPath: string) => post(dataModelAddXsdFromRepoPath(org, app, modelPath)); diff --git a/frontend/packages/shared/src/api/paths.js b/frontend/packages/shared/src/api/paths.js index 372d5c18a0c..ba82ff96e56 100644 --- a/frontend/packages/shared/src/api/paths.js +++ b/frontend/packages/shared/src/api/paths.js @@ -42,7 +42,7 @@ export const submitFeedbackPath = (org, app) => `${basePath}/${org}/${app}/feedb // FormEditor export const ruleHandlerPath = (org, app, layoutSetName) => `${basePath}/${org}/${app}/app-development/rule-handler?${s({ layoutSetName })}`; // Get, Post export const widgetSettingsPath = (org, app) => `${basePath}/${org}/${app}/app-development/widget-settings`; // Get -export const optionListPath = (org, app, optionsListId) => `${basePath}/${org}/${app}/options/${optionsListId}`; // Get +export const optionListPath = (org, app, optionsListId) => `${basePath}/${org}/${app}/options/${optionsListId}`; // Get, Delete export const optionListsPath = (org, app) => `${basePath}/${org}/${app}/options/option-lists`; // Get export const optionListReferencesPath = (org, app) => `${basePath}/${org}/${app}/options/usage`; // Get export const optionListIdsPath = (org, app) => `${basePath}/${org}/${app}/app-development/option-list-ids`; // Get diff --git a/frontend/packages/shared/src/hooks/mutations/index.ts b/frontend/packages/shared/src/hooks/mutations/index.ts index d23c7a9b268..06192bfbe8d 100644 --- a/frontend/packages/shared/src/hooks/mutations/index.ts +++ b/frontend/packages/shared/src/hooks/mutations/index.ts @@ -1,4 +1,5 @@ export { useAddOptionListMutation } from './useAddOptionListMutation'; +export { useDeleteOptionListMutation } from './useDeleteOptionListMutation'; export { useUpdateOptionListMutation } from './useUpdateOptionListMutation'; export { useUpdateOptionListIdMutation } from './useUpdateOptionListIdMutation'; export { useUpsertTextResourcesMutation } from './useUpsertTextResourcesMutation'; diff --git a/frontend/packages/shared/src/hooks/mutations/useDeleteOptionListMutation.test.ts b/frontend/packages/shared/src/hooks/mutations/useDeleteOptionListMutation.test.ts new file mode 100644 index 00000000000..3826336dc1b --- /dev/null +++ b/frontend/packages/shared/src/hooks/mutations/useDeleteOptionListMutation.test.ts @@ -0,0 +1,62 @@ +import { app, org } from '@studio/testing/testids'; +import { queriesMock } from '../../mocks/queriesMock'; +import { renderHookWithProviders } from '../../mocks/renderHookWithProviders'; +import { useDeleteOptionListMutation } from './useDeleteOptionListMutation'; +import { createQueryClientMock } from '../../mocks/queryClientMock'; +import { QueryKey } from 'app-shared/types/QueryKey'; + +// Test data: +const optionsListId = 'test'; + +describe('useDeleteOptionListMutation', () => { + test('Calls useDeleteOptionList with correct parameters', async () => { + const renderDeleteOptionListMutationResult = renderHookWithProviders(() => + useDeleteOptionListMutation(org, app), + ).result; + await renderDeleteOptionListMutationResult.current.mutateAsync(optionsListId); + expect(queriesMock.deleteOptionList).toHaveBeenCalledTimes(1); + expect(queriesMock.deleteOptionList).toHaveBeenCalledWith(org, app, optionsListId); + }); + + test('Sets the option list ids query cache without the given option list id', async () => { + const queryClient = createQueryClientMock(); + queryClient.setQueryData([QueryKey.OptionListIds, org, app], [optionsListId]); + const renderDeleteOptionListMutationResult = renderHookWithProviders( + () => useDeleteOptionListMutation(org, app), + { queryClient }, + ).result; + await renderDeleteOptionListMutationResult.current.mutateAsync(optionsListId); + expect(queryClient.getQueryData([QueryKey.OptionListIds, org, app])).toEqual([]); + }); + + test('Invalidates the option lists query cache', async () => { + const queryClient = createQueryClientMock(); + queryClient.setQueryData( + [QueryKey.OptionLists, org, app], + [{ title: optionsListId, data: [] }], + ); + const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries'); + const renderDeleteOptionListMutationResult = renderHookWithProviders( + () => useDeleteOptionListMutation(org, app), + { queryClient }, + ).result; + await renderDeleteOptionListMutationResult.current.mutateAsync(optionsListId); + expect(invalidateQueriesSpy).toHaveBeenCalledTimes(1); + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ + queryKey: [QueryKey.OptionLists, org, app], + }); + }); + + test('Removes the option list query cache for the given option list id', async () => { + const queryClient = createQueryClientMock(); + queryClient.setQueryData([QueryKey.OptionList, org, app, optionsListId], []); + const renderDeleteOptionListMutationResult = renderHookWithProviders( + () => useDeleteOptionListMutation(org, app), + { queryClient }, + ).result; + await renderDeleteOptionListMutationResult.current.mutateAsync(optionsListId); + expect( + queryClient.getQueryData([QueryKey.OptionList, org, app, optionsListId]), + ).toBeUndefined(); + }); +}); diff --git a/frontend/packages/shared/src/hooks/mutations/useDeleteOptionListMutation.ts b/frontend/packages/shared/src/hooks/mutations/useDeleteOptionListMutation.ts new file mode 100644 index 00000000000..cea4681ce60 --- /dev/null +++ b/frontend/packages/shared/src/hooks/mutations/useDeleteOptionListMutation.ts @@ -0,0 +1,37 @@ +import type { MutationMeta, QueryClient } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useServicesContext } from 'app-shared/contexts/ServicesContext'; +import { QueryKey } from 'app-shared/types/QueryKey'; + +export const useDeleteOptionListMutation = (org: string, app: string, meta?: MutationMeta) => { + const queryClient = useQueryClient(); + const { deleteOptionList } = useServicesContext(); + + return useMutation({ + mutationFn: (optionListId: string) => + deleteOptionList(org, app, optionListId).then(() => optionListId), + onSuccess: (optionListId) => { + setOptionListIdsQueryCache(queryClient, org, app, optionListId); + void queryClient.invalidateQueries({ queryKey: [QueryKey.OptionLists, org, app] }); + void queryClient.removeQueries({ queryKey: [QueryKey.OptionList, org, app, optionListId] }); + }, + meta, + }); +}; + +const setOptionListIdsQueryCache = ( + queryClient: QueryClient, + org: string, + app: string, + optionListId: string, +) => { + const currentOptionListIds = queryClient.getQueryData([ + QueryKey.OptionListIds, + org, + app, + ]); + if (currentOptionListIds) { + const updatedOptionListIds = currentOptionListIds.filter((id) => id !== optionListId); + void queryClient.setQueryData([QueryKey.OptionListIds, org, app], updatedOptionListIds); + } +}; diff --git a/frontend/packages/shared/src/mocks/queriesMock.ts b/frontend/packages/shared/src/mocks/queriesMock.ts index bd11fb0d5ef..c83b2427779 100644 --- a/frontend/packages/shared/src/mocks/queriesMock.ts +++ b/frontend/packages/shared/src/mocks/queriesMock.ts @@ -225,6 +225,7 @@ export const queriesMock: ServicesContextProps = { deleteImage: jest.fn().mockImplementation(() => Promise.resolve()), deleteLanguageCode: jest.fn().mockImplementation(() => Promise.resolve()), deleteLayoutSet: jest.fn().mockImplementation(() => Promise.resolve()), + deleteOptionList: jest.fn().mockImplementation(() => Promise.resolve()), generateModels: jest.fn().mockImplementation(() => Promise.resolve()), logout: jest.fn().mockImplementation(() => Promise.resolve()), pushRepoChanges: jest.fn().mockImplementation(() => Promise.resolve()),