diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SelectedSchemaEditor.test.tsx b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SelectedSchemaEditor.test.tsx index 81a0b5d62f2..5d4c0b11845 100644 --- a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SelectedSchemaEditor.test.tsx +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SelectedSchemaEditor.test.tsx @@ -6,20 +6,37 @@ import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext'; import { act, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; import { textMock } from '../../../../testing/mocks/i18nMock'; import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; -import { datamodelNameMock } from 'app-shared/mocks/datamodelMetadataMocks'; +import { + createJsonMetadataMock, + createXsdMetadataMock, +} from 'app-shared/mocks/datamodelMetadataMocks'; import userEvent from '@testing-library/user-event'; import { dataMock } from '@altinn/schema-editor/mockData'; import { AUTOSAVE_DEBOUNCE_INTERVAL_MILLISECONDS } from 'app-shared/constants'; import type { SchemaEditorAppProps } from '@altinn/schema-editor/SchemaEditorApp'; import { QueryKey } from 'app-shared/types/QueryKey'; import { createApiErrorMock } from 'app-shared/mocks/apiErrorMock'; +import { createJsonModelPathMock } from 'app-shared/mocks/modelPathMocks'; +import type { + DatamodelMetadataJson, + DatamodelMetadataXsd, +} from 'app-shared/types/DatamodelMetadata'; +import { verifyNeverOccurs } from '../../../../testing/testUtils'; const user = userEvent.setup(); // Test data: -const modelPath = datamodelNameMock; +const model1Name = 'model1'; +const model2name = 'model2'; +const model1Path = createJsonModelPathMock(model1Name); +const model2Path = createJsonModelPathMock(model2name); +const model1MetadataJson: DatamodelMetadataJson = createJsonMetadataMock(model1Name); +const model1MetadataXsd: DatamodelMetadataXsd = createXsdMetadataMock(model1Name); +const model2MetadataJson: DatamodelMetadataJson = createJsonMetadataMock(model2name); +const model2MetadataXsd: DatamodelMetadataXsd = createXsdMetadataMock(model2name); + const defaultProps: SelectedSchemaEditorProps = { - modelPath, + modelPath: model1Path, }; const org = 'org'; const app = 'app'; @@ -72,6 +89,7 @@ describe('SelectedSchemaEditor', () => { const getDatamodel = jest.fn().mockImplementation(() => Promise.resolve(dataMock)); render({ getDatamodel, saveDatamodel }); + await waitForElementToBeRemoved(() => screen.queryByTitle(textMock('general.loading'))); const button = screen.getByTestId(saveButtonTestId); @@ -80,7 +98,7 @@ describe('SelectedSchemaEditor', () => { act(() => jest.advanceTimersByTime(AUTOSAVE_DEBOUNCE_INTERVAL_MILLISECONDS)); await waitFor(() => expect(saveDatamodel).toHaveBeenCalledTimes(1)); - expect(saveDatamodel).toHaveBeenCalledWith(org, app, modelPath, dataMock); + expect(saveDatamodel).toHaveBeenCalledWith(org, app, model1Path, dataMock); }); it('Autosaves when changing between models that are not present in the cache', async () => { @@ -92,22 +110,19 @@ describe('SelectedSchemaEditor', () => { await waitForElementToBeRemoved(() => screen.queryByTitle(textMock('general.loading'))); expect(saveDatamodel).not.toHaveBeenCalled(); - const updatedProps = { - ...defaultProps, - modelPath: 'newModel', - }; + const updatedProps = { ...defaultProps, modelPath: model2Path }; rerender(); jest.advanceTimersByTime(AUTOSAVE_DEBOUNCE_INTERVAL_MILLISECONDS); await waitFor(() => expect(saveDatamodel).toHaveBeenCalledTimes(1)); - expect(saveDatamodel).toHaveBeenCalledWith(org, app, datamodelNameMock, dataMock); + expect(saveDatamodel).toHaveBeenCalledWith(org, app, model1Path, dataMock); }); it('Autosaves when changing between models that are already present in the cache', async () => { const saveDatamodel = jest.fn(); const queryClient = createQueryClientMock(); const newModelPath = 'newModel'; - queryClient.setQueryData([QueryKey.JsonSchema, org, app, datamodelNameMock], dataMock); - queryClient.setQueryData([QueryKey.JsonSchema, org, app, newModelPath], dataMock); + queryClient.setQueryData([QueryKey.JsonSchema, org, app, model1Path], dataMock); + queryClient.setQueryData([QueryKey.JsonSchema, org, app, model1Path], dataMock); const { renderResult: { rerender }, } = render({ saveDatamodel }, queryClient); @@ -120,7 +135,29 @@ describe('SelectedSchemaEditor', () => { rerender(); jest.advanceTimersByTime(AUTOSAVE_DEBOUNCE_INTERVAL_MILLISECONDS); await waitFor(() => expect(saveDatamodel).toHaveBeenCalledTimes(1)); - expect(saveDatamodel).toHaveBeenCalledWith(org, app, datamodelNameMock, dataMock); + expect(saveDatamodel).toHaveBeenCalledWith(org, app, model1Path, dataMock); + }); + + it('Does not save when model is deleted', async () => { + const saveDatamodel = jest.fn(); + const queryClient = createQueryClientMock(); + + queryClient.setQueryData([QueryKey.JsonSchema, org, app, model1Path], dataMock); + queryClient.setQueryData([QueryKey.JsonSchema, org, app, model2Path], dataMock); + const { + renderResult: { rerender }, + } = render({ saveDatamodel }, queryClient); + expect(saveDatamodel).not.toHaveBeenCalled(); + + const updatedProps = { + ...defaultProps, + modelPath: model2Path, + }; + queryClient.setQueryData([QueryKey.DatamodelsJson, org, app], [model2MetadataJson]); + queryClient.setQueryData([QueryKey.DatamodelsXsd, org, app], [model2MetadataXsd]); + rerender(); + jest.advanceTimersByTime(AUTOSAVE_DEBOUNCE_INTERVAL_MILLISECONDS); + await verifyNeverOccurs(() => expect(saveDatamodel).toHaveBeenCalled()); }); }); @@ -128,9 +165,18 @@ const render = ( queries: Partial = {}, queryClient = createQueryClientMock(), props: Partial = {}, -) => - renderWithMockStore( +) => { + queryClient.setQueryData( + [QueryKey.DatamodelsJson, org, app], + [model1MetadataJson, model2MetadataJson], + ); + queryClient.setQueryData( + [QueryKey.DatamodelsXsd, org, app], + [model1MetadataXsd, model2MetadataXsd], + ); + return renderWithMockStore( {}, queries, queryClient, )(); +}; diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SelectedSchemaEditor.tsx b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SelectedSchemaEditor.tsx index 3f58ee05bb8..3aedb20ac35 100644 --- a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SelectedSchemaEditor.tsx +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SelectedSchemaEditor.tsx @@ -8,8 +8,15 @@ import { useTranslation } from 'react-i18next'; import { AUTOSAVE_DEBOUNCE_INTERVAL_MILLISECONDS } from 'app-shared/constants'; import type { JsonSchema } from 'app-shared/types/JsonSchema'; import { useOnUnmount } from 'app-shared/hooks/useOnUnmount'; +import type { + DatamodelMetadataJson, + DatamodelMetadataXsd, +} from 'app-shared/types/DatamodelMetadata'; +import { useQueryClient } from '@tanstack/react-query'; +import { QueryKey } from 'app-shared/types/QueryKey'; +import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; +import { mergeJsonAndXsdData } from 'app-development/utils/metadataUtils'; import { extractFilename, removeSchemaExtension } from 'app-shared/utils/filenameUtils'; - export interface SelectedSchemaEditorProps { modelPath: string; } @@ -46,7 +53,9 @@ interface SchemaEditorWithDebounceProps { } const SchemaEditorWithDebounce = ({ jsonSchema, modelPath }: SchemaEditorWithDebounceProps) => { + const { org, app } = useStudioUrlParams(); const { mutate } = useSchemaMutation(); + const queryClient = useQueryClient(); const [model, setModel] = useState(jsonSchema); const saveTimeoutRef = useRef>(); const updatedModel = useRef(jsonSchema); @@ -68,9 +77,24 @@ const SchemaEditorWithDebounce = ({ jsonSchema, modelPath }: SchemaEditorWithDeb [saveFunction], ); + const doesModelExist = useCallback(() => { + const jsonModels: DatamodelMetadataJson[] = queryClient.getQueryData([ + QueryKey.DatamodelsJson, + org, + app, + ]); + const xsdModels: DatamodelMetadataXsd[] = queryClient.getQueryData([ + QueryKey.DatamodelsXsd, + org, + app, + ]); + const metadataList = mergeJsonAndXsdData(jsonModels, xsdModels); + return metadataList.some((datamodel) => datamodel.repositoryRelativeUrl === modelPath); + }, [queryClient, org, app, modelPath]); + useOnUnmount(() => { clearTimeout(saveTimeoutRef.current); - saveFunction(); + if (doesModelExist()) saveFunction(); }); return ( diff --git a/frontend/app-development/hooks/mutations/useDeleteDatamodelMutation.test.ts b/frontend/app-development/hooks/mutations/useDeleteDatamodelMutation.test.ts new file mode 100644 index 00000000000..d61c5df3a03 --- /dev/null +++ b/frontend/app-development/hooks/mutations/useDeleteDatamodelMutation.test.ts @@ -0,0 +1,71 @@ +import { renderHookWithMockStore } from '../../test/mocks'; +import { useDeleteDatamodelMutation } from './useDeleteDatamodelMutation'; +import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext'; +import type { QueryClient } from '@tanstack/react-query'; +import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; +import { waitFor } from '@testing-library/react'; +import { QueryKey } from 'app-shared/types/QueryKey'; +import { queriesMock } from 'app-shared/mocks/queriesMock'; +import { createJsonModelPathMock } from 'app-shared/mocks/modelPathMocks'; +import { + createJsonMetadataMock, + createXsdMetadataMock, +} from 'app-shared/mocks/datamodelMetadataMocks'; + +const modelName = 'modelName'; +const modelPath = createJsonModelPathMock(modelName); +const org = 'org'; +const app = 'app'; +const modelMetadataJson = createJsonMetadataMock(modelName); +const modelMetadataXsd = createXsdMetadataMock(modelName); + +describe('useDeleteDatamodelMutation', () => { + beforeEach(jest.clearAllMocks); + + it('Calls deleteDatamodel with correct parameters', async () => { + const client = createQueryClientMock(); + client.setQueryData([QueryKey.DatamodelsJson, org, app], [modelMetadataJson]); + client.setQueryData([QueryKey.DatamodelsXsd, org, app], [modelMetadataXsd]); + const { + renderHookResult: { result }, + } = render({}, client); + expect(result.current).toBeDefined(); + result.current.mutate(modelPath); + await waitFor(() => result.current.isSuccess); + expect(queriesMock.deleteDatamodel).toHaveBeenCalledTimes(1); + expect(queriesMock.deleteDatamodel).toHaveBeenCalledWith(org, app, modelPath); + }); + + it('Removes the metadata instances from the query cache', async () => { + const client = createQueryClientMock(); + client.setQueryData([QueryKey.DatamodelsJson, org, app], [modelMetadataJson]); + client.setQueryData([QueryKey.DatamodelsXsd, org, app], [modelMetadataXsd]); + const { + renderHookResult: { result }, + } = render({}, client); + result.current.mutate(modelPath); + await waitFor(() => result.current.isSuccess); + expect(client.getQueryData([QueryKey.DatamodelsJson, org, app])).toEqual([]); + expect(client.getQueryData([QueryKey.DatamodelsXsd, org, app])).toEqual([]); + }); + + it('Removes the schema queries from the query cache', async () => { + const client = createQueryClientMock(); + client.setQueryData([QueryKey.DatamodelsJson, org, app], [modelMetadataJson]); + client.setQueryData([QueryKey.DatamodelsXsd, org, app], [modelMetadataXsd]); + const { + renderHookResult: { result }, + } = render({}, client); + result.current.mutate(modelPath); + await waitFor(() => result.current.isSuccess); + expect(client.getQueryData([QueryKey.JsonSchema, org, app, modelPath])).toBeUndefined(); + expect( + client.getQueryData([QueryKey.JsonSchema, org, app, modelMetadataXsd.repositoryRelativeUrl]), + ).toBeUndefined(); + }); +}); + +const render = ( + queries: Partial = {}, + queryClient: QueryClient = createQueryClientMock(), +) => renderHookWithMockStore({}, queries, queryClient)(() => useDeleteDatamodelMutation()); diff --git a/frontend/app-development/hooks/mutations/useDeleteDatamodelMutation.ts b/frontend/app-development/hooks/mutations/useDeleteDatamodelMutation.ts index f292aa4af31..dcf1838e8cf 100644 --- a/frontend/app-development/hooks/mutations/useDeleteDatamodelMutation.ts +++ b/frontend/app-development/hooks/mutations/useDeleteDatamodelMutation.ts @@ -3,6 +3,7 @@ import { useServicesContext } from 'app-shared/contexts/ServicesContext'; import { QueryKey } from 'app-shared/types/QueryKey'; import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; import { isXsdFile } from 'app-shared/utils/filenameUtils'; +import type { DatamodelMetadata } from 'app-shared/types/DatamodelMetadata'; export const useDeleteDatamodelMutation = () => { const { deleteDatamodel } = useServicesContext(); @@ -10,19 +11,33 @@ export const useDeleteDatamodelMutation = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (modelPath: string) => { - await deleteDatamodel(org, app, modelPath); - const respectiveFileNameInXsdOrJson = isXsdFile(modelPath) + const jsonSchemaPath = isXsdFile(modelPath) ? modelPath.replace('.xsd', '.schema.json') - : modelPath.replace('.schema.json', '.xsd'); - queryClient.setQueryData([QueryKey.JsonSchema, org, app, modelPath], undefined); + : modelPath; + const xsdPath = isXsdFile(modelPath) ? modelPath : modelPath.replace('.schema.json', '.xsd'); queryClient.setQueryData( - [QueryKey.JsonSchema, org, app, respectiveFileNameInXsdOrJson], - undefined, + [QueryKey.DatamodelsJson, org, app], + (oldData: DatamodelMetadata[]) => removeDatamodelFromList(oldData, jsonSchemaPath), + ); + queryClient.setQueryData([QueryKey.DatamodelsXsd, org, app], (oldData: DatamodelMetadata[]) => + removeDatamodelFromList(oldData, xsdPath), ); - await Promise.all([ - queryClient.invalidateQueries({ queryKey: [QueryKey.DatamodelsJson, org, app] }), - queryClient.invalidateQueries({ queryKey: [QueryKey.DatamodelsXsd, org, app] }), - ]); + await deleteDatamodel(org, app, modelPath); + return { jsonSchemaPath, xsdPath }; + }, + onSuccess: ({ jsonSchemaPath, xsdPath }) => { + queryClient.removeQueries({ + queryKey: [QueryKey.JsonSchema, org, app, jsonSchemaPath], + }); + queryClient.removeQueries({ + queryKey: [QueryKey.JsonSchema, org, app, xsdPath], + }); }, }); }; + +export const removeDatamodelFromList = ( + datamodels: DatamodelMetadata[], + relativeUrl: string, +): DatamodelMetadata[] => + datamodels.filter((datamodel) => datamodel.repositoryRelativeUrl !== relativeUrl); diff --git a/frontend/packages/shared/src/mocks/datamodelMetadataMocks.ts b/frontend/packages/shared/src/mocks/datamodelMetadataMocks.ts index 6fbae30be5f..22ff72523a3 100644 --- a/frontend/packages/shared/src/mocks/datamodelMetadataMocks.ts +++ b/frontend/packages/shared/src/mocks/datamodelMetadataMocks.ts @@ -2,6 +2,7 @@ import type { DatamodelMetadataJson, DatamodelMetadataXsd, } from 'app-shared/types/DatamodelMetadata'; +import { createJsonModelPathMock, createXsdModelPathMock } from 'app-shared/mocks/modelPathMocks'; export const datamodelNameMock = 'model1'; const description = null; @@ -15,18 +16,21 @@ const metadataMockBase = { lastChanged, }; -export const jsonMetadataMock: DatamodelMetadataJson = { +export const createJsonMetadataMock = (modelName: string): DatamodelMetadataJson => ({ ...metadataMockBase, - fileName: `${datamodelNameMock}.schema.json`, - filePath: `${directory}/${datamodelNameMock}.schema.json`, + fileName: `${modelName}.schema.json`, + filePath: `${directory}/${modelName}.schema.json`, fileType: '.json', - repositoryRelativeUrl: `/App/models/${datamodelNameMock}.schema.json`, -}; + repositoryRelativeUrl: createJsonModelPathMock(modelName), +}); -export const xsdMetadataMock: DatamodelMetadataXsd = { +export const createXsdMetadataMock = (modelName: string): DatamodelMetadataXsd => ({ ...metadataMockBase, - fileName: `${datamodelNameMock}.xsd`, - filePath: `${directory}/${datamodelNameMock}.xsd`, + fileName: `${modelName}.xsd`, + filePath: `${directory}/${modelName}.xsd`, fileType: '.xsd', - repositoryRelativeUrl: `/App/models/${datamodelNameMock}.xsd`, -}; + repositoryRelativeUrl: createXsdModelPathMock(modelName), +}); + +export const jsonMetadataMock: DatamodelMetadataJson = createJsonMetadataMock(datamodelNameMock); +export const xsdMetadataMock: DatamodelMetadataXsd = createXsdMetadataMock(datamodelNameMock); diff --git a/frontend/packages/shared/src/mocks/modelPathMocks.ts b/frontend/packages/shared/src/mocks/modelPathMocks.ts new file mode 100644 index 00000000000..b098721ac3d --- /dev/null +++ b/frontend/packages/shared/src/mocks/modelPathMocks.ts @@ -0,0 +1,2 @@ +export const createJsonModelPathMock = (name: string): string => `/App/models/${name}.schema.json`; +export const createXsdModelPathMock = (name: string): string => `/App/models/${name}.xsd`; diff --git a/frontend/testing/testUtils.ts b/frontend/testing/testUtils.ts index 0f2c2e5cdbb..61363927f41 100644 --- a/frontend/testing/testUtils.ts +++ b/frontend/testing/testUtils.ts @@ -1,3 +1,4 @@ +import { waitFor } from '@testing-library/react'; import { APP_DEVELOPMENT_BASENAME } from 'app-shared/constants'; export const TEST_DOMAIN = 'http://localhost'; @@ -8,3 +9,5 @@ export const setWindowLocationForTests = (org: string, app: string) => { `${TEST_DOMAIN}${APP_DEVELOPMENT_BASENAME}/${org}/${app}`, ) as unknown as Location; }; + +export const verifyNeverOccurs = (fn: () => void) => expect(waitFor(fn)).rejects.toThrow();