diff --git a/frontend/app-development/router/routes.tsx b/frontend/app-development/router/routes.tsx index a53f0659f67..08b2fdcbca8 100644 --- a/frontend/app-development/router/routes.tsx +++ b/frontend/app-development/router/routes.tsx @@ -36,8 +36,9 @@ const isLatestFrontendVersion = (version: AppVersion): boolean => const UiEditor = () => { const { org, app } = useStudioUrlParams(); - const { data } = useAppVersionQuery(org, app); - return isLatestFrontendVersion(data) ? : ; + const { data: version } = useAppVersionQuery(org, app); + if (!version) return null; + return isLatestFrontendVersion(version) ? : ; }; export const routerRoutes: RouterRoute[] = [ diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index cb11b93255b..5d7e3f4edb7 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -1563,7 +1563,8 @@ "ux_editor.component_unknown": "Ukjent komponent", "ux_editor.conditional_rendering_connection_header": "Betingede renderingstilkoblinger", "ux_editor.container_empty": "Tomt, dra noe inn her...", - "ux_editor.container_not_editable_info": "Noen egenskaper for denne komponenten er ikke redigerbare for øyeblikket. Du kan legge til underkomponenter i kolonnen til venstre og redigere tekster.", + "ux_editor.container_not_editable_info": "Noen egenskaper for denne komponenten er ikke redigerbare for øyeblikket. Du kan legge til underkomponenter i kolonnen til venstre.", + "ux_editor.edit_component.id_help_text": "The component ID. Must be unique within all layouts/pages in a layout-set. Cannot end with .", "ux_editor.edit_component.loading_schema": "Laster inn skjema", "ux_editor.edit_component.show_beta_func": "Vis ny konfigurasjon (BETA)", "ux_editor.edit_component.show_beta_func_help_text": "Vi jobber med å få på plass støtte for å redigere alle innstillinger. Ved å huke av her kan du ta i bruk den nye konfigurasjonsvisningen, som støtter flere innstillinger. Merk at denne visningen fortsatt er under utvikling, og vil kunne oppleves som noe ustabil.", diff --git a/frontend/libs/studio-components/package.json b/frontend/libs/studio-components/package.json index 912776303ae..c8f4de82671 100644 --- a/frontend/libs/studio-components/package.json +++ b/frontend/libs/studio-components/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "@studio/icons": "^0.1.0", + "ajv": "8.12.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/frontend/libs/studio-components/src/components/StudioDecimalInput/StudioDecimalInput.tsx b/frontend/libs/studio-components/src/components/StudioDecimalInput/StudioDecimalInput.tsx index e859f1ac9d2..44e46daeab9 100644 --- a/frontend/libs/studio-components/src/components/StudioDecimalInput/StudioDecimalInput.tsx +++ b/frontend/libs/studio-components/src/components/StudioDecimalInput/StudioDecimalInput.tsx @@ -1,8 +1,13 @@ -import type { RefObject } from 'react'; -import React, { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { + forwardRef, + useCallback, + useEffect, + useMemo, + useState, + type RefObject, +} from 'react'; import { convertNumberToString, convertStringToNumber, isStringValidDecimalNumber } from './utils'; -import type { StudioTextfieldProps } from '../StudioTextfield'; -import { StudioTextfield } from '../StudioTextfield'; +import { type StudioTextfieldProps, StudioTextfield } from '../StudioTextfield'; export interface StudioDecimalInputProps extends Omit { description: string; diff --git a/frontend/libs/studio-components/src/components/StudioIconTextfield/StudioIconTextfield.module.css b/frontend/libs/studio-components/src/components/StudioIconTextfield/StudioIconTextfield.module.css new file mode 100644 index 00000000000..523979a60a1 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioIconTextfield/StudioIconTextfield.module.css @@ -0,0 +1,14 @@ +.container { + display: flex; + gap: var(--fds-spacing-2); +} + +.prefixIcon { + color: var(--fds-semantic-text-neutral-default); + margin-top: var(--fds-spacing-7); + font-size: var(--fds-sizing-6); +} + +.textfield { + width: 100%; +} diff --git a/frontend/libs/studio-components/src/components/StudioIconTextfield/StudioIconTextfield.test.tsx b/frontend/libs/studio-components/src/components/StudioIconTextfield/StudioIconTextfield.test.tsx new file mode 100644 index 00000000000..7f1f1f6e64a --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioIconTextfield/StudioIconTextfield.test.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { StudioIconTextfield } from './StudioIconTextfield'; +import type { StudioIconTextfieldProps } from './StudioIconTextfield'; +import { KeyVerticalIcon } from '@navikt/aksel-icons'; +import userEvent from '@testing-library/user-event'; + +describe('StudioIconTextfield', () => { + it('render the icon', async () => { + renderStudioIconTextfield({ + icon: , + }); + expect(screen.getByTitle('my key icon title')).toBeInTheDocument(); + }); + + it('should render label', () => { + renderStudioIconTextfield({ + icon:
, + label: 'id', + }); + expect(screen.getByLabelText('id')).toBeInTheDocument(); + }); + + it('should execute onChange callback when input value changes', async () => { + const user = userEvent.setup(); + const onChangeMock = jest.fn(); + + renderStudioIconTextfield({ + icon:
, + label: 'Your ID', + onChange: onChangeMock, + }); + + const input = screen.getByLabelText('Your ID'); + + const inputValue = 'my id is 123'; + await act(() => user.type(input, inputValue)); + expect(onChangeMock).toHaveBeenCalledTimes(inputValue.length); + }); + + it('should forward the rest of the props to the input', () => { + renderStudioIconTextfield({ + icon:
, + label: 'Your ID', + disabled: true, + }); + expect(screen.getByLabelText('Your ID')).toBeDisabled(); + }); +}); +const renderStudioIconTextfield = (props: StudioIconTextfieldProps) => { + return render(); +}; diff --git a/frontend/libs/studio-components/src/components/StudioIconTextfield/StudioIconTextfield.tsx b/frontend/libs/studio-components/src/components/StudioIconTextfield/StudioIconTextfield.tsx new file mode 100644 index 00000000000..a72ac8e277e --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioIconTextfield/StudioIconTextfield.tsx @@ -0,0 +1,28 @@ +import React, { forwardRef } from 'react'; +import { StudioTextfield, type StudioTextfieldProps } from '../StudioTextfield'; +import cn from 'classnames'; + +import classes from './StudioIconTextfield.module.css'; + +export type StudioIconTextfieldProps = { + icon: React.ReactNode; +} & StudioTextfieldProps; + +export const StudioIconTextfield = forwardRef( + ( + { icon, className: givenClassName, ...rest }: StudioIconTextfieldProps, + ref, + ): React.ReactElement => { + const className = cn(givenClassName, classes.textfield); + return ( +
+
+ {icon} +
+ +
+ ); + }, +); + +StudioIconTextfield.displayName = 'StudioIconTextfield'; diff --git a/frontend/libs/studio-components/src/components/StudioIconTextfield/index.ts b/frontend/libs/studio-components/src/components/StudioIconTextfield/index.ts new file mode 100644 index 00000000000..a5cb33a4600 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioIconTextfield/index.ts @@ -0,0 +1 @@ +export { StudioIconTextfield, type StudioIconTextfieldProps } from './StudioIconTextfield'; diff --git a/frontend/libs/studio-components/src/components/StudioTextfield/StudioTextfield.tsx b/frontend/libs/studio-components/src/components/StudioTextfield/StudioTextfield.tsx index b4a69735a86..c43a80828b2 100644 --- a/frontend/libs/studio-components/src/components/StudioTextfield/StudioTextfield.tsx +++ b/frontend/libs/studio-components/src/components/StudioTextfield/StudioTextfield.tsx @@ -1,6 +1,6 @@ -import { Textfield } from '@digdir/design-system-react'; import React, { forwardRef } from 'react'; -import type { SharedTextInputProps } from '../../types/SharedTextInputProps'; +import { Textfield } from '@digdir/design-system-react'; +import { type SharedTextInputProps } from '../../types/SharedTextInputProps'; import { useTextInputProps } from '../../hooks/useTextInputProps'; export type StudioTextfieldProps = SharedTextInputProps; diff --git a/frontend/libs/studio-components/src/components/StudioTextfield/index.ts b/frontend/libs/studio-components/src/components/StudioTextfield/index.ts index 4dd72df8b03..5e54a27d12a 100644 --- a/frontend/libs/studio-components/src/components/StudioTextfield/index.ts +++ b/frontend/libs/studio-components/src/components/StudioTextfield/index.ts @@ -1,2 +1 @@ -export { StudioTextfield } from './StudioTextfield'; -export type { StudioTextfieldProps } from './StudioTextfield'; +export { StudioTextfield, type StudioTextfieldProps } from './StudioTextfield'; diff --git a/frontend/libs/studio-components/src/components/StudioToggleableTextfield/StudioTextfieldToggleView/StudioTextfieldToggleView.module.css b/frontend/libs/studio-components/src/components/StudioToggleableTextfield/StudioTextfieldToggleView/StudioTextfieldToggleView.module.css new file mode 100644 index 00000000000..6f09805e723 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioToggleableTextfield/StudioTextfieldToggleView/StudioTextfieldToggleView.module.css @@ -0,0 +1,9 @@ +.viewModeIconsContainer { + display: flex; + align-items: center; + color: var(--fds-semantic-text-neutral-default); +} + +.editIcon { + margin-left: auto; +} diff --git a/frontend/libs/studio-components/src/components/StudioToggleableTextfield/StudioTextfieldToggleView/StudioTextfieldToggleView.test.tsx b/frontend/libs/studio-components/src/components/StudioToggleableTextfield/StudioTextfieldToggleView/StudioTextfieldToggleView.test.tsx new file mode 100644 index 00000000000..ff6bc5335de --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioToggleableTextfield/StudioTextfieldToggleView/StudioTextfieldToggleView.test.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { StudioTextfieldToggleView } from './StudioTextfieldToggleView'; +import type { StudioTextfieldToggleViewProps } from './StudioTextfieldToggleView'; +import userEvent from '@testing-library/user-event'; + +describe('StudioTextfieldToggleView', () => { + it('should render button text', () => { + renderStudioTextfieldToggleView({ children: 'My awesome button' }); + expect(screen.getByRole('button', { name: 'My awesome button' })).toBeInTheDocument(); + }); + + it('should execute the "onClick" method when button is clicked', async () => { + const user = userEvent.setup(); + const onClickMock = jest.fn(); + + renderStudioTextfieldToggleView({ children: 'My awesome button text', onClick: onClickMock }); + + await act(() => user.click(screen.getByRole('button', { name: 'My awesome button text' }))); + expect(onClickMock).toHaveBeenCalledTimes(1); + }); + + it('should render the KeyVerticalIcon', () => { + renderStudioTextfieldToggleView({ children: 'My awesome button text' }); + + // Uses testId to find the KeyVerticalIcon, since it's not available for screen reader. + expect(screen.getByTestId('keyIcon')).toBeInTheDocument(); + }); + + it('should render the PencilIcon', () => { + renderStudioTextfieldToggleView({ children: 'My awesome button text' }); + + // Uses testId to find the EditIcon, since it's not available for screen reader. + expect(screen.getByTestId('editIcon')).toBeInTheDocument(); + }); + + it('should forward the rest of the props to the button', () => { + renderStudioTextfieldToggleView({ children: 'My awesome button text', disabled: true }); + expect(screen.getByRole('button', { name: 'My awesome button text' })).toBeDisabled(); + }); +}); + +const renderStudioTextfieldToggleView = (props: Partial) => { + return render(); +}; diff --git a/frontend/libs/studio-components/src/components/StudioToggleableTextfield/StudioTextfieldToggleView/StudioTextfieldToggleView.tsx b/frontend/libs/studio-components/src/components/StudioToggleableTextfield/StudioTextfieldToggleView/StudioTextfieldToggleView.tsx new file mode 100644 index 00000000000..92988850ddd --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioToggleableTextfield/StudioTextfieldToggleView/StudioTextfieldToggleView.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { PencilIcon, KeyVerticalIcon } from '@studio/icons'; +import { StudioButton, type StudioButtonProps } from '@studio/components'; +import classes from './StudioTextfieldToggleView.module.css'; + +export type StudioTextfieldToggleViewProps = StudioButtonProps; + +export const StudioTextfieldToggleView = ({ + onClick, + children, + ...rest +}: StudioTextfieldToggleViewProps) => { + return ( + + + + {children} + + + + ); +}; diff --git a/frontend/libs/studio-components/src/components/StudioToggleableTextfield/StudioTextfieldToggleView/index.ts b/frontend/libs/studio-components/src/components/StudioToggleableTextfield/StudioTextfieldToggleView/index.ts new file mode 100644 index 00000000000..5be1059ebdc --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioToggleableTextfield/StudioTextfieldToggleView/index.ts @@ -0,0 +1,4 @@ +export { + StudioTextfieldToggleView, + type StudioTextfieldToggleViewProps, +} from './StudioTextfieldToggleView'; diff --git a/frontend/libs/studio-components/src/components/StudioToggleableTextfield/StudioToggleableTextfield.test.tsx b/frontend/libs/studio-components/src/components/StudioToggleableTextfield/StudioToggleableTextfield.test.tsx new file mode 100644 index 00000000000..927d91f6ae9 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioToggleableTextfield/StudioToggleableTextfield.test.tsx @@ -0,0 +1,135 @@ +import React from 'react'; +import { act, render, screen, fireEvent } from '@testing-library/react'; +import { + StudioToggleableTextfield, + type StudioToggleableTextfieldProps, +} from './StudioToggleableTextfield'; + +import userEvent from '@testing-library/user-event'; + +describe('StudioToggleableTextfield', () => { + it('Renders the view mode by default', () => { + renderStudioTextField({ + viewProps: { children: 'Edit binding' }, + }); + expect(screen.getByRole('button', { name: 'Edit binding' })).toBeInTheDocument(); + }); + + it('should toggle to edit-mode when edit button is clicked', async () => { + const user = userEvent.setup(); + renderStudioTextField({ + viewProps: { children: 'Edit name' }, + inputProps: { value: '', icon:
, label: 'Your name' }, + }); + await act(() => user.click(screen.getByRole('button', { name: 'Edit name' }))); + expect(screen.getByLabelText('Your name')).toBeEnabled(); + }); + + it('should run custom validation when value changes', async () => { + const customValidation = jest.fn(); + const user = userEvent.setup(); + renderStudioTextField({ + viewProps: { children: 'Edit name' }, + inputProps: { value: '', label: 'Your name', icon:
}, + customValidation, + }); + await act(() => user.click(screen.getByRole('button', { name: 'Edit name' }))); + + const typedInputValue = 'John'; + await act(() => user.type(screen.getByLabelText('Your name'), typedInputValue)); + + expect(customValidation).toHaveBeenCalledTimes(typedInputValue.length); + }); + + it('should be toggle back to view mode on blur', async () => { + const user = userEvent.setup(); + + renderStudioTextField({ + viewProps: { children: 'edit' }, + inputProps: { value: 'value', label: 'Your name', icon:
}, + }); + + await act(() => user.click(screen.getByRole('button', { name: 'edit' }))); + expect(screen.getByLabelText('Your name')).toBeEnabled(); + expect(screen.queryByRole('button', { name: 'edit' })).not.toBeInTheDocument(); + + fireEvent.blur(screen.getByLabelText('Your name')); + await screen.findByRole('button', { name: 'edit' }); + }); + + it('should execute onBlur method when input is blurred', async () => { + const onBlurMock = jest.fn(); + const user = userEvent.setup(); + renderStudioTextField({ + viewProps: { children: 'Edit name' }, + inputProps: { onBlur: onBlurMock, label: 'Your name', icon:
}, + }); + + await act(() => user.click(screen.getByRole('button', { name: 'Edit name' }))); + fireEvent.blur(screen.getByLabelText('Your name')); + expect(onBlurMock).toHaveBeenCalledTimes(1); + }); + + it('should not toggle view on blur when input field has error', async () => { + const user = userEvent.setup(); + + renderStudioTextField({ + viewProps: { children: 'Edit your name' }, + inputProps: { label: 'Your name', icon:
, error: 'Your name is a required field' }, + }); + + await act(() => user.click(screen.getByRole('button', { name: 'Edit your name' }))); + + const inputField = screen.getByLabelText('Your name'); + fireEvent.blur(inputField); + + expect(inputField).toHaveAttribute('aria-invalid', 'true'); + expect(screen.getByText('Your name is a required field')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Edit your name' })).not.toBeInTheDocument(); + }); + + it('should execute onChange method when input value changes', async () => { + const onChangeMock = jest.fn(); + const user = userEvent.setup(); + + renderStudioTextField({ + viewProps: { children: 'edit' }, + inputProps: { onChange: onChangeMock, label: 'Your name', icon:
}, + }); + + const inputValue = 'John'; + await act(() => user.click(screen.getByRole('button', { name: 'edit' }))); + await act(() => user.type(screen.getByLabelText('Your name'), inputValue)); + + expect(onChangeMock).toHaveBeenCalledTimes(inputValue.length); + }); + + it('should render error message if customValidation occured', async () => { + const user = userEvent.setup(); + + renderStudioTextField({ + viewProps: { children: 'Edit name' }, + inputProps: { label: 'Your name', icon:
}, + customValidation: (value: string) => + value === 'test' ? 'Your name cannot be "test"' : undefined, + }); + + await act(() => user.click(screen.getByRole('button', { name: 'Edit name' }))); + await act(() => user.type(screen.getByLabelText('Your name'), 'test')); + expect(screen.getByText('Your name cannot be "test"')); + }); +}); + +const renderStudioTextField = (props: Partial) => { + const defaultProps: StudioToggleableTextfieldProps = { + inputProps: { + value: 'value', + icon:
, + }, + viewProps: { + children: 'edit', + }, + customValidation: jest.fn(), + }; + return render(); +}; diff --git a/frontend/libs/studio-components/src/components/StudioToggleableTextfield/StudioToggleableTextfield.tsx b/frontend/libs/studio-components/src/components/StudioToggleableTextfield/StudioToggleableTextfield.tsx new file mode 100644 index 00000000000..43e45658851 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioToggleableTextfield/StudioToggleableTextfield.tsx @@ -0,0 +1,68 @@ +import React, { forwardRef, useState } from 'react'; +import { + StudioTextfieldToggleView, + type StudioTextfieldToggleViewProps, +} from './StudioTextfieldToggleView'; + +import { StudioIconTextfield, type StudioIconTextfieldProps } from '../StudioIconTextfield'; + +export type StudioToggleableTextfieldProps = { + customValidation?: (value: string) => string | undefined; + inputProps: StudioIconTextfieldProps; + viewProps: Omit; +}; + +export const StudioToggleableTextfield = forwardRef( + ({ inputProps, viewProps, customValidation }: StudioToggleableTextfieldProps, ref) => { + const [isViewMode, setIsViewMode] = useState(true); + const [errorMessage, setErrorMessage] = useState(null); + + const toggleViewMode = (): void => { + setIsViewMode((prevMode) => !prevMode); + }; + + const runCustomValidation = (event: React.ChangeEvent): boolean => { + const errorValidationMessage = customValidation(event.target.value); + + if (errorValidationMessage) { + setErrorMessage(errorValidationMessage); + return true; + } + setErrorMessage(null); + return false; + }; + + const handleBlur = (event: React.FocusEvent): void => { + // Should not close the view mode or blur if there is an error + if (errorMessage || inputProps.error) { + return; + } + + toggleViewMode(); + inputProps.onBlur?.(event); + }; + + const handleOnChange = (event: React.ChangeEvent) => { + if (customValidation) { + runCustomValidation(event); + } + + inputProps.onChange?.(event); + }; + + if (isViewMode) return ; + + return ( + + ); + }, +); + +StudioToggleableTextfield.displayName = 'StudioToggleableTextfield'; diff --git a/frontend/libs/studio-components/src/components/StudioToggleableTextfield/index.ts b/frontend/libs/studio-components/src/components/StudioToggleableTextfield/index.ts new file mode 100644 index 00000000000..5cd34e368d4 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioToggleableTextfield/index.ts @@ -0,0 +1,8 @@ +export { + StudioToggleableTextfield, + type StudioToggleableTextfieldProps, +} from './StudioToggleableTextfield'; +export { + StudioTextfieldToggleView, + type StudioTextfieldToggleViewProps, +} from './StudioTextfieldToggleView'; diff --git a/frontend/libs/studio-components/src/components/StudioToggleableTextfieldSchema/JsonSchemaValidator.test.ts b/frontend/libs/studio-components/src/components/StudioToggleableTextfieldSchema/JsonSchemaValidator.test.ts new file mode 100644 index 00000000000..725d0efda4b --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioToggleableTextfieldSchema/JsonSchemaValidator.test.ts @@ -0,0 +1,64 @@ +import { JsonSchemaValidator } from './JsonSchemaValidator'; +import { type JsonSchema } from '../../types/JSONSchema'; + +const defaultLayoutSchemaMock: JsonSchema = { + $id: 'id', + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + definitions: { + component: { + type: 'object', + properties: { + id: { + type: 'string', + title: 'id', + pattern: '^[0-9a-zA-Z][0-9a-zA-Z-]*(-?[a-zA-Z]+|[a-zA-Z][0-9]+|-[0-9]{6,})$', + description: + 'The component ID. Must be unique within all layouts/pages in a layout-set. Cannot end with .', + }, + }, + required: ['id'], + }, + }, +}; + +describe('JsonSchemaValidator', () => { + describe('isPropertyRequired', () => { + it('should return true if property is required', () => { + const validator = new JsonSchemaValidator(defaultLayoutSchemaMock, []); + const validationResult = validator.isPropertyRequired('definitions/component/properties/id'); + expect(validationResult).toBe(true); + }); + + it('should return false if property is not required', () => { + const layoutSchemaMock: JsonSchema = { + ...defaultLayoutSchemaMock, + definitions: { + component: { + ...defaultLayoutSchemaMock.definitions.component, + required: [], + }, + }, + }; + const validator = new JsonSchemaValidator(layoutSchemaMock, []); + const validationResult = validator.isPropertyRequired('definitions/component/properties/id'); + expect(validationResult).toBe(false); + }); + }); + + describe('validateProperty', () => { + it('should return error message if value is invalid', () => { + const validator = new JsonSchemaValidator(defaultLayoutSchemaMock, []); + const invalidValueType = 'invalid-value'; + const validationResult = validator.validateProperty('id', invalidValueType); + expect(validationResult).toBe('type'); + }); + + it('should return null if value is valid', () => { + const validator = new JsonSchemaValidator(defaultLayoutSchemaMock, []); + const validValueType = { id: 'valid-value' }; + const validationResult = validator.validateProperty('id', validValueType); + expect(validationResult).toBe(null); + }); + }); +}); diff --git a/frontend/libs/studio-components/src/components/StudioToggleableTextfieldSchema/JsonSchemaValidator.ts b/frontend/libs/studio-components/src/components/StudioToggleableTextfieldSchema/JsonSchemaValidator.ts new file mode 100644 index 00000000000..89bd5208f48 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioToggleableTextfieldSchema/JsonSchemaValidator.ts @@ -0,0 +1,60 @@ +import Ajv, { type ErrorObject } from 'ajv'; +import { type JsonSchema } from '../../types/JSONSchema'; + +export class JsonSchemaValidator { + private readonly layoutSchema: JsonSchema = null; + + private JSONValidator: Ajv = new Ajv({ + allErrors: true, + strict: false, + }); + + constructor(layoutSchema: JsonSchema, schemas: JsonSchema[]) { + if (!layoutSchema) return; + + this.layoutSchema = layoutSchema; + + [...schemas, layoutSchema].forEach((schema: JsonSchema): void => { + this.addSchemaToValidator(schema); + }); + } + + public isPropertyRequired(propertyPath: string): boolean { + if (!this.layoutSchema || !propertyPath) return false; + const parent = this.getPropertyByPath( + propertyPath.substring(0, propertyPath.lastIndexOf('/properties')), + ); + + return parent?.required?.includes(propertyPath.split('/').pop()); + } + + public validateProperty(propertyId: string, value: unknown): string | null { + const JSONSchemaValidationErrors = this.validate(propertyId, value); + const firstError = JSONSchemaValidationErrors?.[0]; + const isCurrentComponentError = firstError?.instancePath === ''; + return isCurrentComponentError ? firstError?.keyword : null; + } + + private addSchemaToValidator(schema: JsonSchema): void { + const validate = this.JSONValidator.getSchema(schema?.$id); + if (!validate) { + this.JSONValidator.addSchema(schema); + } + } + + private getPropertyByPath(path: string) { + return { ...path.split('/').reduce((o, p) => (o || {})[p], this.layoutSchema) }; + } + + private validate(schemaId: string, data: unknown): ErrorObject[] | null { + const validateJsonSchema = this.JSONValidator.getSchema(schemaId); + if (validateJsonSchema) { + validateJsonSchema(data); + + if ('errors' in validateJsonSchema) { + return validateJsonSchema.errors; + } + } + return null; + } +} diff --git a/frontend/libs/studio-components/src/components/StudioToggleableTextfieldSchema/StudioToggleableTextfieldSchema.test.tsx b/frontend/libs/studio-components/src/components/StudioToggleableTextfieldSchema/StudioToggleableTextfieldSchema.test.tsx new file mode 100644 index 00000000000..59d2f3e1734 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioToggleableTextfieldSchema/StudioToggleableTextfieldSchema.test.tsx @@ -0,0 +1,196 @@ +import type { JsonSchema } from '../../types/JSONSchema'; +import React from 'react'; +import { + StudioToggleableTextfieldSchema, + type StudioToggleableTextfieldSchemaProps, +} from './StudioToggleableTextfieldSchema'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const defaultLayoutSchemaMock: JsonSchema = { + $id: 'id', + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + definitions: { + component: { + type: 'object', + properties: { + id: { + type: 'string', + title: 'id', + pattern: '^[0-9a-zA-Z][0-9a-zA-Z-]*(-?[a-zA-Z]+|[a-zA-Z][0-9]+|-[0-9]{6,})$', + description: + 'The component ID. Must be unique within all layouts/pages in a layout-set. Cannot end with .', + }, + }, + required: ['id'], + }, + }, +}; + +const defaultProps: StudioToggleableTextfieldSchemaProps = { + layoutSchema: defaultLayoutSchemaMock, + relatedSchemas: [], + viewProps: { + value: '', + onChange: () => {}, + }, + inputProps: { + value: '', + onChange: () => {}, + icon:
, + }, + propertyPath: 'definitions/component/properties/id', + onError: jest.fn(), +}; + +describe('StudioToggleableTextfieldSchema', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should render as view mode as default and support rest props', () => { + renderStudioTextfieldSchema({ + viewProps: { + children: 'Edit id', + className: 'test-class', + }, + }); + const editButton = screen.getByRole('button', { name: 'Edit id' }); + expect(editButton).toBeInTheDocument(); + expect(editButton).toHaveClass('test-class'); + }); + + it('should toggle to edit mode when clicking edit', async () => { + const user = userEvent.setup(); + + renderStudioTextfieldSchema({ + viewProps: { + children: 'Edit id', + }, + inputProps: { + ...defaultProps.inputProps, + label: 'Your id', + }, + }); + + await act(() => user.click(screen.getByRole('button', { name: 'Edit id' }))); + expect(screen.getByLabelText('Your id')).toBeInTheDocument(); + }); + + it('should toggle to view mode on blur', async () => { + const user = userEvent.setup(); + + renderStudioTextfieldSchema({ + viewProps: { + children: 'Edit id', + }, + inputProps: { + ...defaultProps.inputProps, + label: 'Your id', + }, + }); + + await act(() => user.click(screen.getByRole('button', { name: 'Edit id' }))); + expect(screen.queryByRole('button', { name: 'Edit id' })).not.toBeInTheDocument(); + + fireEvent.blur(screen.getByLabelText('Your id')); + expect(screen.getByRole('button', { name: 'Edit id' })).toBeInTheDocument(); + }); + + it('should not toggle to view mode on blur if input is invalid', async () => { + const user = userEvent.setup(); + + renderStudioTextfieldSchema({ + viewProps: { + children: 'Edit id', + }, + inputProps: { + ...defaultProps.inputProps, + label: 'Your id', + error: 'my awesome error message', + }, + }); + + await act(() => user.click(screen.getByRole('button', { name: 'Edit id' }))); + expect(screen.queryByRole('button', { name: 'Edit id' })).not.toBeInTheDocument(); + + fireEvent.blur(screen.getByLabelText('Your id')); + expect(screen.queryByRole('button', { name: 'Edit id' })).not.toBeInTheDocument(); + }); + + it('should validate field against json schema and invoke "onError" if validation has errors', async () => { + const user = userEvent.setup(); + + renderStudioTextfieldSchema({ + viewProps: { + children: 'Edit id', + }, + inputProps: { + ...defaultProps.inputProps, + label: 'Your id', + }, + }); + await act(() => user.click(screen.getByRole('button', { name: 'Edit id' }))); + + await act(() => user.type(screen.getByLabelText('Your id'), 'invalid-value-01')); + expect(defaultProps.onError).toHaveBeenCalledWith({ + errorCode: 'pattern', + details: 'Result of validate property', + }); + }); + + it('should validate field against json schema and invoke "onError" if field is required', async () => { + const user = userEvent.setup(); + + renderStudioTextfieldSchema({ + viewProps: { + children: 'Edit id', + }, + inputProps: { + ...defaultProps.inputProps, + label: 'Your id', + }, + }); + await act(() => user.click(screen.getByRole('button', { name: 'Edit id' }))); + + await act(() => user.type(screen.getByLabelText('Your id'), 'first-id')); + await act(() => user.clear(screen.getByLabelText('Your id'))); + + expect(defaultProps.onError).toHaveBeenCalledWith({ + errorCode: 'required', + details: 'Property value is required', + }); + }); + + it('should invoke onChange and onError when input changes with error', async () => { + const user = userEvent.setup(); + const onErrorMock = jest.fn(); + const onChangeMock = jest.fn(); + + renderStudioTextfieldSchema({ + onError: onErrorMock, + viewProps: { + children: 'Edit id', + }, + inputProps: { + ...defaultProps.inputProps, + label: 'Your id', + onChange: onChangeMock, + }, + }); + + await act(() => user.click(screen.getByRole('button', { name: 'Edit id' }))); + + const invalidValue = '1'; + await act(() => user.type(screen.getByLabelText('Your id'), invalidValue)); + expect(onErrorMock).toHaveBeenCalledWith({ + details: 'Result of validate property', + errorCode: 'pattern', + }); + expect(onChangeMock).toHaveBeenCalledTimes(1); + }); +}); + +const renderStudioTextfieldSchema = (props: Partial = {}) => { + return render(); +}; diff --git a/frontend/libs/studio-components/src/components/StudioToggleableTextfieldSchema/StudioToggleableTextfieldSchema.tsx b/frontend/libs/studio-components/src/components/StudioToggleableTextfieldSchema/StudioToggleableTextfieldSchema.tsx new file mode 100644 index 00000000000..78bd75dad91 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioToggleableTextfieldSchema/StudioToggleableTextfieldSchema.tsx @@ -0,0 +1,82 @@ +import React, { forwardRef } from 'react'; +import { JsonSchemaValidator } from './JsonSchemaValidator'; +import { type JsonSchema } from '../../types/JSONSchema'; +import { + StudioToggleableTextfield, + type StudioToggleableTextfieldProps, +} from '../StudioToggleableTextfield'; + +export type SchemaValidationError = { + errorCode: string; + details: string; +}; + +export type StudioToggleableTextfieldSchemaProps = { + layoutSchema: JsonSchema; + relatedSchemas: JsonSchema[]; + propertyPath: string; + onError?: (error: SchemaValidationError | null) => void; +} & StudioToggleableTextfieldProps; + +export const StudioToggleableTextfieldSchema = forwardRef< + HTMLDivElement, + StudioToggleableTextfieldSchemaProps +>( + ( + { + layoutSchema, + relatedSchemas, + inputProps, + propertyPath, + onError, + ...rest + }: StudioToggleableTextfieldSchemaProps, + ref, + ): React.ReactElement => { + const jsonSchemaValidator = new JsonSchemaValidator(layoutSchema, relatedSchemas); + const propertyId = layoutSchema && propertyPath ? `${layoutSchema.$id}#/${propertyPath}` : null; + + const validateAgainstSchema = ( + event: React.ChangeEvent, + ): SchemaValidationError | null => { + const newValue = event.target.value; + + if (jsonSchemaValidator.isPropertyRequired(propertyPath) && newValue?.length === 0) { + return createSchemaError('required', 'Property value is required'); + } + + if (propertyId) { + const error = jsonSchemaValidator.validateProperty(propertyId, newValue); + return error ? createSchemaError(error, 'Result of validate property') : null; + } + + return null; + }; + + const handleOnChange = (event: React.ChangeEvent) => { + const validationError = validateAgainstSchema(event); + + onError?.(validationError || null); + inputProps.onChange?.(event); + }; + + return ( + ) => handleOnChange(event), + error: inputProps.error, + }} + /> + ); + }, +); + +StudioToggleableTextfieldSchema.displayName = 'StudioToggleableTextfieldSchema'; + +const createSchemaError = (errorCode: string, details: string): SchemaValidationError => ({ + errorCode, + details, +}); diff --git a/frontend/libs/studio-components/src/components/StudioToggleableTextfieldSchema/index.ts b/frontend/libs/studio-components/src/components/StudioToggleableTextfieldSchema/index.ts new file mode 100644 index 00000000000..b0b0e6b7d08 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioToggleableTextfieldSchema/index.ts @@ -0,0 +1,5 @@ +export { + SchemaValidationError, + StudioToggleableTextfieldSchema, + type StudioToggleableTextfieldSchemaProps, +} from './StudioToggleableTextfieldSchema'; diff --git a/frontend/libs/studio-components/src/components/index.ts b/frontend/libs/studio-components/src/components/index.ts index 15daa7f27fb..61aff7349a8 100644 --- a/frontend/libs/studio-components/src/components/index.ts +++ b/frontend/libs/studio-components/src/components/index.ts @@ -13,3 +13,6 @@ export * from './StudioSpinner'; export * from './StudioTextarea'; export * from './StudioTextfield'; export * from './StudioTreeView'; +export * from './StudioToggleableTextfield'; +export * from './StudioIconTextfield'; +export * from './StudioToggleableTextfieldSchema'; diff --git a/frontend/libs/studio-components/src/types/JSONSchema.ts b/frontend/libs/studio-components/src/types/JSONSchema.ts new file mode 100644 index 00000000000..b6a56fa76b8 --- /dev/null +++ b/frontend/libs/studio-components/src/types/JSONSchema.ts @@ -0,0 +1,10 @@ +import { type KeyValuePairs } from './KeyValuePairs'; + +export interface JsonSchema { + properties?: KeyValuePairs; + $defs?: KeyValuePairs; + $schema?: string; + $id?: string; + + [key: string]: any; +} diff --git a/frontend/libs/studio-components/src/types/KeyValuePairs.ts b/frontend/libs/studio-components/src/types/KeyValuePairs.ts new file mode 100644 index 00000000000..8d2431ab107 --- /dev/null +++ b/frontend/libs/studio-components/src/types/KeyValuePairs.ts @@ -0,0 +1,3 @@ +export interface KeyValuePairs { + [key: string]: T; +} diff --git a/frontend/packages/shared/src/utils/formValidationUtils/formValidationUtils.ts b/frontend/packages/shared/src/utils/formValidationUtils/formValidationUtils.ts index 8a01571f92d..873901b1586 100644 --- a/frontend/packages/shared/src/utils/formValidationUtils/formValidationUtils.ts +++ b/frontend/packages/shared/src/utils/formValidationUtils/formValidationUtils.ts @@ -20,10 +20,6 @@ export const addSchemas = (schemas: any[]) => { }); }; -export const getSchema = ($id: string) => { - return ajv.getSchema($id); -}; - export const getPropertyByPath = (schema: any, path: string) => { return { ...path.split('/').reduce((o, p) => (o || {})[p], schema) }; }; diff --git a/frontend/packages/ux-editor/src/components/Properties/Properties.test.tsx b/frontend/packages/ux-editor/src/components/Properties/Properties.test.tsx index ea54c71f13c..642fdc273a0 100644 --- a/frontend/packages/ux-editor/src/components/Properties/Properties.test.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/Properties.test.tsx @@ -51,6 +51,10 @@ jest.mock('./Calculations', () => ({ jest.mock('react-i18next', () => ({ useTranslation: () => mockUseTranslation(texts) })); describe('Properties', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('Text', () => { it('Toggles text when clicked', async () => { const user = userEvent.setup(); @@ -98,14 +102,35 @@ describe('Properties', () => { }); expect(heading).toBeInTheDocument(); + const editComponentIdButton = screen.getByRole('button', { name: /ID/i }); + expect(editComponentIdButton).toBeInTheDocument(); + await act(() => user.click(editComponentIdButton)); const textbox = screen.getByRole('textbox', { name: 'ux_editor.modal_properties_component_change_id', }); - await act(() => user.type(textbox, '2')); + const validId = 'valid-id'; + await act(() => user.type(textbox, validId)); + await act(() => user.click(document.body)); expect(formItemContextProviderMock.handleUpdate).toHaveBeenCalledTimes(1); expect(formItemContextProviderMock.debounceSave).toHaveBeenCalledTimes(1); }); + + it('should not invoke handleUpdate when the id is invalid', async () => { + const user = userEvent.setup(); + renderProperties({ formItem: component1Mock, formItemId: component1IdMock }); + await act(() => user.click(screen.getByRole('button', { name: `ID: ${component1Mock.id}` }))); + + const invalidId = 'invalidId-01'; + await act(() => + user.type( + screen.getByLabelText('ux_editor.modal_properties_component_change_id'), + invalidId, + ), + ); + + expect(formItemContextProviderMock.handleUpdate).not.toHaveBeenCalled(); + }); }); describe('Content', () => { diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditComponentIdRow/EditComponentIdRow.module.css b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditComponentIdRow/EditComponentIdRow.module.css new file mode 100644 index 00000000000..645ba56fd2d --- /dev/null +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditComponentIdRow/EditComponentIdRow.module.css @@ -0,0 +1,4 @@ +.container { + width: 100%; + padding-bottom: var(--fds-spacing-2); +} diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditComponentIdRow/EditComponentIdRow.test.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditComponentIdRow/EditComponentIdRow.test.tsx new file mode 100644 index 00000000000..f99510e1844 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditComponentIdRow/EditComponentIdRow.test.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { act, screen } from '@testing-library/react'; +import { renderWithMockStore } from '../../../../testing/mocks'; +import { EditComponentIdRow, type EditComponentIdRowProps } from './EditComponentIdRow'; +import userEvent from '@testing-library/user-event'; +import { ComponentType } from 'app-shared/types/ComponentType'; +import { textMock } from '../../../../../../../testing/mocks/i18nMock'; + +const studioRender = async (props: Partial) => { + return renderWithMockStore({})( + , + ); +}; + +describe('EditComponentIdRow', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render button ', async () => { + await studioRender({}); + const testIdButton = screen.getByRole('button', { name: 'ID: test' }); + expect(testIdButton).toBeInTheDocument(); + }); + + it('should render textField when the button is clicked', async () => { + const user = userEvent.setup(); + await studioRender({}); + const testIdButton = screen.getByRole('button', { name: 'ID: test' }); + await act(() => user.click(testIdButton)); + const textField = screen.getByRole('textbox', { + name: textMock('ux_editor.modal_properties_component_change_id'), + }); + expect(textField).toBeInTheDocument(); + }); + + it('should not render the textfield when changing from edit mode to view mode ', async () => { + const user = userEvent.setup(); + await studioRender({}); + const testIdButton = screen.getByRole('button', { name: 'ID: test' }); + await act(() => user.click(testIdButton)); + const textField = screen.getByRole('textbox', { + name: textMock('ux_editor.modal_properties_component_change_id'), + }); + await act(() => user.click(document.body)); + expect(textField).not.toBeInTheDocument(); + }); + + it('should call onChange when user change the input in text filed.', async () => { + const user = userEvent.setup(); + const handleComponentUpdate = jest.fn(); + await studioRender({ handleComponentUpdate }); + const testIdButton = screen.getByRole('button', { name: 'ID: test' }); + await act(() => user.click(testIdButton)); + const textField = screen.getByRole('textbox', { + name: textMock('ux_editor.modal_properties_component_change_id'), + }); + await act(() => user.type(textField, 'newTestId')); + await act(() => user.click(document.body)); + expect(handleComponentUpdate).toHaveBeenCalled(); + }); + + it('should show error required error message when id is empty', async () => { + const user = userEvent.setup(); + await studioRender({}); + const testIdButton = screen.getByRole('button', { name: 'ID: test' }); + await act(() => user.click(testIdButton)); + const textField = screen.getByRole('textbox', { + name: textMock('ux_editor.modal_properties_component_change_id'), + }); + await act(() => user.clear(textField)); + expect(screen.getByText(textMock('validation_errors.required'))).toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditComponentIdRow/EditComponentIdRow.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditComponentIdRow/EditComponentIdRow.tsx index 8e047211b3e..42abbbad83a 100644 --- a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditComponentIdRow/EditComponentIdRow.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditComponentIdRow/EditComponentIdRow.tsx @@ -1,60 +1,85 @@ -import React from 'react'; +import React, { useState } from 'react'; +import { StudioToggleableTextfieldSchema, type SchemaValidationError } from '@studio/components'; +import { KeyVerticalIcon } from '@navikt/aksel-icons'; +import classes from './EditComponentIdRow.module.css'; import { idExists } from '../../../../utils/formLayoutUtils'; -import { useTranslation } from 'react-i18next'; import { useSelectedFormLayout } from '../../../../hooks'; -import { FormField } from '../../../FormField'; -import { Textfield } from '@digdir/design-system-react'; +import { useTranslation } from 'react-i18next'; import type { FormItem } from '../../../../types/FormItem'; +import { useLayoutSchemaQuery } from '../../../../hooks/queries/useLayoutSchemaQuery'; export interface EditComponentIdRowProps { handleComponentUpdate: (component: FormItem) => void; component: FormItem; helpText?: string; } + export const EditComponentIdRow = ({ component, handleComponentUpdate, - helpText, }: EditComponentIdRowProps) => { const { components, containers } = useSelectedFormLayout(); + const { t } = useTranslation(); + const [{ data: layoutSchema }, , { data: expressionSchema }, { data: numberFormatSchema }] = + useLayoutSchemaQuery(); + + const [errorMessage, setErrorMessage] = useState(null); - const handleIdChange = (id: string) => { + const idInputValue = component.id; + + const saveComponentUpdate = (id: string) => { handleComponentUpdate({ ...component, id, }); }; + const validateId = (value: string) => { + if (value?.length === 0) { + return t('validation_errors.required'); + } + if (value !== component.id && idExists(value, components, containers)) { + return t('ux_editor.modal_properties_component_id_not_unique_error'); + } + return ''; + }; + + const handleValidationError = (error: SchemaValidationError | null): void => { + const errorCodeMap = { + required: t('validation_errors.required'), + unique: t('ux_editor.modal_properties_component_id_not_unique_error'), + pattern: t('ux_editor.modal_properties_component_id_not_valid'), + }; + setErrorMessage(errorCodeMap[error?.errorCode]); + }; + return ( - { - if (value !== component.id && idExists(value, components, containers)) { - return 'unique'; - } - }} - customValidationMessages={(errorCode: string) => { - if (errorCode === 'unique') { - return t('ux_editor.modal_properties_component_id_not_unique_error'); - } - if (errorCode === 'pattern') { - return t('ux_editor.modal_properties_component_id_not_valid'); - } - }} - renderField={({ fieldProps }) => ( - fieldProps.onChange(e.target.value, e)} - /> - )} - /> +
+ , + value: idInputValue, + onBlur: (event) => saveComponentUpdate(event.target.value), + label: t('ux_editor.modal_properties_component_change_id'), + size: 'small', + error: errorMessage, + }} + customValidation={(value) => { + return validateId(value); + }} + /> +
); }; diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/PropertiesHeader.test.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/PropertiesHeader.test.tsx index 616f4408b9c..4ba1a1c70aa 100644 --- a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/PropertiesHeader.test.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/PropertiesHeader.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { act, screen } from '@testing-library/react'; +import { act, fireEvent, screen } from '@testing-library/react'; import { PropertiesHeader, type PropertiesHeaderProps } from './PropertiesHeader'; import { FormItemContext } from '../../../containers/FormItemContext'; import userEvent from '@testing-library/user-event'; @@ -23,7 +23,7 @@ describe('PropertiesHeader', () => { afterEach(jest.clearAllMocks); it('renders the header name for the component', () => { - render(); + renderPropertiesHeader(); const heading = screen.getByRole('heading', { name: textMock(`ux_editor.component_title.${component1Mock.type}`), @@ -33,7 +33,7 @@ describe('PropertiesHeader', () => { }); it('displays the help text when the help text button is clicked', async () => { - render(); + renderPropertiesHeader(); const helpTextButton = screen.getByRole('button', { name: textMock('ux_editor.component_help_text_general_title'), @@ -50,33 +50,41 @@ describe('PropertiesHeader', () => { ).toBeInTheDocument(); }); - it('calls "handleComponentUpdate" when the id changes', async () => { - render(); + it('should invoke "handleComponentUpdate" when id field blurs', async () => { + renderPropertiesHeader(); - const textBox = screen.getByRole('textbox', { - name: textMock('ux_editor.modal_properties_component_change_id'), - }); + const editComponentIdButton = screen.getByRole('button', { name: 'ID: Component-1' }); + await act(() => user.click(editComponentIdButton)); + + const inputField = screen.getByLabelText( + textMock('ux_editor.modal_properties_component_change_id'), + ); + await act(() => user.type(inputField, 'someNewId')); + fireEvent.blur(inputField); - await act(() => user.type(textBox, 'someId')); - expect(mockHandleComponentUpdate).toHaveBeenCalledTimes(6); + expect(mockHandleComponentUpdate).toHaveBeenCalledTimes(1); }); - it('should display an error when containerId is invalid', async () => { - await render(); + it('should not invoke "handleComponentUpdateMock" when input field has error', async () => { + renderPropertiesHeader(); - const containerIdInput = screen.getByRole('textbox', { - name: textMock('ux_editor.modal_properties_component_change_id'), - }); + const editComponentIdButton = screen.getByRole('button', { name: 'ID: Component-1' }); + await act(() => user.click(editComponentIdButton)); - await act(() => user.type(containerIdInput, 'test@')); - expect( - screen.getByText(textMock('ux_editor.modal_properties_component_id_not_valid')), - ).toBeInTheDocument(); - expect(mockHandleComponentUpdate).toHaveBeenCalledTimes(4); + const containerIdInput = screen.getByLabelText( + textMock('ux_editor.modal_properties_component_change_id'), + ); + + const invalidId = 'test@'; + await act(() => user.type(containerIdInput, invalidId)); + fireEvent.blur(containerIdInput); + + expect(screen.getByText(textMock('ux_editor.modal_properties_component_id_not_valid'))); + expect(containerIdInput).toHaveAttribute('aria-invalid', 'true'); + expect(mockHandleComponentUpdate).toHaveBeenCalledTimes(0); }); }); - -const render = (props: Partial = {}) => { +const renderPropertiesHeader = (props: Partial = {}) => { const componentType = props.form ? props.form.type : defaultProps.form.type; queryClientMock.setQueryData( [QueryKey.FormComponent, componentType], diff --git a/yarn.lock b/yarn.lock index 284f0b274f4..4c8b32d19ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3753,6 +3753,7 @@ __metadata: "@testing-library/jest-dom": "npm:^6.1.3" "@testing-library/react": "npm:^14.0.0" "@types/jest": "npm:^29.5.5" + ajv: "npm:8.12.0" eslint: "npm:8.57.0" jest: "npm:^29.7.0" jest-environment-jsdom: "npm:^29.7.0"