diff --git a/.github/workflows/template-flux-config-push.yaml b/.github/workflows/template-flux-config-push.yaml index 4c405347b63..51713a79532 100644 --- a/.github/workflows/template-flux-config-push.yaml +++ b/.github/workflows/template-flux-config-push.yaml @@ -72,7 +72,7 @@ jobs: --password-stdin - name: Setup Flux CLI - uses: fluxcd/flux2/action@v2.3.0 + uses: fluxcd/flux2/action@v2.4.0 - name: Generate cofiguration file run: | diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/LandingPagePanel.module.css b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/LandingPagePanel.module.css index 5a67bd9d759..6fbebba2061 100644 --- a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/LandingPagePanel.module.css +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/LandingPagePanel.module.css @@ -1,9 +1,9 @@ .buttonWrapper { display: flex; flex-direction: row; - gap: var(--ds-spacing-3); - padding-top: var(--ds-spacing-3); - padding-bottom: var(--ds-spacing-3); + gap: var(--fds-spacing-3); + padding-top: var(--fds-spacing-3); + padding-bottom: var(--fds-spacing-3); align-items: center; } @@ -13,7 +13,7 @@ background-color: var(--fds-semantic-surface-info-subtle); box-shadow: 1px 1px 3px 2px rgb(0 0 0 / 25%); padding: var(--fds-spacing-10); - gap: var(--ds-spacing-8); + gap: var(--fds-spacing-8); width: 40%; height: fit-content; margin-top: var(--fds-spacing-10); diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SchemaEditorWithToolbar.tsx b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SchemaEditorWithToolbar.tsx index 3a68e7c8ce1..58bfbbccbbf 100644 --- a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SchemaEditorWithToolbar.tsx +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SchemaEditorWithToolbar.tsx @@ -18,7 +18,7 @@ export const SchemaEditorWithToolbar = ({ createPathOption, dataModels, }: SchemaEditorWithToolbarProps) => { - const [createNewOpen, setCreateNewOpen] = useState(false); + const [isCreateNewOpen, setIsCreateNewOpen] = useState(false); const [selectedOption, setSelectedOption] = useState(undefined); const [schemaGenerationErrorMessages, setSchemaGenerationErrorMessages] = useState([]); const { mutate: addXsdFromRepo } = useAddXsdMutation(); @@ -41,11 +41,11 @@ export const SchemaEditorWithToolbar = ({ return (
setSchemaGenerationErrorMessages(errorMessages) @@ -58,7 +58,7 @@ export const SchemaEditorWithToolbar = ({ /> )}
- {!dataModels.length && setCreateNewOpen(true)} />} + {!dataModels.length && setIsCreateNewOpen(true)} />} {modelPath && }
diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/CreateNewWrapper.module.css b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/CreateNewWrapper.module.css deleted file mode 100644 index d799729b9f7..00000000000 --- a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/CreateNewWrapper.module.css +++ /dev/null @@ -1,4 +0,0 @@ -.popoverContent { - /* This is so that it goes above the header which has an z-index of 2000 */ - z-index: 2001; -} diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/CreateNewWrapper.test.tsx b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/CreateNewWrapper.test.tsx deleted file mode 100644 index b4edbb3df68..00000000000 --- a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/CreateNewWrapper.test.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import React from 'react'; -import { screen } from '@testing-library/react'; -import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event'; -import type { CreateNewWrapperProps } from './CreateNewWrapper'; -import { CreateNewWrapper } from './CreateNewWrapper'; -import { textMock } from '@studio/testing/mocks/i18nMock'; -import { - dataModel1NameMock, - jsonMetadata1Mock, -} from '../../../../../packages/schema-editor/test/mocks/metadataMocks'; -import { renderWithProviders } from '../../../../test/testUtils'; -import { app, org } from '@studio/testing/testids'; -import { APP_DEVELOPMENT_BASENAME } from 'app-shared/constants'; -import { QueryKey } from 'app-shared/types/QueryKey'; -import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; - -// Test data: -const handleCreateSchema = jest.fn(); -const setCreateNewOpen = jest.fn(); -const defaultProps: CreateNewWrapperProps = { - createNewOpen: false, - dataModels: [], - disabled: false, - handleCreateSchema, - setCreateNewOpen, -}; - -describe('CreateNewWrapper', () => { - afterEach(jest.clearAllMocks); - - it('should open the popup when clicking "new" button', async () => { - const user = userEvent.setup(); - render(); - - expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); - expect( - screen.queryByRole('button', { - name: textMock('schema_editor.create_model_confirm_button'), - }), - ).not.toBeInTheDocument(); - - const newButton = screen.getByRole('button', { - name: textMock('general.create_new'), - }); - await user.click(newButton); - - expect(setCreateNewOpen).toHaveBeenCalledTimes(1); - expect(setCreateNewOpen).toHaveBeenCalledWith(true); - }); - - it('should close the popup when clicking "new" button', async () => { - const user = userEvent.setup(); - render({ createNewOpen: true }); - expect(screen.getByRole('textbox')).toBeInTheDocument(); - expect(okButton()).toBeInTheDocument(); - - const newButton = screen.getByRole('button', { - name: textMock('general.create_new'), - }); - - await user.click(newButton); - expect(setCreateNewOpen).toHaveBeenCalledTimes(1); - expect(setCreateNewOpen).toHaveBeenCalledWith(false); - }); - - describe('createAction', () => { - it('should call handleCreateSchema callback when ok button is clicked', async () => { - const user = userEvent.setup(); - render({ createNewOpen: true }); - - const textInput = screen.getByRole('textbox'); - await user.type(textInput, 'new-model'); - await user.click(okButton()); - expect(handleCreateSchema).toHaveBeenCalledWith({ - name: 'new-model', - relativePath: undefined, - }); - }); - - it('should call handleCreateSchema callback when input is focused and Enter key is pressed', async () => { - const user = userEvent.setup(); - render({ createNewOpen: true }); - - const textInput = screen.getByRole('textbox'); - - await user.type(textInput, 'new-model'); - await user.keyboard('{Enter}'); - expect(handleCreateSchema).toHaveBeenCalledWith({ - name: 'new-model', - relativePath: undefined, - }); - }); - - it('should call handleCreateSchema callback with relativePath when createPathOption is set and ok button is clicked', async () => { - const user = userEvent.setup(); - render({ createNewOpen: true, createPathOption: true }); - - const textInput = screen.getByRole('textbox'); - await user.type(textInput, 'new-model'); - await user.click(okButton()); - expect(handleCreateSchema).toHaveBeenCalledWith({ - name: 'new-model', - relativePath: '', - }); - }); - - it('should not call handleCreateSchema callback and show error message when trying to create a new model with the same name as an existing one when ok button is clicked', async () => { - const user = userEvent.setup(); - const newModelName = dataModel1NameMock; - const errMessage = textMock('schema_editor.error_model_name_exists', { newModelName }); - render({ createNewOpen: true, dataModels: [jsonMetadata1Mock] }); - - const textInput = screen.getByRole('textbox'); - - expect(screen.queryByText(errMessage)).not.toBeInTheDocument(); - await user.type(textInput, newModelName); - - expect(okButton()).toBeDisabled(); - expect(handleCreateSchema).not.toHaveBeenCalled(); - expect(screen.getByText(errMessage)).toBeInTheDocument(); - }); - - it('should not call handleCreateSchema callback when trying to create a new model with no name when ok button is clicked', async () => { - const userWithNoPointerEventCheck = userEvent.setup({ - pointerEventsCheck: PointerEventsCheckLevel.Never, - }); - render({ createNewOpen: true, dataModels: [jsonMetadata1Mock] }); - - await userWithNoPointerEventCheck.click(okButton()); - - expect(handleCreateSchema).not.toHaveBeenCalled(); - }); - - it('should not allow a name already in use in applicationmetadata json file', async () => { - const user = userEvent.setup(); - - const dataTypeName = 'testmodel'; - const queryClient = createQueryClientMock(); - queryClient.setQueryData([QueryKey.AppMetadata, org, app], { - dataTypes: [{ id: dataTypeName }], - }); - render({ createNewOpen: true, dataModels: [jsonMetadata1Mock] }, queryClient); - - await user.type(screen.getByRole('textbox'), dataTypeName); - expect( - screen.getByText(textMock('schema_editor.error_data_type_name_exists')), - ).toBeInTheDocument(); - - expect(okButton()).toBeDisabled(); - }); - }); -}); - -const okButton = () => { - return screen.getByRole('button', { - name: textMock('schema_editor.create_model_confirm_button'), - }); -}; - -const render = ( - props: Partial = {}, - queryClient = createQueryClientMock(), -) => { - renderWithProviders(, { - startUrl: `${APP_DEVELOPMENT_BASENAME}/${org}/${app}/ui-editor`, - queryClient, - }); -}; diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/CreateNewWrapper.tsx b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/CreateNewWrapper.tsx deleted file mode 100644 index ab21b0c3bea..00000000000 --- a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/CreateNewWrapper.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import React, { useState } from 'react'; -import classes from './CreateNewWrapper.module.css'; -import { ErrorMessage, Textfield } from '@digdir/designsystemet-react'; -import { useTranslation } from 'react-i18next'; -import { PlusIcon } from '@studio/icons'; -import { extractModelNamesFromMetadataList } from '../../../../utils/metadataUtils'; -import type { DataModelMetadata } from 'app-shared/types/DataModelMetadata'; -import { StudioButton, StudioPopover } from '@studio/components'; -import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; -import { useAppMetadataQuery } from 'app-shared/hooks/queries'; - -export interface CreateNewWrapperProps { - disabled: boolean; - createNewOpen: boolean; - createPathOption?: boolean; - dataModels: DataModelMetadata[]; - setCreateNewOpen: (open: boolean) => void; - handleCreateSchema: (props: { name: string; relativePath: string | undefined }) => void; -} - -export function CreateNewWrapper({ - disabled, - createPathOption = false, - createNewOpen, - dataModels, - setCreateNewOpen, - handleCreateSchema, -}: CreateNewWrapperProps) { - const { t } = useTranslation(); - const [newModelName, setNewModelName] = useState(''); - const [nameError, setNameError] = useState(''); - - const { org, app } = useStudioEnvironmentParams(); - const { data: appMetadata } = useAppMetadataQuery(org, app); - const modelNames = extractModelNamesFromMetadataList(dataModels); - - const relativePath = createPathOption ? '' : undefined; - - const onNameChange = (e: any) => { - const name = e.target.value || ''; - setNewModelName(name); - validateName(name); - }; - - const dataTypeWithNameExists = (id: string) => { - return appMetadata?.dataTypes?.find( - (dataType) => dataType.id.toLowerCase() === id.toLowerCase(), - ); - }; - - const nameValidationRegex = /^[a-zA-Z][a-zA-Z0-9_.\-æÆøØåÅ ]*$/; - const validateName = (name: string) => { - if (!name || !name.match(nameValidationRegex)) { - setNameError(t('schema_editor.invalid_datamodel_name')); - return; - } - if (modelNames.includes(name)) { - setNameError(t('schema_editor.error_model_name_exists', { newModelName: name })); - return; - } - if (dataTypeWithNameExists(name)) { - setNameError(t('schema_editor.error_data_type_name_exists')); - return; - } - setNameError(''); - }; - - const onCreateConfirmClick = () => { - handleCreateSchema({ - name: newModelName, - relativePath, - }); - setNewModelName(''); - setNameError(''); - }; - - const onKeyUp = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - onCreateConfirmClick(); - } - }; - - return ( - - setCreateNewOpen(!createNewOpen)} - size='small' - > - {} - {t('general.create_new')} - - - {nameError}} - /> - - {t('schema_editor.create_model_confirm_button')} - - - - ); -} diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/CreateNewWrapper/CreateNewWrapper.module.css b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/CreateNewWrapper/CreateNewWrapper.module.css new file mode 100644 index 00000000000..af5356f29e0 --- /dev/null +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/CreateNewWrapper/CreateNewWrapper.module.css @@ -0,0 +1,8 @@ +.popover { + display: flex; + flex-direction: column; + gap: var(--fds-spacing-3); + + /* This is so that popovers goes above the header which has an z-index of 2000 */ + z-index: 2001; +} diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/CreateNewWrapper/CreateNewWrapper.test.tsx b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/CreateNewWrapper/CreateNewWrapper.test.tsx new file mode 100644 index 00000000000..018b114c4f2 --- /dev/null +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/CreateNewWrapper/CreateNewWrapper.test.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event'; +import type { CreateNewWrapperProps } from './CreateNewWrapper'; +import { CreateNewWrapper } from './CreateNewWrapper'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { renderWithProviders } from '../../../../../test/testUtils'; +import { app, org } from '@studio/testing/testids'; +import { APP_DEVELOPMENT_BASENAME } from 'app-shared/constants'; +import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; + +// Test data: +const mockCreateDataModel = jest.fn(); +const mockSetIsCreateNewOpen = jest.fn(); +const defaultProps: CreateNewWrapperProps = { + isCreateNewOpen: false, + disabled: false, + setIsCreateNewOpen: mockSetIsCreateNewOpen, + dataModels: [], +}; + +describe('CreateNewWrapper', () => { + afterEach(jest.clearAllMocks); + + it('should open the popup when clicking "new" button', async () => { + const user = userEvent.setup(); + renderCreateNewWrapper(); + + expect(queryInputField()).not.toBeInTheDocument(); + expect(queryConfirmButton()).not.toBeInTheDocument(); + + await user.click(getNewButton()); + + expect(mockSetIsCreateNewOpen).toHaveBeenCalledTimes(1); + expect(mockSetIsCreateNewOpen).toHaveBeenCalledWith(true); + }); + + it('should close the popup when clicking "new" button', async () => { + const user = userEvent.setup(); + renderCreateNewWrapper({ isCreateNewOpen: true }); + + expect(queryInputField()).toBeInTheDocument(); + expect(queryConfirmButton()).toBeInTheDocument(); + + await user.click(getNewButton()); + + expect(mockSetIsCreateNewOpen).toHaveBeenCalledTimes(1); + expect(mockSetIsCreateNewOpen).toHaveBeenCalledWith(false); + }); + + it('should disable confirm button and show an error text when validation fails', async () => { + const user = userEvent.setup(); + const newModelName = '_InvalidName'; + const errorMessage = textMock('schema_editor.error_invalid_datamodel_name'); + renderCreateNewWrapper({ isCreateNewOpen: true }); + + expect(queryErrorMessage(errorMessage)).not.toBeInTheDocument(); + + await user.type(queryInputField(), newModelName); + + expect(queryErrorMessage(errorMessage)).toBeInTheDocument(); + expect(queryConfirmButton()).toBeDisabled(); + }); + + describe('createDataModel', () => { + it('should call createDataModel when confirm button is clicked', async () => { + const user = userEvent.setup(); + renderCreateNewWrapper({ isCreateNewOpen: true }); + + await user.type(queryInputField(), 'new-model'); + await user.click(queryConfirmButton()); + + expect(mockCreateDataModel).toHaveBeenCalledWith(org, app, { + modelName: 'new-model', + relativeDirectory: undefined, + }); + }); + + it('should call createDataModel when input is focused and Enter key is pressed', async () => { + const user = userEvent.setup(); + renderCreateNewWrapper({ isCreateNewOpen: true }); + + await user.type(queryInputField(), 'new-model'); + await user.keyboard('{Enter}'); + + expect(mockCreateDataModel).toHaveBeenCalledWith(org, app, { + modelName: 'new-model', + relativeDirectory: undefined, + }); + }); + + it('should call createDataModel with relativePath when createPathOption is set and ok button is clicked', async () => { + const user = userEvent.setup(); + renderCreateNewWrapper({ isCreateNewOpen: true, createPathOption: true }); + + await user.type(queryInputField(), 'new-model'); + await user.click(queryConfirmButton()); + + expect(mockCreateDataModel).toHaveBeenCalledWith(org, app, { + modelName: 'new-model', + relativeDirectory: '', + }); + }); + + it('should not call createDataModel when name field is empty and ok button is clicked', async () => { + const userWithNoPointerEventCheck = userEvent.setup({ + pointerEventsCheck: PointerEventsCheckLevel.Never, + }); + renderCreateNewWrapper({ isCreateNewOpen: true }); + + await userWithNoPointerEventCheck.click(queryConfirmButton()); + + expect(mockCreateDataModel).not.toHaveBeenCalled(); + }); + + it('should not call createDataModel when name field is empty and enter button is pressed', async () => { + const userWithNoPointerEventCheck = userEvent.setup({ + pointerEventsCheck: PointerEventsCheckLevel.Never, + }); + renderCreateNewWrapper({ isCreateNewOpen: true }); + + await userWithNoPointerEventCheck.keyboard('{Enter}'); + + expect(mockCreateDataModel).not.toHaveBeenCalled(); + }); + }); +}); + +const getNewButton = () => screen.getByRole('button', { name: textMock('general.create_new') }); + +const queryInputField = () => + screen.queryByRole('textbox', { name: textMock('schema_editor.create_model_description') }); + +const queryErrorMessage = (errorMessage: string) => { + return screen.queryByText(errorMessage); +}; + +const queryConfirmButton = () => + screen.queryByRole('button', { name: textMock('schema_editor.create_model_confirm_button') }); + +const renderCreateNewWrapper = ( + props: Partial = {}, + queryClient = createQueryClientMock(), +) => { + renderWithProviders(, { + queries: { createDataModel: mockCreateDataModel }, + startUrl: `${APP_DEVELOPMENT_BASENAME}/${org}/${app}/ui-editor`, + queryClient, + }); +}; diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/CreateNewWrapper/CreateNewWrapper.tsx b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/CreateNewWrapper/CreateNewWrapper.tsx new file mode 100644 index 00000000000..f647e15cdb7 --- /dev/null +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/CreateNewWrapper/CreateNewWrapper.tsx @@ -0,0 +1,105 @@ +import type { ChangeEvent, KeyboardEvent } from 'react'; +import React, { useState } from 'react'; +import classes from './CreateNewWrapper.module.css'; +import { useTranslation } from 'react-i18next'; +import { PlusIcon } from '@studio/icons'; +import { StudioButton, StudioPopover, StudioTextfield } from '@studio/components'; +import { useValidateSchemaName } from 'app-shared/hooks/useValidateSchemaName'; +import { useCreateDataModelMutation } from '../../../../../hooks/mutations'; +import type { DataModelMetadata } from 'app-shared/types/DataModelMetadata'; +import { extractModelNamesFromMetadataList } from '../../../../../utils/metadataUtils'; +import { useAppMetadataQuery } from 'app-shared/hooks/queries'; +import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; +import { extractDataTypeNamesFromAppMetadata } from '../utils/validationUtils'; + +export interface CreateNewWrapperProps { + disabled: boolean; + isCreateNewOpen: boolean; + createPathOption?: boolean; + dataModels: DataModelMetadata[]; + setIsCreateNewOpen: (open: boolean) => void; +} + +export function CreateNewWrapper({ + disabled, + createPathOption = false, + isCreateNewOpen, + dataModels, + setIsCreateNewOpen, +}: CreateNewWrapperProps) { + const { org, app } = useStudioEnvironmentParams(); + const { data: appMetadata } = useAppMetadataQuery(org, app); + const { mutate: createDataModel } = useCreateDataModelMutation(); + const dataModelNames = extractModelNamesFromMetadataList(dataModels); + const dataTypeNames = extractDataTypeNamesFromAppMetadata(appMetadata); + const { validateName, nameError, setNameError } = useValidateSchemaName( + dataModelNames, + dataTypeNames, + ); + const [newModelName, setNewModelName] = useState(''); + const { t } = useTranslation(); + + const isConfirmButtonActivated = newModelName && !nameError; + const relativePath = createPathOption ? '' : undefined; + + const handleNameChange = (e: ChangeEvent) => { + const name = e.target.value || ''; + setNewModelName(name); + validateName(name); + }; + + const handleConfirm = () => { + createDataModel({ + name: newModelName, + relativePath, + }); + + handleOpenChange(); + }; + + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Enter' && isConfirmButtonActivated) { + handleConfirm(); + } + }; + + const handleOpenChange = () => { + setIsCreateNewOpen(!isCreateNewOpen); + setNewModelName(''); + setNameError(''); + }; + + return ( + + + {} + {t('general.create_new')} + + + + + {t('schema_editor.create_model_confirm_button')} + + + + ); +} diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/CreateNewWrapper/index.ts b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/CreateNewWrapper/index.ts new file mode 100644 index 00000000000..38059909724 --- /dev/null +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/CreateNewWrapper/index.ts @@ -0,0 +1 @@ +export { CreateNewWrapper } from './CreateNewWrapper'; diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/DeleteWrapper/DeleteWrapper.module.css b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/DeleteWrapper/DeleteWrapper.module.css new file mode 100644 index 00000000000..efdc64c3fb5 --- /dev/null +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/DeleteWrapper/DeleteWrapper.module.css @@ -0,0 +1,4 @@ +.popover { + /* This is so that popovers goes above the header which has an z-index of 2000 */ + z-index: 2001; +} diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/DeleteWrapper.test.tsx b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/DeleteWrapper/DeleteWrapper.test.tsx similarity index 94% rename from frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/DeleteWrapper.test.tsx rename to frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/DeleteWrapper/DeleteWrapper.test.tsx index 0ef1ed60ccb..ac81274898a 100644 --- a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/DeleteWrapper.test.tsx +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/DeleteWrapper/DeleteWrapper.test.tsx @@ -7,11 +7,11 @@ import { textMock } from '@studio/testing/mocks/i18nMock'; import { jsonMetadata1Mock, jsonMetadata2Mock, -} from '../../../../../packages/schema-editor/test/mocks/metadataMocks'; +} from '../../../../../../packages/schema-editor/test/mocks/metadataMocks'; import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; import { QueryKey } from 'app-shared/types/QueryKey'; -import { convertMetadataToOption } from '../../../../utils/metadataUtils'; -import { renderWithProviders } from '../../../../test/mocks'; +import { convertMetadataToOption } from '../../../../../utils/metadataUtils'; +import { renderWithProviders } from '../../../../../test/mocks'; import type { QueryClient } from '@tanstack/react-query'; import { app, org } from '@studio/testing/testids'; import { queriesMock } from 'app-shared/mocks/queriesMock'; diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/DeleteWrapper.tsx b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/DeleteWrapper/DeleteWrapper.tsx similarity index 89% rename from frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/DeleteWrapper.tsx rename to frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/DeleteWrapper/DeleteWrapper.tsx index 0fdc14c7229..1c0c12c5d01 100644 --- a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/DeleteWrapper.tsx +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/DeleteWrapper/DeleteWrapper.tsx @@ -2,12 +2,14 @@ import React from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { StudioButton } from '@studio/components'; import { TrashIcon } from '@studio/icons'; -import { useDeleteDataModelMutation } from '../../../../hooks/mutations'; -import type { MetadataOption } from '../../../../types/MetadataOption'; +import { useDeleteDataModelMutation } from '../../../../../hooks/mutations'; +import type { MetadataOption } from '../../../../../types/MetadataOption'; import { AltinnConfirmDialog } from 'app-shared/components'; import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; import { useUpdateBpmn } from 'app-shared/hooks/useUpdateBpmn'; import { removeDataTypeIdsToSign } from 'app-shared/utils/bpmnUtils'; +import classes from './DeleteWrapper.module.css'; + export interface DeleteWrapperProps { selectedOption: MetadataOption | null; } @@ -36,6 +38,7 @@ export function DeleteWrapper({ selectedOption }: DeleteWrapperProps) { return ( ; }; diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/TopToolbar.tsx b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/TopToolbar.tsx index 9139f7af038..cf3ff87ccb6 100644 --- a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/TopToolbar.tsx +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/TopToolbar.tsx @@ -5,8 +5,6 @@ import { XSDUpload } from './XSDUpload'; import { SchemaSelect } from './SchemaSelect'; import { DeleteWrapper } from './DeleteWrapper'; import { computeSelectedOption } from '../../../../utils/metadataUtils'; -import type { CreateDataModelMutationArgs } from '../../../../hooks/mutations/useCreateDataModelMutation'; -import { useCreateDataModelMutation } from '../../../../hooks/mutations'; import type { MetadataOption } from '../../../../types/MetadataOption'; import { GenerateModelsButton } from './GenerateModelsButton'; import { usePrevious } from '@studio/components'; @@ -14,47 +12,40 @@ import type { DataModelMetadata } from 'app-shared/types/DataModelMetadata'; import { useTranslation } from 'react-i18next'; export interface TopToolbarProps { - createNewOpen: boolean; + isCreateNewOpen: boolean; createPathOption?: boolean; dataModels: DataModelMetadata[]; selectedOption?: MetadataOption; - setCreateNewOpen: (open: boolean) => void; + setIsCreateNewOpen: (open: boolean) => void; setSelectedOption: (option?: MetadataOption) => void; onSetSchemaGenerationErrorMessages: (errorMessages: string[]) => void; } export function TopToolbar({ - createNewOpen, + isCreateNewOpen, createPathOption, dataModels, selectedOption, - setCreateNewOpen, + setIsCreateNewOpen, setSelectedOption, onSetSchemaGenerationErrorMessages, }: TopToolbarProps) { const modelPath = selectedOption?.value.repositoryRelativeUrl; const { t } = useTranslation(); - const { mutate: createDataModel } = useCreateDataModelMutation(); const prevDataModels = usePrevious(dataModels); useEffect(() => { setSelectedOption(computeSelectedOption(selectedOption, dataModels, prevDataModels)); }, [selectedOption, dataModels, prevDataModels, setSelectedOption]); - const handleCreateSchema = (model: CreateDataModelMutationArgs) => { - createDataModel(model); - setCreateNewOpen(false); - }; - return (
{ const { t } = useTranslation(); diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload/FileNameError.ts b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/types/FileNameError.ts similarity index 100% rename from frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload/FileNameError.ts rename to frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/types/FileNameError.ts diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/utils/validationUtils.test.ts b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/utils/validationUtils.test.ts new file mode 100644 index 00000000000..658a8330d1b --- /dev/null +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/utils/validationUtils.test.ts @@ -0,0 +1,23 @@ +import { mockAppMetadata, mockDataTypeId } from '../../../../../test/applicationMetadataMock'; +import { extractDataTypeNamesFromAppMetadata } from './validationUtils'; + +describe('extractDataTypeNamesFromAppMetadata', () => { + it('should extract data type names when application metadata is provided', () => { + const dataTypeNames = extractDataTypeNamesFromAppMetadata(mockAppMetadata); + expect(dataTypeNames).toEqual([mockDataTypeId]); + }); + + it('should return an empty array when dataTypes is undefined', () => { + const mockAppMetadataCopy = { ...mockAppMetadata }; + delete mockAppMetadataCopy.dataTypes; + + const dataTypeNames = extractDataTypeNamesFromAppMetadata(mockAppMetadataCopy); + + expect(dataTypeNames).toEqual([]); + }); + + it('should return an empty array when application metadata is undefined', () => { + const dataTypeNames = extractDataTypeNamesFromAppMetadata(undefined); + expect(dataTypeNames).toEqual([]); + }); +}); diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload/validationUtils.ts b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/utils/validationUtils.ts similarity index 80% rename from frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload/validationUtils.ts rename to frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/utils/validationUtils.ts index 7367c51abd6..d65112503a6 100644 --- a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload/validationUtils.ts +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/utils/validationUtils.ts @@ -1,6 +1,7 @@ import type { ApplicationMetadata } from 'app-shared/types/ApplicationMetadata'; +import type { FileNameError } from '../types/FileNameError'; +import { DATA_MODEL_NAME_REGEX } from 'app-shared/constants'; import { FileNameUtils } from '@studio/pure-functions'; -import type { FileNameError } from './FileNameError'; export const doesFileExistInMetadataWithClassRef = ( appMetadata: ApplicationMetadata, @@ -39,11 +40,16 @@ export const findFileNameError = ( }; const isNameFormatValid = (fileNameWithoutExtension: string): boolean => { - const fileNameRegex: RegExp = /^[a-zA-Z][a-zA-Z0-9_.\-æÆøØåÅ ]*$/; - return Boolean(fileNameWithoutExtension.match(fileNameRegex)); + return Boolean(fileNameWithoutExtension.match(DATA_MODEL_NAME_REGEX)); }; const doesFileExistInMetadata = ( appMetadata: ApplicationMetadata, fileNameWithoutExtension: string, ): boolean => appMetadata.dataTypes?.some((dataType) => dataType.id === fileNameWithoutExtension); + +export const extractDataTypeNamesFromAppMetadata = ( + appMetadata?: ApplicationMetadata, +): string[] => { + return appMetadata?.dataTypes?.map((dataType) => dataType.id) || []; +}; diff --git a/frontend/app-development/hooks/mutations/useGenerateModelsMutation.ts b/frontend/app-development/hooks/mutations/useGenerateModelsMutation.ts index 9ba270b0979..ae8fa7c77c0 100644 --- a/frontend/app-development/hooks/mutations/useGenerateModelsMutation.ts +++ b/frontend/app-development/hooks/mutations/useGenerateModelsMutation.ts @@ -20,6 +20,8 @@ export const useGenerateModelsMutation = ( Promise.all([ queryClient.invalidateQueries({ queryKey: [QueryKey.DataModelsJson, org, app] }), queryClient.invalidateQueries({ queryKey: [QueryKey.DataModelsXsd, org, app] }), + queryClient.invalidateQueries({ queryKey: [QueryKey.AppMetadata, org, app] }), + queryClient.invalidateQueries({ queryKey: [QueryKey.AppMetadataModelIds, org, app] }), ]), meta, }); diff --git a/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/AccessControlTab/AccessControlTab.test.tsx b/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/AccessControlTab/AccessControlTab.test.tsx index f2fdd656671..f7116c1df68 100644 --- a/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/AccessControlTab/AccessControlTab.test.tsx +++ b/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/AccessControlTab/AccessControlTab.test.tsx @@ -6,7 +6,7 @@ import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext'; import { ServicesContextProvider } from 'app-shared/contexts/ServicesContext'; import type { QueryClient } from '@tanstack/react-query'; -import { mockAppMetadata } from '../../../mocks/applicationMetadataMock'; +import { mockAppMetadata } from '../../../../../../../../test/applicationMetadataMock'; import userEvent from '@testing-library/user-event'; import { app, org } from '@studio/testing/testids'; import { MemoryRouter } from 'react-router-dom'; diff --git a/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/AccessControlTab/SelectAllowedPartyTypes/SelectAllowedPartyTypes.test.tsx b/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/AccessControlTab/SelectAllowedPartyTypes/SelectAllowedPartyTypes.test.tsx index 7b95536f7c1..5321f69b76a 100644 --- a/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/AccessControlTab/SelectAllowedPartyTypes/SelectAllowedPartyTypes.test.tsx +++ b/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/AccessControlTab/SelectAllowedPartyTypes/SelectAllowedPartyTypes.test.tsx @@ -3,7 +3,7 @@ import { SelectAllowedPartyTypes, type SelectAllowedPartyTypesProps, } from './SelectAllowedPartyTypes'; -import { mockAppMetadata } from '../../../../mocks/applicationMetadataMock'; +import { mockAppMetadata } from '../../../../../../../../../test/applicationMetadataMock'; import { textMock } from '@studio/testing/mocks/i18nMock'; import { render as rtlRender, screen, waitFor } from '@testing-library/react'; import type { QueryClient } from '@tanstack/react-query'; diff --git a/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/SetupTab/SetupTab.test.tsx b/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/SetupTab/SetupTab.test.tsx index f82bfec3ec0..7d5e7842657 100644 --- a/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/SetupTab/SetupTab.test.tsx +++ b/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/SetupTab/SetupTab.test.tsx @@ -8,7 +8,7 @@ import type { QueryClient } from '@tanstack/react-query'; import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; import { MemoryRouter } from 'react-router-dom'; import { queriesMock } from 'app-shared/mocks/queriesMock'; -import { mockAppMetadata } from '../../../mocks/applicationMetadataMock'; +import { mockAppMetadata } from '../../../../../../../../test/applicationMetadataMock'; import { app, org } from '@studio/testing/testids'; const getAppMetadata = jest.fn().mockImplementation(() => Promise.resolve({})); diff --git a/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/SetupTab/SetupTabContent/SetupTabContent.test.tsx b/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/SetupTab/SetupTabContent/SetupTabContent.test.tsx index 8a3ced8835e..91f6c9765d5 100644 --- a/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/SetupTab/SetupTabContent/SetupTabContent.test.tsx +++ b/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/SetupTab/SetupTabContent/SetupTabContent.test.tsx @@ -10,7 +10,7 @@ import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; import { MemoryRouter } from 'react-router-dom'; import { queriesMock } from 'app-shared/mocks/queriesMock'; import type { ApplicationMetadata } from 'app-shared/types/ApplicationMetadata'; -import { mockAppMetadata } from '../../../../mocks/applicationMetadataMock'; +import { mockAppMetadata } from '../../../../../../../../../test/applicationMetadataMock'; import userEvent from '@testing-library/user-event'; import { app, org } from '@studio/testing/testids'; diff --git a/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/mocks/applicationMetadataMock.ts b/frontend/app-development/test/applicationMetadataMock.ts similarity index 85% rename from frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/mocks/applicationMetadataMock.ts rename to frontend/app-development/test/applicationMetadataMock.ts index a50499bd96b..5d407df4d09 100644 --- a/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/mocks/applicationMetadataMock.ts +++ b/frontend/app-development/test/applicationMetadataMock.ts @@ -1,6 +1,7 @@ import type { ApplicationMetadata, CopyInstanceSettings, + DataTypeElement, HideSettings, MessageBoxConfig, OnEntry, @@ -34,9 +35,17 @@ const mockOnEntry: OnEntry = { show: 'select-instance', }; +export const mockDataTypeId: string = 'mockDataTypeId'; +const mockDataTypes: DataTypeElement[] = [ + { + id: mockDataTypeId, + }, +]; + export const mockAppMetadata: ApplicationMetadata = { id: 'mockId', org, + dataTypes: mockDataTypes, partyTypesAllowed: mockPartyTypesAllowed, validFrom: mockValidFrom, validTo: mockValidTo, diff --git a/frontend/language/src/en.json b/frontend/language/src/en.json index bf00d62fbf2..ddf2d9d6352 100644 --- a/frontend/language/src/en.json +++ b/frontend/language/src/en.json @@ -638,13 +638,13 @@ "schema_editor.delete_data_model": "Delete data model", "schema_editor.delete_field": "Delete field", "schema_editor.delete_model_confirm": "Are you sure you want to delete {{schemaName}}?", - "schema_editor.depth_error": "It is not possible to put the group here because it will make the form exceed the maximum allowed depth.", "schema_editor.description": "Description", "schema_editor.descriptive_fields": "Descriptive fields", "schema_editor.enum": "Valid values", - "schema_editor.enum_empty": "The list is empty—all values will be approved.", + "schema_editor.enum_empty": "The list is empty. All values will be approved.", "schema_editor.enum_error_duplicate": "The values must be unique.", "schema_editor.enum_legend": "List of valid values", + "schema_editor.error_depth": "It is not possible to put the group here because it will make the form exceed the maximum allowed depth.", "schema_editor.error_model_name_exists": "A model with the name {{newModelName}} already exists.", "schema_editor.field": "Field", "schema_editor.field_name": "Field name", diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index bfd5859b960..3b0bb2cc4b1 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -881,7 +881,6 @@ "schema_editor.delete_data_model": "Slett datamodell", "schema_editor.delete_field": "Slett felt", "schema_editor.delete_model_confirm": "Er du sikker på at du vil slette datamodellen {{schemaName}}?", - "schema_editor.depth_error": "Du kan ikke plassere gruppen her, fordi skjemaet får for mange nivåer.", "schema_editor.description": "Tekst", "schema_editor.descriptive_fields": "Beskrivende felter", "schema_editor.disable_deletion_info_for_used_definition": "Du kan ikke slette en definisjon som er i bruk.", @@ -894,8 +893,12 @@ "schema_editor.error_could_not_detect_taskType": "Kunne ikke hente oppgavetype for {{layout}}", "schema_editor.error_could_not_detect_taskType_description": "Dette gjør at vi ikke kan vise riktig liste over tilgjengelige komponenter. Velg en annen sidegruppe eller prøv å last siden på nytt.", "schema_editor.error_data_type_name_exists": "Modellen kan ikke ha samme navn som datatyper i løsningen.", + "schema_editor.error_depth": "Du kan ikke plassere denne gruppen her, fordi skjemaet får for mange nivåer.", + "schema_editor.error_invalid_child": "Du kan ikke plassere denne komponenttypen i denne gruppen.", + "schema_editor.error_invalid_datamodel_name": "Navnet er ugyldig. Det første tegnet må være en stor eller liten bokstav fra a til z. Deretter kan du bruke små eller store bokstaver fra a til z, tall, understrek og bindestrek.", "schema_editor.error_model_name_exists": "Modellnavnet {{newModelName}} er allerede i bruk.", - "schema_editor.error_upload_data_model_id_exists_override_option": "Det eksisterer allerede en modell med dette navnet. Ønsker du å overskrive den?", + "schema_editor.error_reserved_keyword": "Systemet bruker allerede dette navnet. Velg et annet navn.", + "schema_editor.error_upload_data_model_id_exists_override_option": "Det finnes allerede en datamodell med dette navnet. Vil du overskrive den?", "schema_editor.field": "Objekt", "schema_editor.field_name": "Navn på felt", "schema_editor.fields": "Felter", @@ -929,8 +932,6 @@ "schema_editor.generate_model_files": "Generer modeller", "schema_editor.go_to_type": "Gå til type", "schema_editor.integer": "Heltall", - "schema_editor.invalid_child_error": "Du kan ikke plassere den komponenttypen i denne gruppen.", - "schema_editor.invalid_datamodel_name": "Navnet er ugyldig. Du kan bruke tall og store og små bokstaver fra det norske alfabetet, og understrek, punktum og bindestrek.", "schema_editor.language": "Språk", "schema_editor.language_add_language": "Legg til språk:", "schema_editor.language_confirm_deletion": "Ja, slett språket", @@ -1823,13 +1824,13 @@ "ux_editor.upload_file_error_too_large": "Kunne ikke laste opp filen. Den er for stor.", "ux_editor.url_label": "Lenke", "ux_editor.warning": "Advarsel", - "validation_errors.length": "Antall tillatte tegn er {{0}}", - "validation_errors.max": "Største gyldig verdi er {{0}}", - "validation_errors.maxLength": "Bruk {{0}} eller færre tegn", - "validation_errors.min": "Minste gyldig verdi er {{0}}", - "validation_errors.minLength": "Bruk {{0}} eller flere tegn", - "validation_errors.numbers_only": "Kun sifre er gyldige tegn", - "validation_errors.pattern": "Feil format eller verdi", - "validation_errors.required": "Feltet må fylles ut", - "validation_errors.value_as_url": "Ugyldig lenke" + "validation_errors.length": "Antall tillatte tegn er {{0}}.", + "validation_errors.max": "Største gyldige verdi er {{0}}.", + "validation_errors.maxLength": "Bruk {{number}} eller færre tegn.", + "validation_errors.min": "Minste gyldige verdi er {{0}}.", + "validation_errors.minLength": "Bruk {{0}} eller flere tegn.", + "validation_errors.numbers_only": "Du kan bare bruke sifre.", + "validation_errors.pattern": "Feil format eller verdi.", + "validation_errors.required": "Feltet må fylles ut.", + "validation_errors.value_as_url": "Ugyldig lenke." } diff --git a/frontend/libs/studio-components/package.json b/frontend/libs/studio-components/package.json index 0b48d6d77c2..c23c809d5f7 100644 --- a/frontend/libs/studio-components/package.json +++ b/frontend/libs/studio-components/package.json @@ -20,7 +20,7 @@ "uuid": "10.0.0" }, "devDependencies": { - "@chromatic-com/storybook": "2.0.2", + "@chromatic-com/storybook": "3.2.2", "@storybook/addon-essentials": "8.4.5", "@storybook/addon-interactions": "8.4.5", "@storybook/addon-links": "8.4.5", diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.test.tsx b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.test.tsx index 4aa68ee00aa..267f27e8a70 100644 --- a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.test.tsx +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.test.tsx @@ -273,6 +273,15 @@ describe('StudioCodeListEditor', () => { await user.type(invalidValueInput, 'new unique value'); expect(onInvalid).not.toHaveBeenCalled(); }); + + it('Does not trigger onInvalid if the code list is invalid, but onInvalid is not defined', async () => { + const user = userEvent.setup(); + renderCodeListEditor({ codeList: codeListWithDuplicatedValues, onInvalid: undefined }); + const validValueInput = screen.getByRole('textbox', { name: texts.itemValue(3) }); + const newValue = 'new value'; + await user.type(validValueInput, newValue); + expect(onInvalid).not.toHaveBeenCalled(); + }); }); function renderCodeListEditor(props: Partial = {}) { diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.tsx b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.tsx index 13a5906ace1..31d4dca0923 100644 --- a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.tsx +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.tsx @@ -60,8 +60,7 @@ function StatefulCodeListEditor({ const handleChange = useCallback( (newCodeList: CodeList) => { setCodeList(newCodeList); - if (isCodeListValid(newCodeList)) onChange(newCodeList); - else onInvalid(); + isCodeListValid(newCodeList) ? onChange(newCodeList) : onInvalid?.(); }, [onChange, onInvalid], ); diff --git a/frontend/packages/shared/src/constants.js b/frontend/packages/shared/src/constants.js index 193e01a5748..5cb7f7018c3 100644 --- a/frontend/packages/shared/src/constants.js +++ b/frontend/packages/shared/src/constants.js @@ -19,5 +19,5 @@ export const PROD_ENV_TYPE = 'production'; export const PROTECTED_TASK_NAME_CUSTOM_RECEIPT = 'CustomReceipt'; export const PREVIEW_MOCK_PARTY_ID = '51001'; export const PREVIEW_MOCK_INSTANCE_GUID = 'f1e23d45-6789-1bcd-8c34-56789abcdef0'; - export const MEDIA_QUERY_MAX_WIDTH = '(max-width: 1024px)'; +export const DATA_MODEL_NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_\-]*$/; diff --git a/frontend/packages/shared/src/hooks/useValidateSchemaName.test.ts b/frontend/packages/shared/src/hooks/useValidateSchemaName.test.ts new file mode 100644 index 00000000000..a9f984bdd7a --- /dev/null +++ b/frontend/packages/shared/src/hooks/useValidateSchemaName.test.ts @@ -0,0 +1,176 @@ +import { DATA_MODEL_NAME_MAX_LENGTH, useValidateSchemaName } from './useValidateSchemaName'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { act, renderHook } from '@testing-library/react'; + +// Test data +const existingModelName = 'existingModelName'; +const existingDataTypeName = 'existingDataTypeName'; +const dataModelNames = [existingModelName]; +const dataTypeNames = [existingDataTypeName, existingModelName]; + +describe('useValidateSchemaName', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should set nameError to empty string when name is valid', () => { + const { result } = renderUseValidateSchemaName(); + + act(() => { + result.current.validateName('ValidName'); + }); + + expect(result.current.nameError).toBe(''); + }); + + it('should set error when name is empty', () => { + const { result } = renderUseValidateSchemaName(); + + act(() => { + result.current.validateName(''); + }); + + expect(result.current.nameError).toBe(textMock('validation_errors.required')); + }); + + it('should set error when name exceeds max length', () => { + const longName = 'a'.repeat(DATA_MODEL_NAME_MAX_LENGTH + 1); + const { result } = renderUseValidateSchemaName(); + + act(() => { + result.current.validateName(longName); + }); + + expect(result.current.nameError).toBe( + textMock('validation_errors.maxLength', { number: DATA_MODEL_NAME_MAX_LENGTH }), + ); + }); + + it('should set error when data model with same name exists', () => { + const { result } = renderUseValidateSchemaName(); + + act(() => { + result.current.validateName(existingModelName); + }); + + expect(result.current.nameError).toBe( + textMock('schema_editor.error_model_name_exists', { + newModelName: existingModelName, + }), + ); + }); + + it('should set error when data type in appMetadata with same name exists, when the data type is not also a data model', () => { + const { result } = renderUseValidateSchemaName(); + + act(() => { + result.current.validateName(existingModelName); + }); + + expect(result.current.nameError).not.toBe( + textMock('schema_editor.error_data_type_name_exists'), + ); + + act(() => { + result.current.validateName(existingDataTypeName); + }); + + expect(result.current.nameError).toBe(textMock('schema_editor.error_data_type_name_exists')); + }); + + it('should set error when name is a C# reserved keyword', () => { + const { result } = renderUseValidateSchemaName(); + + act(() => { + result.current.validateName('class'); + }); + + expect(result.current.nameError).toBe(textMock('schema_editor.error_reserved_keyword')); + }); + + describe('regular expressions', () => { + it('should disallow numbers at start of name', () => { + const { result } = renderUseValidateSchemaName(); + const invalidFirstCharacters = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0']; + + invalidFirstCharacters.forEach((char) => { + act(() => { + result.current.validateName(char); + }); + + expect(result.current.nameError).toBe( + textMock('schema_editor.error_invalid_datamodel_name'), + ); + }); + }); + + it('should allow numbers in rest of name', () => { + const { result } = renderUseValidateSchemaName(); + + act(() => { + result.current.validateName('a1234567890'); + }); + + expect(result.current.nameError).toBe(''); + }); + + it('should disallow "-" and "_" at start of name', () => { + const { result } = renderUseValidateSchemaName(); + const invalidFirstCharacters = ['-', '_']; + + invalidFirstCharacters.forEach((char) => { + act(() => { + result.current.validateName(char); + }); + + expect(result.current.nameError).toBe( + textMock('schema_editor.error_invalid_datamodel_name'), + ); + }); + }); + + it('should allow "-" and "_" in rest of name', () => { + const { result } = renderUseValidateSchemaName(); + + act(() => { + result.current.validateName('a-_'); + }); + + expect(result.current.nameError).toBe(''); + }); + + it('should disallow " " and "." in name', () => { + const { result } = renderUseValidateSchemaName(); + const invalidCharacters = [' ', '.', 'a ', 'a.']; + + invalidCharacters.forEach((char) => { + act(() => { + result.current.validateName('a' + char); + }); + + expect(result.current.nameError).toBe( + textMock('schema_editor.error_invalid_datamodel_name'), + ); + }); + }); + + it('should disallow Norwegian special characters in name', () => { + const { result } = renderUseValidateSchemaName(); + const invalidNames = ['æ', 'ø', 'å', 'Æ', 'Ø', 'Å', 'aæ', 'aø', 'aå', 'aÆ', 'aØ', 'aÅ']; + + invalidNames.forEach((name) => { + act(() => { + result.current.validateName(name); + }); + + expect(result.current.nameError).toBe( + textMock('schema_editor.error_invalid_datamodel_name'), + ); + }); + }); + }); +}); + +const renderUseValidateSchemaName = () => { + return renderHook(() => useValidateSchemaName(dataModelNames, dataTypeNames)); +}; diff --git a/frontend/packages/shared/src/hooks/useValidateSchemaName.ts b/frontend/packages/shared/src/hooks/useValidateSchemaName.ts new file mode 100644 index 00000000000..e95c031270b --- /dev/null +++ b/frontend/packages/shared/src/hooks/useValidateSchemaName.ts @@ -0,0 +1,127 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { DATA_MODEL_NAME_REGEX } from 'app-shared/constants'; + +export const useValidateSchemaName = ( + existingDataModelNames: string[], + existingDataTypeNames: string[], +) => { + const [nameError, setNameError] = useState(''); + const { t } = useTranslation(); + + const validateName = (name: string): void => { + if (!name) { + setNameError(t('validation_errors.required')); + return; + } + if (!name.match(DATA_MODEL_NAME_REGEX)) { + setNameError(t('schema_editor.error_invalid_datamodel_name')); + return; + } + if (name.length > DATA_MODEL_NAME_MAX_LENGTH) { + setNameError(t('validation_errors.maxLength', { number: DATA_MODEL_NAME_MAX_LENGTH })); + return; + } + if (existingDataModelNames.includes(name)) { + setNameError(t('schema_editor.error_model_name_exists', { newModelName: name })); + return; + } + if (existingDataTypeNames.includes(name)) { + setNameError(t('schema_editor.error_data_type_name_exists')); + return; + } + if (isCSharpReservedKeyword(name)) { + setNameError(t('schema_editor.error_reserved_keyword')); + return; + } + setNameError(''); + }; + + return { validateName, nameError, setNameError }; +}; + +export const DATA_MODEL_NAME_MAX_LENGTH = 100; + +const isCSharpReservedKeyword = (word: string): boolean => { + const cSharpKeywords = new Set([ + 'abstract', + 'as', + 'base', + 'bool', + 'break', + 'byte', + 'case', + 'catch', + 'char', + 'checked', + 'class', + 'const', + 'continue', + 'decimal', + 'default', + 'delegate', + 'do', + 'double', + 'else', + 'enum', + 'event', + 'explicit', + 'extern', + 'false', + 'finally', + 'fixed', + 'float', + 'for', + 'foreach', + 'goto', + 'if', + 'implicit', + 'in', + 'int', + 'interface', + 'internal', + 'is', + 'lock', + 'long', + 'namespace', + 'new', + 'null', + 'object', + 'operator', + 'out', + 'override', + 'params', + 'private', + 'protected', + 'public', + 'readonly', + 'ref', + 'return', + 'sbyte', + 'sealed', + 'short', + 'sizeof', + 'stackalloc', + 'static', + 'string', + 'struct', + 'switch', + 'this', + 'throw', + 'true', + 'try', + 'typeof', + 'uint', + 'ulong', + 'unchecked', + 'unsafe', + 'ushort', + 'using', + 'virtual', + 'void', + 'volatile', + 'while', + ]); + + return cSharpKeywords.has(word); +}; diff --git a/frontend/packages/ux-editor-v3/src/containers/FormDesigner.tsx b/frontend/packages/ux-editor-v3/src/containers/FormDesigner.tsx index 5e161dca146..016eb168b27 100644 --- a/frontend/packages/ux-editor-v3/src/containers/FormDesigner.tsx +++ b/frontend/packages/ux-editor-v3/src/containers/FormDesigner.tsx @@ -104,8 +104,8 @@ export const FormDesigner = ({ } if (formLayoutIsReady) { - const triggerDepthAlert = () => alert(t('schema_editor.depth_error')); - const triggerInvalidChildAlert = () => alert(t('schema_editor.invalid_child_error')); + const triggerDepthAlert = () => alert(t('schema_editor.error_depth')); + const triggerInvalidChildAlert = () => alert(t('schema_editor.error_invalid_child')); const layout = formLayouts[selectedLayout]; const addItem: HandleAdd = (type, { parentId, index }) => { diff --git a/frontend/packages/ux-editor/src/containers/FormDesigner.tsx b/frontend/packages/ux-editor/src/containers/FormDesigner.tsx index d6f39ac88fd..8795a4eaf36 100644 --- a/frontend/packages/ux-editor/src/containers/FormDesigner.tsx +++ b/frontend/packages/ux-editor/src/containers/FormDesigner.tsx @@ -100,8 +100,8 @@ export const FormDesigner = (): JSX.Element => { } if (formLayoutIsReady) { - const triggerDepthAlert = () => alert(t('schema_editor.depth_error')); - const triggerInvalidChildAlert = () => alert(t('schema_editor.invalid_child_error')); + const triggerDepthAlert = () => alert(t('schema_editor.error_depth')); + const triggerInvalidChildAlert = () => alert(t('schema_editor.error_invalid_child')); const layout = formLayouts[selectedFormLayoutName]; const addItem: HandleAdd = (type, { parentId, index }) => { diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/Altinn.EFormidlingClient.Tests.csproj b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/Altinn.EFormidlingClient.Tests.csproj index f309ba52c21..c09d2e97678 100644 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/Altinn.EFormidlingClient.Tests.csproj +++ b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/Altinn.EFormidlingClient.Tests.csproj @@ -20,13 +20,13 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Altinn.EFormidlingClient.csproj b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Altinn.EFormidlingClient.csproj index e728ffff92c..f31f6a8ba2a 100644 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Altinn.EFormidlingClient.csproj +++ b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Altinn.EFormidlingClient.csproj @@ -25,7 +25,7 @@ - + diff --git a/yarn.lock b/yarn.lock index 033db7b4efc..1d55a972a85 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1920,16 +1920,18 @@ __metadata: languageName: node linkType: hard -"@chromatic-com/storybook@npm:2.0.2": - version: 2.0.2 - resolution: "@chromatic-com/storybook@npm:2.0.2" +"@chromatic-com/storybook@npm:3.2.2": + version: 3.2.2 + resolution: "@chromatic-com/storybook@npm:3.2.2" dependencies: - chromatic: "npm:^11.4.0" + chromatic: "npm:^11.15.0" filesize: "npm:^10.0.12" jsonfile: "npm:^6.1.0" react-confetti: "npm:^6.1.0" strip-ansi: "npm:^7.1.0" - checksum: 10/acd23f87072aaec2a4db174ec19b2479d9a0a4390f10ca804198f6453dce0c35055861bc19a4905fee97f8fd87e43b6c51fda7ce3e30bb355a2729af85f53440 + peerDependencies: + storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 + checksum: 10/71338edf56cdbc855074c78981f2e1612b364cd864fa99bbda5c0aad147769b9f476de2fd76816102fd504efc5c0c54ba559d5ac9e3828d53278fe7000863d54 languageName: node linkType: hard @@ -4108,7 +4110,7 @@ __metadata: version: 0.0.0-use.local resolution: "@studio/components@workspace:frontend/libs/studio-components" dependencies: - "@chromatic-com/storybook": "npm:2.0.2" + "@chromatic-com/storybook": "npm:3.2.2" "@storybook/addon-essentials": "npm:8.4.5" "@storybook/addon-interactions": "npm:8.4.5" "@storybook/addon-links": "npm:8.4.5" @@ -7448,9 +7450,9 @@ __metadata: languageName: node linkType: hard -"chromatic@npm:^11.4.0": - version: 11.5.1 - resolution: "chromatic@npm:11.5.1" +"chromatic@npm:^11.15.0": + version: 11.19.0 + resolution: "chromatic@npm:11.19.0" peerDependencies: "@chromatic-com/cypress": ^0.*.* || ^1.0.0 "@chromatic-com/playwright": ^0.*.* || ^1.0.0 @@ -7463,7 +7465,7 @@ __metadata: chroma: dist/bin.js chromatic: dist/bin.js chromatic-cli: dist/bin.js - checksum: 10/092406c78e3971b16221170f29581ae1d9ea4aa3c014e945aafeb8663cb44ebed97fbb8b36aedf93b0a336d4eca69440061ba8b9eb133f77a46059fe8452a691 + checksum: 10/e9e55d3ceeb6b7f7d1b591c123c83b07b3afacfac2deb4f6a48d2edd6664f5da65a3b2ab42002e5269c90894fa029755ad95f8763f5730cbeaa3ecc1de3ab309 languageName: node linkType: hard