diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index b1bc159580e..65292847739 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -6,8 +6,11 @@ on: - main paths: - 'frontend/**' - - '.github/workflow/playwright.yml' - workflow_dispatch: + - '.github/workflows/playwright.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: playwright-run: @@ -37,7 +40,7 @@ jobs: restore-keys: | ${{ runner.os }}-yarn- - - name: Attempt to wait for deploy to environment (10 minutes sleep) + - name: Attempt to wait for deploy to environment (12 minutes sleep) run: sleep 10m shell: bash diff --git a/backend/src/Designer/Controllers/PreviewController.cs b/backend/src/Designer/Controllers/PreviewController.cs index 813b6397dca..d7bbc2790bd 100644 --- a/backend/src/Designer/Controllers/PreviewController.cs +++ b/backend/src/Designer/Controllers/PreviewController.cs @@ -72,6 +72,24 @@ public IActionResult Index(string org, string app) return View(); } + /// + /// Endpoint to fetch the cshtml to render app-frontend specific to what is defined in the app-repo + /// + /// Unique identifier of the organisation responsible for the app. + /// Application identifier which is unique within an organisation. + /// The cshtml modified to ignore this route path added in the iframe. + [HttpGet] + [Route("/app-specific-preview/{org}/{app:regex(^(?!datamodels$)[[a-z]][[a-z0-9-]]{{1,28}}[[a-z0-9]]$)}")] + public async Task AppFrontendSpecificPreview(string org, string app) + { + string developer = AuthenticationHelper.GetDeveloperUserName(_httpContextAccessor.HttpContext); + AltinnAppGitRepository altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(org, app, developer); + var appFrontendCshtml = await altinnAppGitRepository.GetAppFrontendCshtml(); + var modifiedContent = ReplaceIndexToFetchCorrectOrgAppInCshtml(appFrontendCshtml); + + return Content(modifiedContent, "text/html"); + } + /// /// Action for getting local app-images /// @@ -877,5 +895,15 @@ private string GetSelectedLayoutSetInEditorFromRefererHeader(string refererHeade return string.IsNullOrEmpty(layoutSetName) ? null : layoutSetName; } + + private string ReplaceIndexToFetchCorrectOrgAppInCshtml(string originalContent) + { + // Replace the array indexes in the script in the cshtml that retrieves the org and app name since + // /app-specific-preview/ is added when fetching the cshtml file from endpoint instead of designer wwwroot + string modifiedContent = originalContent.Replace("window.org = appId[1];", "window.org = appId[2];"); + modifiedContent = modifiedContent.Replace("window.app = appId[2];", "window.app = appId[3];"); + + return modifiedContent; + } } } diff --git a/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs b/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs index 8e263626d93..d946bd55b02 100644 --- a/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs +++ b/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs @@ -38,6 +38,7 @@ public class AltinnAppGitRepository : AltinnGitRepository private const string LANGUAGE_RESOURCE_FOLDER_NAME = "texts/"; private const string MARKDOWN_TEXTS_FOLDER_NAME = "md/"; private const string PROCESS_DEFINITION_FOLDER_PATH = "App/config/process/"; + private const string CSHTML_PATH = "App/views/Home/index.cshtml"; private const string SERVICE_CONFIG_FILENAME = "config.json"; private const string LAYOUT_SETTINGS_FILENAME = "Settings.json"; @@ -701,6 +702,23 @@ public async Task CreateLayoutSetFile(string layoutSetName) return layoutSets; } + /// + /// Gets the cshtml file for the app + /// + /// A that observes if operation is cancelled. + /// The content of Index.cshtml + public async Task GetAppFrontendCshtml(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (FileExistsByRelativePath(CSHTML_PATH)) + { + string cshtml = await ReadTextByRelativePathAsync(CSHTML_PATH, cancellationToken); + return cshtml; + } + + throw new FileNotFoundException("Index.cshtml was not found."); + } + /// /// Gets the options list with the provided id. /// The id of the options list to fetch. @@ -854,8 +872,6 @@ private static string GetPathToRuleConfiguration(string layoutSetName) Path.Combine(LAYOUTS_FOLDER_NAME, layoutSetName, RULE_CONFIGURATION_FILENAME); } - - /// /// String writer that ensures UTF8 is used. /// diff --git a/backend/src/Designer/wwwroot/designer/html/preview.html b/backend/src/Designer/wwwroot/designer/html/preview.html deleted file mode 100644 index 50304de6b35..00000000000 --- a/backend/src/Designer/wwwroot/designer/html/preview.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - App in preview - - - - - - - - - -
-
-
- - - diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index f5a71e23eef..e6fa738ec6e 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -5,7 +5,7 @@ "plugin:react/recommended", "plugin:react-hooks/recommended" ], - "plugins": ["jsx-a11y", "react", "react-hooks", "@typescript-eslint"], + "plugins": ["jsx-a11y", "react", "react-hooks", "@typescript-eslint", "import"], "parser": "@typescript-eslint/parser", "rules": { "react/destructuring-assignment": "off", @@ -13,20 +13,40 @@ "react/require-default-props": "off", "import/prefer-default-export": "off", "react/jsx-key": "error", - "react/jsx-filename-extension": ["warn", { "extensions": [".tsx"] }], + "react/jsx-filename-extension": [ + "warn", + { + "extensions": [".tsx"] + } + ], "prefer-const": "error", "object-curly-spacing": ["error", "always"], "@typescript-eslint/consistent-type-imports": "error", "@typescript-eslint/no-use-before-define": "off", - "@typescript-eslint/no-explicit-any": "off" + "@typescript-eslint/no-explicit-any": "off", + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": ["**/libs/**"], + "message": "Use the package name, not the relative path, when importing from a @studio library." + } + ] + } + ] }, - "overrides": [ { "files": ["*.tsx"], "rules": { "react/react-in-jsx-scope": "off", - "react/jsx-no-useless-fragment": ["error", { "allowExpressions": true }], + "react/jsx-no-useless-fragment": [ + "error", + { + "allowExpressions": true + } + ], "no-use-before-define": "off", "no-shadow": "off", "react/function-component-definition": "off", diff --git a/frontend/app-development/router/routes.test.tsx b/frontend/app-development/router/routes.test.tsx new file mode 100644 index 00000000000..be0902f351a --- /dev/null +++ b/frontend/app-development/router/routes.test.tsx @@ -0,0 +1,61 @@ +import { render, screen } from '@testing-library/react'; +import { routerRoutes } from './routes'; +import { RoutePaths } from '../enums/RoutePaths'; +import React from 'react'; +import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; +import { QueryKey } from 'app-shared/types/QueryKey'; +import type { AppVersion } from 'app-shared/types/AppVersion'; +import { ServicesContextProvider } from 'app-shared/contexts/ServicesContext'; +import { queriesMock } from 'app-shared/mocks/queriesMock'; + +// Mocks: +jest.mock('../../packages/ux-editor-v3/src/SubApp', () => ({ + SubApp: () =>
, +})); +jest.mock('../../packages/ux-editor/src/SubApp', () => ({ + SubApp: () =>
, +})); + +// Test data +const org = 'org'; +const app = 'app'; + +describe('routes', () => { + describe(RoutePaths.UIEditor, () => { + type FrontendVersion = null | '3.0.0' | '4.0.0'; + type PackageVersion = 'version 3' | 'latest version'; + type TestCase = [PackageVersion, FrontendVersion]; + + const testCases: TestCase[] = [ + ['version 3', null], + ['version 3', '3.0.0'], + ['latest version', '4.0.0'], + ]; + + it.each(testCases)( + 'Renders the %s schema editor page when the app frontend version is %s', + (expectedPackage, frontendVersion) => { + renderUiEditor(frontendVersion); + expect(screen.getByTestId(expectedPackage)).toBeInTheDocument(); + }, + ); + + const renderUiEditor = (frontendVersion: string | null) => + renderSubapp(RoutePaths.UIEditor, frontendVersion); + }); +}); + +const renderSubapp = (path: RoutePaths, frontendVersion: string = null) => { + const Subapp = routerRoutes.find((route) => route.path === path)!.subapp; + const appVersion: AppVersion = { + frontendVersion, + backendVersion: '7.0.0', + }; + const queryClient = createQueryClientMock(); + queryClient.setQueryData([QueryKey.AppVersion, org, app], appVersion); + return render( + + + , + ); +}; diff --git a/frontend/app-development/router/routes.tsx b/frontend/app-development/router/routes.tsx index e81b02c29e7..a53f0659f67 100644 --- a/frontend/app-development/router/routes.tsx +++ b/frontend/app-development/router/routes.tsx @@ -1,10 +1,15 @@ -import { SubApp } from '../../packages/ux-editor/src/SubApp'; +import { SubApp as UiEditorLatest } from '../../packages/ux-editor/src/SubApp'; +import { SubApp as UiEditorV3 } from '../../packages/ux-editor-v3/src/SubApp'; import { Overview } from '../features/overview/components/Overview'; import { TextEditor } from '../features/textEditor/TextEditor'; import DataModellingContainer from '../features/dataModelling/containers/DataModellingContainer'; import { DeployPage } from '../features/appPublish/pages/deployPage'; import { ProcessEditor } from 'app-development/features/processEditor'; import { RoutePaths } from 'app-development/enums/RoutePaths'; +import type { AppVersion } from 'app-shared/types/AppVersion'; +import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; +import { useAppVersionQuery } from 'app-shared/hooks/queries'; +import React from 'react'; interface IRouteProps { headerTextKey?: string; @@ -25,10 +30,20 @@ interface RouterRoute { props?: IRouteProps; } +const latestFrontendVersion = '4'; +const isLatestFrontendVersion = (version: AppVersion): boolean => + version?.frontendVersion?.startsWith(latestFrontendVersion); + +const UiEditor = () => { + const { org, app } = useStudioUrlParams(); + const { data } = useAppVersionQuery(org, app); + return isLatestFrontendVersion(data) ? : ; +}; + export const routerRoutes: RouterRoute[] = [ { path: RoutePaths.UIEditor, - subapp: SubApp, + subapp: UiEditor, }, { path: RoutePaths.Overview, diff --git a/frontend/jest.config.js b/frontend/jest.config.js index 5ab4e7920e3..916d95ac603 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -32,7 +32,9 @@ const config = { }, transformIgnorePatterns: [ `node_modules(\\\\|/)(?!${packagesToTransform})`, - 'frontend/packages/ux-editor/src/testing/schemas/', + '\\.schema\\.v1\\.json$', + 'nb.json$', + 'en.json$', ], reporters: ['default', 'jest-junit'], moduleNameMapper: { @@ -44,6 +46,7 @@ const config = { '^@altinn/schema-editor/(.*)': path.join(__dirname, 'packages/schema-editor/src/$1'), '^@altinn/schema-model/(.*)': path.join(__dirname, 'packages/schema-model/src/$1'), '^@altinn/ux-editor/(.*)': path.join(__dirname, 'packages/ux-editor/src/$1'), + '^@altinn/ux-editor-v3/(.*)': path.join(__dirname, 'packages/ux-editor-v3/src/$1'), '^@altinn/process-editor/(.*)': path.join(__dirname, 'packages/process-editor/src/$1'), '^@altinn/policy-editor/(.*)': path.join(__dirname, 'packages/policy-editor/src/$1'), '^@studio/icons': path.join(__dirname, 'libs/studio-icons/src/$1'), @@ -60,6 +63,9 @@ if (process.env.CI) { config.reporters.push('github-actions'); config.collectCoverage = true; config.coverageReporters = ['lcov']; - config.coveragePathIgnorePatterns = ['frontend/packages/ux-editor/src/testing/']; + config.coveragePathIgnorePatterns = [ + 'frontend/packages/ux-editor/src/testing/', + 'frontend/packages/ux-editor-v3/src/testing/', + ]; } module.exports = config; diff --git a/frontend/language/src/en.json b/frontend/language/src/en.json index 4cc1e5650b2..1d6b03ec888 100644 --- a/frontend/language/src/en.json +++ b/frontend/language/src/en.json @@ -455,6 +455,8 @@ "left_menu.pages_error_format": "Must consist of letters (a-z), numbers, or \"-\", \"_\" and \".\"", "left_menu.pages_error_length": "Can not be more than 30 characters", "left_menu.pages_error_unique": "Must be unique", + "local_changes.modal_download_only_changed_button": "Download zip-file with only locally changed files.", + "local_changes_modal.download_all_button": "Download zip-file of the entire repo.", "merge_conflict.body1": "There are breaking changes in the application that blocks further work.", "merge_conflict.body2": "You can fix these by either download the application as a zip-file and delete the changes, or just blast the application directly.", "merge_conflict.download": "Download", @@ -918,6 +920,7 @@ "sync_header.no_changes_to_share": "Push", "sync_header.nothing_to_push": "You have pushed your latest changes", "sync_header.repo_is_offline": "Repo is offline", + "sync_header.repository": "Repository", "sync_header.service_updated_to_latest": "Your app is updated to the latest version", "sync_header.service_updated_to_latest_submessage": "It is important to fetch changes often if there are multiple people working on the same app. This reduces the chances of merge conflicts.", "sync_header.share_changes": "Push", @@ -932,6 +935,7 @@ "testing.testing_in_testenv_title": "Testing in test environment", "top_menu.about": "About", "top_menu.create": "Create", + "top_menu.dashboard": "Dashboard", "top_menu.datamodel": "Data model", "top_menu.deploy": "Deploy", "top_menu.policy-editor": "Policy", @@ -987,7 +991,6 @@ "ux_editor.component_dropdown_set_preselected": "Set pre-selected value in dropdown list", "ux_editor.component_file_upload": "Attachment", "ux_editor.component_group": "Group", - "ux_editor.component_group_header": "Group - {{id}}", "ux_editor.component_header": "Title", "ux_editor.component_image": "Image", "ux_editor.component_information_panel": "Informative message", diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index d92ea3cdf1f..e493ad4fe61 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -1123,6 +1123,7 @@ "schema_editor.import": "Importer", "schema_editor.in_use_error": "Kan ikke slettes, er i bruk.", "schema_editor.integer": "Heltall", + "schema_editor.invalid_child_error": "Det er ikke mulig å plassere den komponenttypen i denne gruppen.", "schema_editor.keyword": "Nøkkelord", "schema_editor.language": "Språk", "schema_editor.language_add_language": "Legg til språk:", @@ -1265,6 +1266,7 @@ "sync_header.no_changes_to_share": "Last opp dine endringer", "sync_header.nothing_to_push": "Du har delt dine siste endringer", "sync_header.repo_is_offline": "Repo er utilgjengelig", + "sync_header.repository": "Repository", "sync_header.service_updated_to_latest": "Appen din er oppdatert til siste versjon", "sync_header.service_updated_to_latest_submessage": "Hvis det er flere personer som jobber på samme app er det viktig å hente endringer ofte. På denne måten reduserer du sjansen for mergekonflikter.", "sync_header.settings": "Innstillinger", @@ -1290,6 +1292,7 @@ "text_editor.variables_editing_not_supported": "Det, er ikke lagt til støtte for redigering av variabler i Studio.", "top_menu.about": "Oversikt", "top_menu.create": "Lage", + "top_menu.dashboard": "Dashboard", "top_menu.datamodel": "Datamodell", "top_menu.deploy": "Publiser", "top_menu.preview": "Forhåndsvis", @@ -1341,7 +1344,6 @@ "ux_editor.component_deletion_confirm": "Ja, slett komponenten", "ux_editor.component_deletion_text": "Er du sikker på at du vil slette denne komponenten?", "ux_editor.component_dropdown_set_preselected": "Sett forhåndsvalgt verdi for nedtrekksliste", - "ux_editor.component_group_header": "Gruppe - {{id}}", "ux_editor.component_help_text.Accordion": "Med Accordion kan du presentere mye innhold på liten plass i en eller flere rader. Hele raden er klikkbar og lar brukere åpne eller lukke visningen av innholdet under.", "ux_editor.component_help_text.AccordionGroup": "En samling med Accordions som vises vertikalt. Brukes for å gruppere Accordions som hører sammen.", "ux_editor.component_help_text.ActionButton": "Knapp for å trigge en spesifisert aksjon knyttet til prosess-steget sluttbruker befinner seg i. Eksempler inkluderer 'sign', 'confirm', 'reject'.", diff --git a/frontend/libs/studio-components/.eslintrc.js b/frontend/libs/studio-components/.eslintrc.js index 4cab4852377..bc4e0e3ce86 100644 --- a/frontend/libs/studio-components/.eslintrc.js +++ b/frontend/libs/studio-components/.eslintrc.js @@ -14,6 +14,29 @@ module.exports = { ], }, }, + { + files: ['*.tsx', '*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@tanstack/react-query'], + message: + 'In the components library, backend data should be provided as input values to the functions and components that needs them.', + }, + { + group: ['app-shared/*', '@altinn/*'], + message: + 'Files in the @studio/components package should not depend on app-specific packages.', + }, + // Todo: Add restriction for I18next: https://github.com/Altinn/altinn-studio/issues/12189 + ], + }, + ], + }, + }, { files: ['*.test.tsx'], rules: { diff --git a/frontend/libs/studio-components/README.md b/frontend/libs/studio-components/README.md index 9f54226e2e0..97f39a99c62 100644 --- a/frontend/libs/studio-components/README.md +++ b/frontend/libs/studio-components/README.md @@ -1,6 +1,6 @@ # @studio/components -Studio-Components is a tool that wraps around `@digdir/design-system-react` and `@digdir/design-system-tokens`, giving you access to the Designsystem's componetns and tokens adding Altinn customisation to them. It is also a place where internal components for altinn-studio lives, where they follow a "dummy-components" pattern. It's great for delvelopers and designers to have this library for components to ensure that the components used throughout altinn studio's applications looks great and stay consistent. +Studio-Components is a tool that wraps around `@digdir/design-system-react` and `@digdir/design-system-tokens`, giving you access to the Designsystem's components and tokens adding Altinn customisation to them. It is also a place where internal components for altinn-studio lives, where they follow a "dummy-components" pattern. It's great for developers and designers to have this library for components to ensure that the components used throughout Altinn studio's applications looks great and stay consistent. ## Contribute with new components diff --git a/frontend/libs/studio-icons/.eslintrc.js b/frontend/libs/studio-icons/.eslintrc.js new file mode 100644 index 00000000000..9f921d136c6 --- /dev/null +++ b/frontend/libs/studio-icons/.eslintrc.js @@ -0,0 +1,21 @@ +module.exports = { + overrides: [ + { + files: ['*.tsx', '*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['app-shared/*', '@altinn/*'], + message: + 'Files in the @studio/icons package should not depend on app-specific packages.', + }, + ], + }, + ], + }, + }, + ], +}; diff --git a/frontend/libs/studio-pure-functions/.eslintrc.js b/frontend/libs/studio-pure-functions/.eslintrc.js new file mode 100644 index 00000000000..d41b1b9bf39 --- /dev/null +++ b/frontend/libs/studio-pure-functions/.eslintrc.js @@ -0,0 +1,25 @@ +module.exports = { + overrides: [ + { + files: ['*.tsx', '*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['react'], + message: 'The @studio/pure-functions package should not depend on React.', + }, + { + group: ['app-shared/*', '@altinn/*'], + message: + 'Files in the @studio/pure-functions package should not depend on app-specific packages.', + }, + ], + }, + ], + }, + }, + ], +}; diff --git a/frontend/packages/process-editor/src/extensions/altinnCustomTasks.ts b/frontend/packages/process-editor/src/extensions/altinnCustomTasks.ts index 365cd684c64..2f597acb2ce 100644 --- a/frontend/packages/process-editor/src/extensions/altinnCustomTasks.ts +++ b/frontend/packages/process-editor/src/extensions/altinnCustomTasks.ts @@ -1,6 +1,6 @@ export const altinnCustomTasks = { name: 'AltinnTask', - uri: 'http://altinn.no', + uri: 'http://altinn.no/process', prefix: 'altinn', xml: { tagAlias: 'lowerCase', diff --git a/frontend/packages/schema-editor/.eslintrc.json b/frontend/packages/schema-editor/.eslintrc.json index 2e04e89f037..d3ba3d71abc 100644 --- a/frontend/packages/schema-editor/.eslintrc.json +++ b/frontend/packages/schema-editor/.eslintrc.json @@ -5,6 +5,17 @@ { "ignoreRestSiblings": true } + ], + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": ["**/schema-model", "!@altinn/schema-model"], + "message": "Import from @altinn/schema-model instead of using relative path." + } + ] + } ] } } diff --git a/frontend/packages/schema-editor/src/classes/SavableSchemaModel.test.ts b/frontend/packages/schema-editor/src/classes/SavableSchemaModel.test.ts index d4420c6baf0..c6053378a72 100644 --- a/frontend/packages/schema-editor/src/classes/SavableSchemaModel.test.ts +++ b/frontend/packages/schema-editor/src/classes/SavableSchemaModel.test.ts @@ -1,6 +1,6 @@ import { SavableSchemaModel } from './SavableSchemaModel'; -import type { NodePosition } from '../../../schema-model'; -import { extractNameFromPointer, ROOT_POINTER, SchemaModel } from '../../../schema-model'; +import type { NodePosition } from '@altinn/schema-model'; +import { extractNameFromPointer, ROOT_POINTER, SchemaModel } from '@altinn/schema-model'; import { uiSchemaNodesMock, definitionNodeMock, diff --git a/frontend/packages/schema-editor/src/components/SchemaEditor/hooks/useMoveProperty.test.ts b/frontend/packages/schema-editor/src/components/SchemaEditor/hooks/useMoveProperty.test.ts index 9b23c34ed1a..db3cf626d7f 100644 --- a/frontend/packages/schema-editor/src/components/SchemaEditor/hooks/useMoveProperty.test.ts +++ b/frontend/packages/schema-editor/src/components/SchemaEditor/hooks/useMoveProperty.test.ts @@ -7,7 +7,7 @@ import { ROOT_POINTER, SchemaModel, validateTestUiSchema, -} from '../../../../../schema-model'; +} from '@altinn/schema-model'; import { combinationNodeMock, fieldNode1Mock, diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/CustomProperties.test.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/CustomProperties.test.tsx index d8e013bcda8..66ad00b5f5a 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/CustomProperties.test.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/CustomProperties.test.tsx @@ -9,7 +9,7 @@ import userEvent from '@testing-library/user-event'; import { nodeMockBase } from '../../../test/mocks/uiSchemaMock'; import { renderWithProviders } from '../../../test/renderWithProviders'; import { getSavedModel } from '../../../test/test-utils'; -import { validateTestUiSchema } from '../../../../schema-model'; +import { validateTestUiSchema } from '@altinn/schema-model'; const user = userEvent.setup(); diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemFieldsTab/ItemFieldsTab.test.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemFieldsTab/ItemFieldsTab.test.tsx index 549c8407fb7..43634664788 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemFieldsTab/ItemFieldsTab.test.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemFieldsTab/ItemFieldsTab.test.tsx @@ -3,12 +3,11 @@ import { act, screen } from '@testing-library/react'; import type { ItemFieldsTabProps } from './ItemFieldsTab'; import { ItemFieldsTab } from './ItemFieldsTab'; import type { FieldNode, UiSchemaNodes } from '@altinn/schema-model'; -import { FieldType, ObjectKind, SchemaModel } from '@altinn/schema-model'; +import { FieldType, ObjectKind, SchemaModel, validateTestUiSchema } from '@altinn/schema-model'; import { mockUseTranslation } from '../../../../../../testing/mocks/i18nMock'; import type { RenderWithProvidersData } from '../../../../test/renderWithProviders'; import { renderWithProviders } from '../../../../test/renderWithProviders'; import userEvent from '@testing-library/user-event'; -import { validateTestUiSchema } from '../../../../../schema-model'; import { nodeMockBase, rootNodeMock } from '../../../../test/mocks/uiSchemaMock'; import { getSavedModel } from '../../../../test/test-utils'; diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemFieldsTab/ItemFieldsTable/ItemFieldsTable.test.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemFieldsTab/ItemFieldsTable/ItemFieldsTable.test.tsx index cb160fb7013..6acd7972fb7 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemFieldsTab/ItemFieldsTable/ItemFieldsTable.test.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemFieldsTab/ItemFieldsTable/ItemFieldsTable.test.tsx @@ -9,7 +9,7 @@ import { renderWithProviders } from '../../../../../test/renderWithProviders'; import { nodeMockBase, rootNodeMock } from '../../../../../test/mocks/uiSchemaMock'; import { textMock } from '../../../../../../../testing/mocks/i18nMock'; import userEvent from '@testing-library/user-event'; -import { validateTestUiSchema } from '../../../../../../schema-model'; +import { validateTestUiSchema } from '@altinn/schema-model'; const selectedItemPointer = '#/properties/test'; const selectedItemChildPointer = '#/properties/test/properties/testProperty'; diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumList.test.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumList.test.tsx index 641a1631539..27597babc5b 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumList.test.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumList.test.tsx @@ -5,8 +5,8 @@ import { EnumList } from './EnumList'; import { fieldNode1Mock, uiSchemaNodesMock } from '../../../../../test/mocks/uiSchemaMock'; import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../../../../../test/renderWithProviders'; -import type { FieldNode } from '../../../../../../schema-model'; -import { SchemaModel } from '../../../../../../schema-model'; +import type { FieldNode } from '@altinn/schema-model'; +import { SchemaModel } from '@altinn/schema-model'; import { textMock } from '../../../../../../../testing/mocks/i18nMock'; const mockEnums: string[] = ['a', 'b', 'c']; diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ItemRestrictions.test.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ItemRestrictions.test.tsx index 6691245e3a8..f0503000e24 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ItemRestrictions.test.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ItemRestrictions.test.tsx @@ -5,7 +5,7 @@ import { ItemRestrictions } from './ItemRestrictions'; import { renderWithProviders } from '../../../../test/renderWithProviders'; import userEvent from '@testing-library/user-event'; import { fieldNode1Mock, uiSchemaNodesMock } from '../../../../test/mocks/uiSchemaMock'; -import { SchemaModel } from '../../../../../schema-model'; +import { SchemaModel } from '@altinn/schema-model'; const user = userEvent.setup(); diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/NameField.test.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/NameField.test.tsx index 0853544c488..c38e0a1c668 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/NameField.test.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/NameField.test.tsx @@ -6,7 +6,7 @@ import type { NameFieldProps } from './NameField'; import { NameField } from './NameField'; import { renderWithProviders } from '../../../test/renderWithProviders'; import { combinationNodeMock, uiSchemaNodesMock } from '../../../test/mocks/uiSchemaMock'; -import { SchemaModel } from '../../../../schema-model'; +import { SchemaModel } from '@altinn/schema-model'; const user = userEvent.setup(); diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ReferenceSelectionComponent.test.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ReferenceSelectionComponent.test.tsx index f9e7fa22591..d8d6e55bdab 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/ReferenceSelectionComponent.test.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ReferenceSelectionComponent.test.tsx @@ -6,7 +6,7 @@ import type { UiSchemaNode, UiSchemaNodes } from '@altinn/schema-model'; import { createNodeBase, Keyword, ObjectKind, SchemaModel } from '@altinn/schema-model'; import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../../../test/renderWithProviders'; -import { validateTestUiSchema } from '../../../../schema-model'; +import { validateTestUiSchema } from '@altinn/schema-model'; const user = userEvent.setup(); diff --git a/frontend/packages/schema-editor/test/renderHookWithProviders.tsx b/frontend/packages/schema-editor/test/renderHookWithProviders.tsx index 40c551a141e..148bf99c01c 100644 --- a/frontend/packages/schema-editor/test/renderHookWithProviders.tsx +++ b/frontend/packages/schema-editor/test/renderHookWithProviders.tsx @@ -5,7 +5,7 @@ import type { RenderWithProvidersData } from './renderWithProviders'; import type { SchemaEditorAppContextProps } from '@altinn/schema-editor/contexts/SchemaEditorAppContext'; import { SchemaEditorAppContext } from '@altinn/schema-editor/contexts/SchemaEditorAppContext'; import { uiSchemaNodesMock } from './mocks/uiSchemaMock'; -import { SchemaModel } from '../../schema-model'; +import { SchemaModel } from '@altinn/schema-model'; export const renderHookWithProviders = ( diff --git a/frontend/packages/schema-editor/test/renderWithProviders.tsx b/frontend/packages/schema-editor/test/renderWithProviders.tsx index de1d87c7a1c..4b2701ecb7f 100644 --- a/frontend/packages/schema-editor/test/renderWithProviders.tsx +++ b/frontend/packages/schema-editor/test/renderWithProviders.tsx @@ -4,7 +4,7 @@ import { render } from '@testing-library/react'; import type { SchemaEditorAppContextProps } from '@altinn/schema-editor/contexts/SchemaEditorAppContext'; import { SchemaEditorAppContext } from '@altinn/schema-editor/contexts/SchemaEditorAppContext'; import { uiSchemaNodesMock } from './mocks/uiSchemaMock'; -import { SchemaModel } from '../../schema-model'; +import { SchemaModel } from '@altinn/schema-model'; export interface RenderWithProvidersData { appContextProps?: Partial; diff --git a/frontend/packages/shared/src/api/paths.js b/frontend/packages/shared/src/api/paths.js index 35984287f32..e5ef473a269 100644 --- a/frontend/packages/shared/src/api/paths.js +++ b/frontend/packages/shared/src/api/paths.js @@ -65,7 +65,7 @@ export const orgsListPath = () => `${basePath}/orgs`; // Get // Preview export const instanceIdForPreviewPath = (org, app) => `${basePath}/${org}/${app}/mock-instance-id`; // Get -export const previewPage = (org, app, selectedLayoutSet) => `/designer/html/preview.html?${s({ org, app, selectedLayoutSet })}`; +export const previewPage = (org, app, selectedLayoutSet) => `/app-specific-preview/${org}/${app}?${s({ selectedLayoutSet })}`; // Preview - SignalR Hub export const previewSignalRHubSubPath = () => `/previewHub`; diff --git a/frontend/packages/shared/src/components/AltinnPopper.module.css b/frontend/packages/shared/src/components/AltinnPopper.module.css deleted file mode 100644 index c445d4f7b6e..00000000000 --- a/frontend/packages/shared/src/components/AltinnPopper.module.css +++ /dev/null @@ -1,9 +0,0 @@ -.snackbarError { - color: #000; - background: #f9cad3; - border-radius: 0; - box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.25); - font-size: 16px; - padding: 12px 24px 12px 24px; - max-width: 550px; -} diff --git a/frontend/packages/shared/src/components/AltinnPopper.tsx b/frontend/packages/shared/src/components/AltinnPopper.tsx deleted file mode 100644 index 1e0f49a8456..00000000000 --- a/frontend/packages/shared/src/components/AltinnPopper.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import classes from './AltinnPopper.module.css'; -import { Popper } from '@mui/material'; - -export type AltinnPopperProps = { - styleObj?: object; - message?: string; - anchorEl: any; -}; - -export const AltinnPopper = ({ anchorEl, styleObj, message }: AltinnPopperProps) => ( - - {message} - -); diff --git a/frontend/packages/shared/src/components/DragAndDropTree/DragAndDropTree.test.tsx b/frontend/packages/shared/src/components/DragAndDropTree/DragAndDropTree.test.tsx index 963ca231e76..5325210363e 100644 --- a/frontend/packages/shared/src/components/DragAndDropTree/DragAndDropTree.test.tsx +++ b/frontend/packages/shared/src/components/DragAndDropTree/DragAndDropTree.test.tsx @@ -41,7 +41,7 @@ const render = () => ); describe('DragAndDropTree', () => { - it('Reders root items', () => { + it('Renders root items', () => { render(); expect( screen.getByRole('treeitem', { name: rootNodeLabel1, expanded: false }), diff --git a/frontend/packages/shared/src/components/FormField/FormField.tsx b/frontend/packages/shared/src/components/FormField/FormField.tsx index fe54e4e9b2d..fa054e04a6e 100644 --- a/frontend/packages/shared/src/components/FormField/FormField.tsx +++ b/frontend/packages/shared/src/components/FormField/FormField.tsx @@ -1,11 +1,8 @@ import React, { useCallback, useEffect, useState } from 'react'; import { ErrorMessage, HelpText } from '@digdir/design-system-react'; import classes from './FormField.module.css'; -import { useText } from '../../../../ux-editor/src/hooks'; -import { - validateProperty, - isPropertyRequired, -} from '../../../../ux-editor/src/utils/formValidationUtils'; +import { useTranslation } from 'react-i18next'; +import { validateProperty, isPropertyRequired } from '../../utils/formValidationUtils'; import type { TranslationKey } from 'language/type'; import type { JsonSchema } from 'app-shared/types/JsonSchema'; @@ -53,7 +50,7 @@ export const FormField = ({ customValidationMessages, renderField, }: FormFieldProps): JSX.Element => { - const t = useText(); + const { t } = useTranslation(); const [propertyId, setPropertyId] = useState( schema && propertyPath ? `${schema.$id}#/${propertyPath}` : null, diff --git a/frontend/packages/shared/src/components/GiteaHeader/ThreeDotsMenu/ThreeDotsMenu.test.tsx b/frontend/packages/shared/src/components/GiteaHeader/ThreeDotsMenu/ThreeDotsMenu.test.tsx index 1c973c6a09d..b96885a2ac9 100644 --- a/frontend/packages/shared/src/components/GiteaHeader/ThreeDotsMenu/ThreeDotsMenu.test.tsx +++ b/frontend/packages/shared/src/components/GiteaHeader/ThreeDotsMenu/ThreeDotsMenu.test.tsx @@ -8,7 +8,7 @@ import { textMock } from '../../../../../../testing/mocks/i18nMock'; const user = userEvent.setup(); const threeDotsButtonMock = textMock('sync_header.gitea_menu'); const cloneTextMock = textMock('sync_header.clone'); -const repositoryTextMock = textMock('dashboard.repository'); +const repositoryTextMock = textMock('sync_header.repository'); const localChangesTextMock = textMock('sync_header.local_changes'); const localChangesModalMock = 'LocalChangesModal'; @@ -31,10 +31,10 @@ describe('ThreeDotsMenu', () => { const cloneText = screen.getByRole('button', { name: cloneTextMock }); expect(cloneText).toBeInTheDocument(); - const repoText = screen.getByText(repositoryTextMock); + const repoText = screen.getByRole('link', { name: repositoryTextMock }); expect(repoText).toBeInTheDocument(); - const localchangeText = screen.getByText(localChangesTextMock); + const localchangeText = screen.getByRole('button', { name: localChangesTextMock }); expect(localchangeText).toBeInTheDocument(); }); diff --git a/frontend/packages/shared/src/components/GiteaHeader/ThreeDotsMenu/ThreeDotsMenu.tsx b/frontend/packages/shared/src/components/GiteaHeader/ThreeDotsMenu/ThreeDotsMenu.tsx index bbb1bd91379..16399d54fd5 100644 --- a/frontend/packages/shared/src/components/GiteaHeader/ThreeDotsMenu/ThreeDotsMenu.tsx +++ b/frontend/packages/shared/src/components/GiteaHeader/ThreeDotsMenu/ThreeDotsMenu.tsx @@ -59,16 +59,16 @@ export const ThreeDotsMenu = ({ - {t('dashboard.repository')} + {t('sync_header.repository')} -
  • setLocalChangesModalIsOpen(true)}> -
    +
  • +
  • + {localChangesModalIsOpen && ( { - const t = useText(); + const { t } = useTranslation(); const defaultActions = actionGroupMap[mode || 'standBy']; const [actions, setActions] = useState(defaultActions); diff --git a/frontend/packages/shared/src/components/altinnHeader/AltinnHeader.tsx b/frontend/packages/shared/src/components/altinnHeader/AltinnHeader.tsx index b42b5bbc169..4cf6d760ab5 100644 --- a/frontend/packages/shared/src/components/altinnHeader/AltinnHeader.tsx +++ b/frontend/packages/shared/src/components/altinnHeader/AltinnHeader.tsx @@ -13,6 +13,7 @@ import type { TopBarMenuItem } from 'app-shared/types/TopBarMenuItem'; import { getRepositoryType } from 'app-shared/utils/repository'; import { RepositoryType } from 'app-shared/types/global'; import { TopBarMenu } from 'app-shared/enums/TopBarMenu'; +import { useTranslation } from 'react-i18next'; export interface AltinnHeaderProps { menuItems: TopBarMenuItem[]; @@ -39,13 +40,15 @@ export const AltinnHeader = ({ variant = 'regular', repoOwnerIsOrg, }: AltinnHeaderProps) => { + const { t } = useTranslation(); + const repositoryType = getRepositoryType(org, app); return (
    - + {app && ( diff --git a/frontend/packages/shared/src/components/dragAndDrop/DragAndDropList/DragAndDropList.test.tsx b/frontend/packages/shared/src/components/dragAndDrop/DragAndDropList/DragAndDropList.test.tsx index c76f47a2d49..6dfd763b88f 100644 --- a/frontend/packages/shared/src/components/dragAndDrop/DragAndDropList/DragAndDropList.test.tsx +++ b/frontend/packages/shared/src/components/dragAndDrop/DragAndDropList/DragAndDropList.test.tsx @@ -16,7 +16,7 @@ const rootId = 'rootId'; const uniqueDomId = ':r0:'; const onDrop = jest.fn(); const gap = '1rem'; -const defaultlistItemContextProps: DragAndDropListItemContextProps = { +const defaultListItemContextProps: DragAndDropListItemContextProps = { isDisabled: false, itemId, }; @@ -51,7 +51,7 @@ function render({ listItemContextProps = {}, rootContextProps = {} }: RenderProp value={{ ...rootContextProps, ...defaultRootContextProps }} > {children} diff --git a/frontend/packages/shared/src/components/dragAndDrop/DragAndDropListItem/DragAndDropListItem.test.tsx b/frontend/packages/shared/src/components/dragAndDrop/DragAndDropListItem/DragAndDropListItem.test.tsx index 68d9f685c4c..e840066751a 100644 --- a/frontend/packages/shared/src/components/dragAndDrop/DragAndDropListItem/DragAndDropListItem.test.tsx +++ b/frontend/packages/shared/src/components/dragAndDrop/DragAndDropListItem/DragAndDropListItem.test.tsx @@ -22,7 +22,7 @@ const defaultlistItemProps: DragAndDropListItemProps = { itemId, renderItem, }; -const defaultlistItemContextProps: DragAndDropListItemContextProps = { +const defaultListItemContextProps: DragAndDropListItemContextProps = { isDisabled: false, itemId: parentId, }; @@ -59,7 +59,7 @@ function render({ {...listItemProps} {...defaultlistItemProps} /> diff --git a/frontend/packages/shared/src/components/index.ts b/frontend/packages/shared/src/components/index.ts index 4d1b7685980..701f7f6fd2a 100644 --- a/frontend/packages/shared/src/components/index.ts +++ b/frontend/packages/shared/src/components/index.ts @@ -1,4 +1,3 @@ export { default as AltinnMenu } from './molecules/AltinnMenu'; -export { default as AltinnMenuItem } from './molecules/AltinnMenuItem'; export { AltinnConfirmDialog } from './AltinnConfirmDialog'; export { default as FileSelector } from './FileSelector'; diff --git a/frontend/packages/shared/src/components/molecules/AltinnMenuItem.module.css b/frontend/packages/shared/src/components/molecules/AltinnMenuItem.module.css deleted file mode 100644 index 35894c94019..00000000000 --- a/frontend/packages/shared/src/components/molecules/AltinnMenuItem.module.css +++ /dev/null @@ -1,8 +0,0 @@ -.menu { - padding-top: 0; - padding-bottom: 0; -} - -.icon { - min-width: 3rem; -} diff --git a/frontend/packages/shared/src/components/molecules/AltinnMenuItem.tsx b/frontend/packages/shared/src/components/molecules/AltinnMenuItem.tsx deleted file mode 100644 index 94af4f88170..00000000000 --- a/frontend/packages/shared/src/components/molecules/AltinnMenuItem.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react'; -import { ListItemIcon, ListItemText, MenuItem, Typography } from '@mui/material'; -import classes from './AltinnMenuItem.module.css'; - -export interface IAltinnMenuItemProps { - text: string; - icon: React.ComponentType; - onClick: (event: React.SyntheticEvent) => void; - disabled?: boolean; - id: string; - className?: string; - testId?: string; -} - -function AltinnMenuItem(props: IAltinnMenuItemProps, ref: React.Ref) { - const { text, icon: IconComponent, onClick, disabled, id, className, testId } = props; - return ( - - - {IconComponent && } - - - {text} - - - ); -} - -export default React.forwardRef(AltinnMenuItem); diff --git a/frontend/packages/shared/src/hooks/mutations/useUpsertTextResourcesMutation.test.ts b/frontend/packages/shared/src/hooks/mutations/useUpsertTextResourcesMutation.test.tsx similarity index 59% rename from frontend/packages/shared/src/hooks/mutations/useUpsertTextResourcesMutation.test.ts rename to frontend/packages/shared/src/hooks/mutations/useUpsertTextResourcesMutation.test.tsx index e4af08ed311..433875c21c1 100644 --- a/frontend/packages/shared/src/hooks/mutations/useUpsertTextResourcesMutation.test.ts +++ b/frontend/packages/shared/src/hooks/mutations/useUpsertTextResourcesMutation.test.tsx @@ -1,10 +1,11 @@ import { queriesMock } from 'app-shared/mocks/queriesMock'; -import { renderHookWithMockStore } from '../../../../ux-editor/src/testing/mocks'; -import { waitFor } from '@testing-library/react'; -import { useTextResourcesQuery } from '../../../../../app-development/hooks/queries'; +import { renderHook } from '@testing-library/react'; import type { UpsertTextResourcesMutationArgs } from './useUpsertTextResourcesMutation'; import { useUpsertTextResourcesMutation } from './useUpsertTextResourcesMutation'; import type { ITextResource } from 'app-shared/types/global'; +import { createQueryClientMock } from '../../mocks/queryClientMock'; +import React from 'react'; +import { ServicesContextProvider } from '../../contexts/ServicesContext'; // Test data: const org = 'org'; @@ -17,7 +18,7 @@ const args: UpsertTextResourcesMutationArgs = { language, textResources }; describe('useUpsertTextResourcesMutation', () => { test('Calls upsertTextResources with correct parameters', async () => { - const { result: upsertTextResources } = await renderUpsertTextResourcesMutation(); + const { result: upsertTextResources } = renderUpsertTextResourcesMutation(); await upsertTextResources.current.mutateAsync(args); expect(queriesMock.upsertTextResources).toHaveBeenCalledTimes(1); expect(queriesMock.upsertTextResources).toHaveBeenCalledWith(org, app, language, { @@ -26,10 +27,13 @@ describe('useUpsertTextResourcesMutation', () => { }); }); -const renderUpsertTextResourcesMutation = async () => { - const { result: texts } = renderHookWithMockStore()(() => - useTextResourcesQuery(org, app), - ).renderHookResult; - await waitFor(() => expect(texts.current.isSuccess).toBe(true)); - return renderHookWithMockStore()(() => useUpsertTextResourcesMutation(org, app)).renderHookResult; +const renderUpsertTextResourcesMutation = () => { + const client = createQueryClientMock(); + return renderHook(() => useUpsertTextResourcesMutation(org, app), { + wrapper: ({ children }) => ( + + {children} + + ), + }); }; diff --git a/frontend/packages/shared/src/hooks/queries/useTextResourcesQuery.test.ts b/frontend/packages/shared/src/hooks/queries/useTextResourcesQuery.test.ts deleted file mode 100644 index 1f75ede4724..00000000000 --- a/frontend/packages/shared/src/hooks/queries/useTextResourcesQuery.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { queriesMock } from 'app-shared/mocks/queriesMock'; -import { - renderHookWithMockStore, - textLanguagesMock, -} from '../../../../ux-editor/src/testing/mocks'; -import { useTextResourcesQuery } from './useTextResourcesQuery'; -import { waitFor } from '@testing-library/react'; - -// Test data: -const org = 'org'; -const app = 'app'; - -describe('useTextResourcesQuery', () => { - it('Calls getTextResources for each language', async () => { - const getTextLanguages = jest.fn().mockImplementation(() => Promise.resolve(textLanguagesMock)); - const { result: resourcesResult } = renderHookWithMockStore( - {}, - { - getTextLanguages, - }, - )(() => useTextResourcesQuery(org, app)).renderHookResult; - await waitFor(() => expect(resourcesResult.current.isSuccess).toBe(true)); - expect(getTextLanguages).toHaveBeenCalledTimes(1); - expect(getTextLanguages).toHaveBeenCalledWith(org, app); - expect(queriesMock.getTextResources).toHaveBeenCalledTimes(textLanguagesMock.length); - textLanguagesMock.forEach((language) => { - expect(queriesMock.getTextResources).toHaveBeenCalledWith(org, app, language); - }); - }); -}); diff --git a/frontend/packages/shared/src/hooks/queries/useTextResourcesQuery.test.tsx b/frontend/packages/shared/src/hooks/queries/useTextResourcesQuery.test.tsx new file mode 100644 index 00000000000..db798da2e59 --- /dev/null +++ b/frontend/packages/shared/src/hooks/queries/useTextResourcesQuery.test.tsx @@ -0,0 +1,36 @@ +import { queriesMock } from '../../mocks/queriesMock'; +import { useTextResourcesQuery } from './useTextResourcesQuery'; +import { renderHook, waitFor } from '@testing-library/react'; +import React from 'react'; +import { ServicesContextProvider } from '../../contexts/ServicesContext'; +import { createQueryClientMock } from '../../mocks/queryClientMock'; + +// Test data: +const org = 'org'; +const app = 'app'; +const languagesMock = ['nb', 'nn', 'en']; + +describe('useTextResourcesQuery', () => { + it('Calls getTextResources for each language', async () => { + const getTextLanguages = jest.fn().mockImplementation(() => Promise.resolve(languagesMock)); + const client = createQueryClientMock(); + const { result: resourcesResult } = renderHook(() => useTextResourcesQuery(org, app), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + await waitFor(() => expect(resourcesResult.current.isSuccess).toBe(true)); + expect(getTextLanguages).toHaveBeenCalledTimes(1); + expect(getTextLanguages).toHaveBeenCalledWith(org, app); + expect(queriesMock.getTextResources).toHaveBeenCalledTimes(languagesMock.length); + languagesMock.forEach((language) => { + expect(queriesMock.getTextResources).toHaveBeenCalledWith(org, app, language); + }); + }); +}); diff --git a/frontend/packages/ux-editor/src/utils/formValidationUtils.test.tsx b/frontend/packages/shared/src/utils/formValidationUtils/formValidationUtils.test.tsx similarity index 88% rename from frontend/packages/ux-editor/src/utils/formValidationUtils.test.tsx rename to frontend/packages/shared/src/utils/formValidationUtils/formValidationUtils.test.tsx index edc9b628b42..f933f603896 100644 --- a/frontend/packages/ux-editor/src/utils/formValidationUtils.test.tsx +++ b/frontend/packages/shared/src/utils/formValidationUtils/formValidationUtils.test.tsx @@ -6,11 +6,11 @@ import { validate, validateProperty, } from './formValidationUtils'; -import expressionSchema from '../testing/schemas/json/layout/expression.schema.v1.json'; -import numberFormatSchema from '../testing/schemas/json/layout/number-format.schema.v1.json'; -import layoutSchema from '../testing/schemas/json/layout/layout.schema.v1.json'; -import inputSchema from '../testing/schemas/json/component/Input.schema.v1.json'; -import commonDefsSchema from '../testing/schemas/json/component/common-defs.schema.v1.json'; +import expressionSchema from './test-data/expression.schema.v1.json'; +import numberFormatSchema from './test-data/number-format.schema.v1.json'; +import layoutSchema from './test-data/layout.schema.v1.json'; +import inputSchema from './test-data/Input.schema.v1.json'; +import commonDefsSchema from './test-data/common-defs.schema.v1.json'; describe('formValidationUtils', () => { beforeAll(() => { diff --git a/frontend/packages/ux-editor/src/utils/formValidationUtils.ts b/frontend/packages/shared/src/utils/formValidationUtils/formValidationUtils.ts similarity index 100% rename from frontend/packages/ux-editor/src/utils/formValidationUtils.ts rename to frontend/packages/shared/src/utils/formValidationUtils/formValidationUtils.ts diff --git a/frontend/packages/shared/src/utils/formValidationUtils/index.ts b/frontend/packages/shared/src/utils/formValidationUtils/index.ts new file mode 100644 index 00000000000..adca53fc1cb --- /dev/null +++ b/frontend/packages/shared/src/utils/formValidationUtils/index.ts @@ -0,0 +1 @@ +export * from './formValidationUtils'; diff --git a/frontend/packages/shared/src/utils/formValidationUtils/test-data/Input.schema.v1.json b/frontend/packages/shared/src/utils/formValidationUtils/test-data/Input.schema.v1.json new file mode 100644 index 00000000000..3f010e0ab71 --- /dev/null +++ b/frontend/packages/shared/src/utils/formValidationUtils/test-data/Input.schema.v1.json @@ -0,0 +1,345 @@ +{ + "$id": "https://altinncdn.no/schemas/json/component/Input.schema.v1.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Input component", + "description": "Schema that describes the layout configuration for an input component.", + "type": "object", + "properties": { + "id": { + "$ref": "https://altinncdn.no/schemas/json/component/common-defs.schema.v1.json#/definitions/id" + }, + "type": { + "type": "string", + "title": "Type", + "description": "The component type.", + "const": "Input" + }, + "dataModelBindings": { + "title": "Data model bindings", + "description": "Data model bindings for component", + "type": "object", + "properties": { + "simpleBinding": { + "type": "string", + "title": "Simple binding", + "description": "Data model binding for components connection to a single field in the data model" + } + }, + "required": ["simpleBinding"], + "additionalProperties": false + }, + "required": { + "title": "Required", + "description": "Boolean or expression indicating if the component is required when filling in the form. Defaults to false.", + "default": false, + "$ref": "https://altinncdn.no/schemas/json/layout/expression.schema.v1.json#/definitions/boolean" + }, + "readOnly": { + "title": "Read only", + "description": "Boolean or expression indicating if the component should be presented as read only. Defaults to false.", + "default": false, + "$ref": "https://altinncdn.no/schemas/json/layout/expression.schema.v1.json#/definitions/boolean" + }, + "textResourceBindings": { + "type": "object", + "title": "Text resource bindings", + "description": "Text resource bindings for a component.", + "properties": { + "title": { + "type": "string", + "title": "Label", + "description": "The title/label text for the component" + }, + "description": { + "type": "string", + "title": "Description", + "description": "The description text for the component" + }, + "help": { + "type": "string", + "title": "Help text", + "description": "The help text for the component" + } + }, + "required": ["title"], + "additionalProperties": true + }, + "triggers": { + "$ref": "https://altinncdn.no/schemas/json/component/common-defs.schema.v1.json#/definitions/triggers" + }, + "grid": { + "$ref": "https://altinncdn.no/schemas/json/component/common-defs.schema.v1.json#/definitions/gridSettings" + }, + "labelSettings": { + "$ref": "https://altinncdn.no/schemas/json/component/common-defs.schema.v1.json#/definitions/labelSettings" + }, + "pageBreak": { + "$ref": "https://altinncdn.no/schemas/json/component/common-defs.schema.v1.json#/definitions/pageBreak" + }, + "hidden": { + "title": "Hidden", + "description": "Boolean value or expression indicating if the component should be hidden. Defaults to false.", + "default": false, + "$ref": "https://altinncdn.no/schemas/json/layout/expression.schema.v1.json#/definitions/boolean" + }, + "renderAsSummary": { + "title": "Render as summary", + "description": "Boolean or expression indicating if the component should be rendered as a summary. Defaults to false.", + "default": false, + "$ref": "https://altinncdn.no/schemas/json/layout/expression.schema.v1.json#/definitions/boolean" + }, + "formatting": { + "title": "Input formatting", + "description": "Set of options for formatting input fields.", + "type": "object", + "properties": { + "currency": { + "type": "string", + "title": "Language-sensitive number formatting based on currency", + "description": "Enables currency along with thousand and decimal separators to be language sensitive based on selected app language. They are configured in number property. Note: parts that already exist in number property are not overridden by this prop.", + "enum": [ + "AED", + "AFN", + "ALL", + "AMD", + "ANG", + "AOA", + "ARS", + "AUD", + "AWG", + "AZN", + "BAM", + "BBD", + "BDT", + "BGN", + "BHD", + "BIF", + "BMD", + "BND", + "BOB", + "BOV", + "BRL", + "BSD", + "BTN", + "BWP", + "BYN", + "BZD", + "CAD", + "CDF", + "CHE", + "CHF", + "CHW", + "CLF", + "CLP", + "CNY", + "COP", + "COU", + "CRC", + "CUC", + "CUP", + "CVE", + "CZK", + "DJF", + "DKK", + "DOP", + "DZD", + "EGP", + "ERN", + "ETB", + "EUR", + "FJD", + "FKP", + "GBP", + "GEL", + "GHS", + "GIP", + "GMD", + "GNF", + "GTQ", + "GYD", + "HKD", + "HNL", + "HTG", + "HUF", + "IDR", + "ILS", + "INR", + "IQD", + "IRR", + "ISK", + "JMD", + "JOD", + "JPY", + "KES", + "KGS", + "KHR", + "KMF", + "KPW", + "KRW", + "KWD", + "KYD", + "KZT", + "LAK", + "LBP", + "LKR", + "LRD", + "LSL", + "LYD", + "MAD", + "MDL", + "MGA", + "MKD", + "MMK", + "MNT", + "MOP", + "MRU", + "MUR", + "MVR", + "MWK", + "MXN", + "MXV", + "MYR", + "MZN", + "NAD", + "NGN", + "NIO", + "NOK", + "NPR", + "NZD", + "OMR", + "PAB", + "PEN", + "PGK", + "PHP", + "PKR", + "PLN", + "PYG", + "QAR", + "RON", + "RSD", + "RUB", + "RWF", + "SAR", + "SBD", + "SCR", + "SDG", + "SEK", + "SGD", + "SHP", + "SLE", + "SLL", + "SOS", + "SRD", + "SSP", + "STN", + "SVC", + "SYP", + "SZL", + "THB", + "TJS", + "TMT", + "TND", + "TOP", + "TRY", + "TTD", + "TWD", + "TZS", + "UAH", + "UGX", + "USD", + "USN", + "UYI", + "UYU", + "UYW", + "UZS", + "VED", + "VES", + "VND", + "VUV", + "WST", + "XAF", + "XCD", + "XDR", + "XOF", + "XPF", + "XSU", + "XUA", + "YER", + "ZAR", + "ZMW", + "ZWL" + ] + }, + "unit": { + "title": "Language-sensitive number formatting based on unit", + "type": "string", + "description": "Enables unit along with thousand and decimal separators to be language sensitive based on selected app language. They are configured in number property. Note: parts that already exist in number property are not overridden by this prop.", + "enum": [ + "celsius", + "centimeter", + "day", + "degree", + "foot", + "gram", + "hectare", + "hour", + "inch", + "kilogram", + "kilometer", + "liter", + "meter", + "milliliter", + "millimeter", + "millisecond", + "minute", + "month", + "percent", + "second", + "week", + "year" + ] + }, + "position": { + "type": "string", + "title": "Position of the currency/unit symbol (only when using currency or unit options)", + "description": "Display the unit as prefix or suffix. Default is prefix", + "enum": ["prefix", "suffix"] + }, + "align": { + "type": "string", + "title": "Align input", + "description": "The alignment for Input field (eg. right aligning a series of numbers).", + "enum": ["left", "center", "right"] + } + } + }, + "saveWhileTyping": { + "title": "Automatic saving while typing", + "description": "Boolean or number. True = feature on (default), false = feature off (saves on focus blur), number = timeout in milliseconds (400 by default)", + "default": true, + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "number" + } + ] + }, + "variant": { + "type": "string", + "title": "Input Variant", + "description": "An enum to choose if the inputfield it is a normal textfield or a searchbar", + "enum": ["text", "search"] + }, + "autocomplete": { + "$ref": "https://altinncdn.no/schemas/json/component/common-defs.schema.v1.json#/definitions/autocomplete" + }, + "maxLength": { + "title": "Maximum length", + "description": "Maximum length of input field", + "type": "number" + } + }, + "required": ["id", "type", "dataModelBindings"] +} diff --git a/frontend/packages/shared/src/utils/formValidationUtils/test-data/common-defs.schema.v1.json b/frontend/packages/shared/src/utils/formValidationUtils/test-data/common-defs.schema.v1.json new file mode 100644 index 00000000000..a0569d03990 --- /dev/null +++ b/frontend/packages/shared/src/utils/formValidationUtils/test-data/common-defs.schema.v1.json @@ -0,0 +1,558 @@ +{ + "$id": "https://altinncdn.no/schemas/json/component/common-defs.schema.v1.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Common definitions", + "description": "Schema that contains common definitions used by components", + "type": "object", + "definitions": { + "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 ." + }, + "hidden": { + "title": "Hidden", + "description": "Boolean value or expression indicating if the component should be hidden. Defaults to false.", + "default": false, + "$ref": "https://altinncdn.no/schemas/json/layout/expression.schema.v1.json#/definitions/boolean" + }, + "renderAsSummary": { + "title": "Render as summary", + "description": "Boolean or expression indicating if the component should be rendered as a summary. Defaults to false.", + "default": false, + "$ref": "https://altinncdn.no/schemas/json/layout/expression.schema.v1.json#/definitions/boolean" + }, + "required": { + "title": "Required", + "description": "Boolean or expression indicating if the component is required when filling in the form. Defaults to false.", + "default": false, + "$ref": "https://altinncdn.no/schemas/json/layout/expression.schema.v1.json#/definitions/boolean" + }, + "readOnly": { + "title": "Read only", + "description": "Boolean or expression indicating if the component should be presented as read only. Defaults to false.", + "default": false, + "$ref": "https://altinncdn.no/schemas/json/layout/expression.schema.v1.json#/definitions/boolean" + }, + "basicTextResources": { + "type": "object", + "title": "Text resource bindings", + "description": "Text resource bindings for a component.", + "properties": { + "title": { + "type": "string", + "title": "Label", + "description": "The title/label text for the component" + }, + "description": { + "type": "string", + "title": "Description", + "description": "The description text for the component" + }, + "help": { + "type": "string", + "title": "Help text", + "description": "The help text for the component" + }, + "shortName": { + "type": "string", + "title": "Short name", + "description": "The short name for the component (used in validation messages) (optional). If it is not specified, 'title' text is used." + }, + "tableTitle": { + "type": "string", + "title": "Table title", + "description": "The text shown in column title when component is used in repeating group (optional). If it is not specified, 'title' text is used." + } + }, + "required": ["title"], + "additionalProperties": true + }, + "basicDataModelBindings": { + "title": "Data model bindings", + "description": "Data model bindings for component", + "type": "object", + "properties": { + "simpleBinding": { + "type": "string", + "title": "Simple binding", + "description": "Data model binding for components connection to a single field in the data model" + } + }, + "required": ["simpleBinding"], + "additionalProperties": false + }, + "gridProps": { + "properties": { + "xs": { + "$ref": "#/definitions/gridValue", + "title": "xs", + "description": "Grid breakpoint at 0px" + }, + "sm": { + "$ref": "#/definitions/gridValue", + "title": "sm", + "description": "Grid breakpoint at 600px" + }, + "md": { + "$ref": "#/definitions/gridValue", + "title": "md", + "description": "Grid breakpoint at 960px" + }, + "lg": { + "$ref": "#/definitions/gridValue", + "title": "lg", + "description": "Grid breakpoint at 1280px" + }, + "xl": { + "$ref": "#/definitions/gridValue", + "title": "xl", + "description": "Grid breakpoint at 1920px" + } + } + }, + "gridSettings": { + "allOf": [ + { + "$ref": "#/definitions/gridProps" + } + ], + "properties": { + "labelGrid": { + "title": "labelGrid", + "description": "Optional grid for the component label. Used in combination with innerGrid to align labels on the side.", + "examples": [ + { + "xs": 12 + } + ], + "$ref": "#/definitions/gridProps" + }, + "innerGrid": { + "title": "innerGrid", + "description": "Optional grid for inner component content like input field or dropdown. Used to avoid inner content filling the component width.", + "examples": [ + { + "xs": 12 + } + ], + "$ref": "#/definitions/gridProps" + } + } + }, + "gridValue": { + "type": "integer", + "maximum": 12, + "minimum": 1, + "examples": [12] + }, + "labelSettings": { + "type": "object", + "title": "Label settings", + "description": "A collection of settings for how the component label should be rendered.", + "properties": { + "optionalIndicator": { + "type": "boolean", + "title": "Optional indicator", + "description": "Controls whether the text that is indicating that a field is optional should be displayed.", + "default": true + } + } + }, + "pageBreak": { + "type": "object", + "properties": { + "breakBefore": { + "title": "Page break before", + "type": "string", + "description": "PDF only: Value or expression indicating whether a page break should be added before the component. Can be either: 'auto' (default), 'always', or 'avoid'.", + "enum": ["auto", "always", "avoid"], + "default": "auto" + }, + "breakAfter": { + "title": "Page break after", + "type": "string", + "description": "PDF only: Value or expression indicating whether a page break should be added after the component. Can be either: 'auto' (default), 'always', or 'avoid'.", + "enum": ["auto", "always", "avoid"], + "default": "auto" + } + } + }, + "triggers": { + "title": "Triggers", + "description": "An array of actions that should be triggered when data connected to this component changes.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "validation", + "validateRow", + "validatePage", + "validateAllPages", + "calculatePageOrder" + ] + } + }, + "saveWhileTyping": { + "title": "Automatic saving while typing", + "description": "Boolean or number. True = feature on (default), false = feature off (saves on focus blur), number = timeout in milliseconds (400 by default)", + "default": true, + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "number" + } + ] + }, + "autocomplete": { + "title": "HTML attribute: autocomplete", + "description": "The HTML autocomplete attribute lets web developers specify what if any permission the user agent has to provide automated assistance in filling out form field values, as well as guidance to the browser as to the type of information expected in the field.", + "type": "string", + "enum": [ + "on", + "off", + "name", + "honorific-prefix", + "given-name", + "additional-name", + "family-name", + "honorific-suffix", + "nickname", + "email", + "username", + "new-password", + "current-password", + "one-time-code", + "organization-title", + "organization", + "street-address", + "address-line1", + "address-line2", + "address-line3", + "address-level4", + "address-level3", + "address-level2", + "address-level1", + "country", + "country-name", + "postal-code", + "cc-name", + "cc-given-name", + "cc-additional-name", + "cc-family-name", + "cc-number", + "cc-exp", + "cc-exp-month", + "cc-exp-year", + "cc-csc", + "cc-type", + "transaction-currency", + "transaction-amount", + "language", + "bday", + "bday-day", + "bday-month", + "bday-year", + "sex", + "tel", + "tel-country-code", + "tel-national", + "tel-area-code", + "tel-local", + "tel-extension", + "url", + "photo" + ] + }, + "mapping": { + "type": "object", + "title": "Mapping", + "examples": [ + { + "some.source.field": "key1", + "some.other.source": "key2" + } + ], + "additionalProperties": { + "type": "string" + } + }, + "optionsId": { + "type": "string", + "title": "Options ID", + "description": "Reference to connected options by id." + }, + "options": { + "properties": { + "label": { + "type": "string", + "title": "Label", + "description": "The option label. Can be plain text or a text resource binding." + }, + "value": { + "type": "string", + "title": "Value", + "description": "The option value." + } + }, + "required": ["label", "value"] + }, + "gridRow": { + "properties": { + "header": { + "title": "Header?", + "description": "A boolean indicating if the row should be a header row", + "type": "boolean", + "default": false + }, + "readOnly": { + "title": "Read only?", + "description": "A boolean indicating if the row should be a read only row (yellow background)", + "type": "boolean", + "default": false + }, + "cells": { + "title": "Grid row cells", + "description": "An array of cells to be rendered in the row", + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/gridCellText" + }, + { + "$ref": "#/definitions/gridCellComponent" + }, + { + "$ref": "#/definitions/tableColumnOptions" + }, + { + "type": "null", + "title": "Empty cell" + } + ] + } + } + }, + "required": ["cells"] + }, + "gridCellText": { + "properties": { + "text": { + "title": "Text", + "description": "The text or text resource ID to be rendered in the cell", + "type": "string" + } + }, + "$ref": "#/definitions/tableColumnOptions", + "required": ["text"] + }, + "gridCellComponent": { + "properties": { + "component": { + "title": "Component ID", + "description": "The ID of the component to be rendered in the cell", + "type": "string" + } + }, + "$ref": "#/definitions/tableColumnOptions", + "required": ["component"] + }, + "groupEditOptions": { + "properties": { + "mode": { + "title": "Edit mode", + "description": "Mode for how repeating group table is displayed in edit mode", + "type": "string", + "enum": ["hideTable", "likert", "showAll", "showTable", "onlyTable"] + }, + "filter": { + "title": "Filter", + "description": "Conditions for filtering visible items in repeating group", + "type": "array", + "items": { + "$ref": "#/definitions/groupFilterItem" + } + }, + "saveButton": { + "title": "Save button", + "description": "Boolean or expression indicating whether save button should be shown or not for a given row", + "$ref": "https://altinncdn.no/schemas/json/layout/expression.schema.v1.json#/definitions/boolean" + }, + "saveAndNextButton": { + "title": "Save and open next button", + "description": "Boolean or expression indicating whether save and go to next button should be shown or not in addition to save and close button", + "$ref": "https://altinncdn.no/schemas/json/layout/expression.schema.v1.json#/definitions/boolean" + }, + "deleteButton": { + "title": "Delete button", + "description": "Boolean or expression indicating whether delete button should be shown or not for a given row", + "$ref": "https://altinncdn.no/schemas/json/layout/expression.schema.v1.json#/definitions/boolean" + }, + "editButton": { + "title": "Edit button", + "description": "Boolean or expression indicating whether edit button should be shown or not for a given row", + "$ref": "https://altinncdn.no/schemas/json/layout/expression.schema.v1.json#/definitions/boolean" + }, + "multiPage": { + "title": "Multi-page", + "description": "Boolean value indicating if form components in edit mode should be shown over multiple pages/views.", + "type": "boolean" + }, + "addButton": { + "title": "Add button", + "description": "Boolean or expression indicating whether add new button should be shown or not under the table.", + "$ref": "https://altinncdn.no/schemas/json/layout/expression.schema.v1.json#/definitions/boolean" + }, + "alwaysShowAddButton": { + "title": "Show add button on open group", + "description": "Boolean value indicating whether add new button should be shown or not under the table when a group is open.", + "type": "boolean", + "default": false + }, + "openByDefault": { + "title": "Open by default", + "description": "Boolean or string indicating if group should be opened by default. If no items exist: 'first', 'last', and true adds a new item. If items exist already, true does not open anything, but 'first' opens the first item, and 'last' opens the last item in the group.", + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": ["first", "last"] + } + ] + }, + "alertOnDelete": { + "title": "Alert on delete", + "description": "Boolean value indicating if warning popup should be displayed when attempting to delete a row", + "type": "boolean" + } + } + }, + "groupPanelOptions": { + "properties": { + "variant": { + "title": "Panel variant", + "description": "Change the look of the panel.", + "type": "string", + "enum": ["info", "warning", "success"], + "default": "info" + }, + "showIcon": { + "title": "Show icon", + "description": "Boolean value indicating if the icon should be shown.", + "type": "boolean", + "default": true + }, + "iconUrl": { + "title": "Icon url", + "description": "Url of the icon to be shown in panel. Can be relative if hosted by app or full if referencing a cdn or other hosting.", + "type": "string", + "examples": ["fancyIcon.svg", "https://cdn.example.com/fancyIcon.svg"] + }, + "iconAlt": { + "title": "Icon alt", + "description": "Alternative text for the icon. Only applicable if iconUrl is provided. Can be plain text or a text resource reference.", + "type": "string" + }, + "groupReference": { + "title": "Group reference", + "description": "Reference to the group that is being displayed in the panel. Used for referencing another repeating group context.", + "type": "object", + "properties": { + "group": { + "type": "string", + "title": "Group", + "description": "Group reference. Can be either the group id or the group data model binding.", + "examples": ["the-group-id", "some.model.theGroup"] + } + } + } + } + }, + "groupFilterItem": { + "properties": { + "key": { + "title": "Key", + "description": "Key representing field in data model to check.", + "type": "string" + }, + "value": { + "title": "Value", + "description": "Value to check against.", + "type": "string" + } + } + }, + "paginationProperties": { + "type": "object", + "properties": { + "alternatives": { + "type": "array", + "items": { + "type": "number" + }, + "title": "Alternatives", + "description": "List of page sizes the user can choose from. Make sure to test the performance of the largest number of items per page you are allowing." + }, + "default": { + "type": "number", + "title": "Default", + "description": "The pagination size that is set to default." + } + }, + "required": ["alternatives", "default"] + }, + "tableColumnOptions": { + "title": "Column options", + "description": "Column options for specified header.", + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/tableColumnTextOptions" + }, + { + "type": "object", + "properties": { + "width": { + "title": "Width", + "description": "Width of cell in % or 'auto'. Defaults to 'auto'", + "type": "string", + "pattern": "^([0-9]{1,2}%|100%|auto)$" + } + } + } + ] + }, + "tableColumnTextOptions": { + "properties": { + "alignText": { + "title": "Align Text", + "description": "Choose text alignment between 'left', 'center', or 'right' for text in table cells. Defaults to 'left' for text and 'right' for numbers.", + "type": "string", + "enum": ["left", "center", "right"] + }, + "textOverflow": { + "title": "Text Overflow", + "description": "Use this property to controll behaviour when text is too large to be displayed in table cell.", + "properties": { + "lineWrap": { + "title": "Line Wrap", + "description": "Toggle line wrapping on or off. Defaults to true", + "type": "boolean" + }, + "maxHeight": { + "title": "Max Height", + "description": "Determines the number of lines to display in table cell before hiding the rest of the text with an elipsis (...). Defaults to 2.", + "type": "number" + } + } + } + } + } + } +} diff --git a/frontend/packages/shared/src/utils/formValidationUtils/test-data/expression.schema.v1.json b/frontend/packages/shared/src/utils/formValidationUtils/test-data/expression.schema.v1.json new file mode 100644 index 00000000000..3ebd771609b --- /dev/null +++ b/frontend/packages/shared/src/utils/formValidationUtils/test-data/expression.schema.v1.json @@ -0,0 +1,698 @@ +{ + "$id": "https://altinncdn.no/schemas/json/layout/expression.schema.v1.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Expression", + "description": "Multi-purpose expression mini-language used to declare dynamic behaviour in Altinn 3 apps", + "examples": [ + ["equals", ["dataModel", "My.Model.Group.Field"], "string constant"], + ["greaterThanEq", ["component", "my-component-id"], ["dataModel", "My.Model.Other.Field"]], + ["or", ["equals", "foo", "bar"], ["equals", "foo", "baz"]], + [ + "if", + [ + "or", + ["equals", ["component", "my-component"], ""], + ["equals", ["component", "my-component"], null] + ], + "This will be the value if the condition above is true", + "else", + [ + "if", + ["notEquals", ["component", "my-other-component"], "illegal value"], + "This will be the value if the first condition is false, and the second is true", + "else", + "This will be the value if all the conditions above are false" + ] + ], + ["concat", "Are you sure you want to delete ", ["dataModel", "My.Model.Title"], "?"] + ], + "$ref": "#/definitions/any", + "definitions": { + "any": { + "title": "Any expression", + "anyOf": [ + { + "type": "null", + "title": "Null/missing value" + }, + { + "$ref": "#/definitions/strict-string" + }, + { + "$ref": "#/definitions/strict-boolean" + }, + { + "$ref": "#/definitions/strict-number" + }, + { + "$ref": "#/definitions/func-if" + } + ] + }, + "string": { + "title": "Any expression returning string", + "anyOf": [ + { + "type": "null", + "title": "Null/missing value" + }, + { + "$ref": "#/definitions/strict-string" + }, + { + "$ref": "#/definitions/func-if" + }, + { + "$ref": "#/definitions/strict-number", + "description": "Numbers can be cast to strings" + }, + { + "$ref": "#/definitions/strict-boolean", + "description": "Booleans can be cast to strings" + } + ] + }, + "strict-string": { + "title": "Any expression returning string (strict)", + "anyOf": [ + { + "type": "string", + "title": "String constant" + }, + { + "$ref": "#/definitions/func-component" + }, + { + "$ref": "#/definitions/func-dataModel" + }, + { + "$ref": "#/definitions/func-instanceContext" + }, + { + "$ref": "#/definitions/func-frontendSettings" + }, + { + "$ref": "#/definitions/func-concat" + }, + { + "$ref": "#/definitions/func-round" + }, + { + "$ref": "#/definitions/func-text" + }, + { + "$ref": "#/definitions/func-language" + }, + { + "$ref": "#/definitions/func-lowerCase" + }, + { + "$ref": "#/definitions/func-upperCase" + } + ] + }, + "boolean": { + "title": "Any expression returning boolean", + "anyOf": [ + { + "type": "null", + "title": "Null/missing value" + }, + { + "$ref": "#/definitions/strict-boolean" + }, + { + "$ref": "#/definitions/func-if" + }, + { + "$ref": "#/definitions/strict-string", + "description": "Stringy true/false/0/1 can be cast to boolean" + }, + { + "$ref": "#/definitions/strict-number", + "description": "Numeric 0/1 can be cast to boolean" + } + ] + }, + "strict-boolean": { + "title": "Any expression returning boolean (strict)", + "anyOf": [ + { + "type": "boolean", + "title": "Boolean constant" + }, + { + "$ref": "#/definitions/func-equals" + }, + { + "$ref": "#/definitions/func-notEquals" + }, + { + "$ref": "#/definitions/func-greaterThan" + }, + { + "$ref": "#/definitions/func-greaterThanEq" + }, + { + "$ref": "#/definitions/func-lessThan" + }, + { + "$ref": "#/definitions/func-lessThanEq" + }, + { + "$ref": "#/definitions/func-not" + }, + { + "$ref": "#/definitions/func-and" + }, + { + "$ref": "#/definitions/func-or" + }, + { + "$ref": "#/definitions/func-authContext" + }, + { + "$ref": "#/definitions/func-contains" + }, + { + "$ref": "#/definitions/func-notContains" + }, + { + "$ref": "#/definitions/func-endsWith" + }, + { + "$ref": "#/definitions/func-startsWith" + }, + { + "$ref": "#/definitions/func-commaContains" + } + ] + }, + "number": { + "title": "Any expression returning a number", + "anyOf": [ + { + "type": "null", + "title": "Null/missing value" + }, + { + "$ref": "#/definitions/strict-number" + }, + { + "$ref": "#/definitions/func-if" + }, + { + "$ref": "#/definitions/strict-string", + "description": "Numeric strings can be cast to numbers" + } + ] + }, + "strict-number": { + "title": "Any expression returning a number (strict)", + "anyOf": [ + { + "type": "number", + "title": "Numeric constant" + }, + { + "$ref": "#/definitions/func-stringLength" + } + ] + }, + "func-if": { + "title": "If/else conditional expression", + "description": "This function will evaluate and return the result of either branch. If else is not given, null will be returned instead.", + "anyOf": [ + { + "$ref": "#/definitions/func-if-with-else" + }, + { + "$ref": "#/definitions/func-if-without-else" + } + ] + }, + "func-if-without-else": { + "type": "array", + "prefixItems": [ + { + "const": "if" + }, + { + "$ref": "#/definitions/boolean" + }, + { + "$ref": "#/definitions/any" + } + ], + "additionalItems": false + }, + "func-if-with-else": { + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "if" + }, + { + "$ref": "#/definitions/boolean" + }, + { + "$ref": "#/definitions/any" + }, + { + "type": "string", + "const": "else" + }, + { + "$ref": "#/definitions/any" + } + ], + "additionalItems": false + }, + "func-component": { + "title": "Component value lookup function", + "description": "This function will look up a nearby component and its value (only supports simpleBinding currently). Other components can be siblings, or siblings of parent groups.", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "component" + }, + { + "$ref": "#/definitions/string" + } + ], + "additionalItems": false + }, + "func-dataModel": { + "title": "Data model lookup function", + "description": "This function will look up a value in the data model, using the JSON dot notation for referencing the data model structure. Relative positioning inside repeating groups will be resolved automatically if no positional indexes are specified.", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "dataModel" + }, + { + "$ref": "#/definitions/string" + } + ], + "additionalItems": false + }, + "func-instanceContext": { + "title": "Instance context lookup function", + "description": "This function can be used to lookup a value from the instance context", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "instanceContext" + }, + { + "enum": ["appId", "instanceId", "instanceOwnerPartyId"] + } + ], + "additionalItems": false + }, + "func-authContext": { + "title": "Auth context lookup function", + "description": "This function can be used to check the users permissions on the current process step.", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "authContext" + }, + { + "enum": ["read", "write", "instantiate", "confirm", "sign", "reject"] + } + ], + "additionalItems": false + }, + "func-frontendSettings": { + "title": "Frontend settings lookup function", + "description": "This function can be used to lookup a value from frontendSettings (only supports scalar values, no objects or arrays)", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "frontendSettings" + }, + { + "$ref": "#/definitions/string" + } + ], + "additionalItems": false + }, + "func-concat": { + "title": "String concatenation function", + "description": "This function will concatenate strings or numbers, producing a final string as a result", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "concat" + } + ], + "additionalItems": { + "$ref": "#/definitions/string" + } + }, + "func-equals": { + "title": "Equals function", + "description": "This function compares two values (or expressions) for equality", + "type": "array", + "prefixItems": [ + { + "const": "equals" + }, + { + "$ref": "#/definitions/any" + }, + { + "$ref": "#/definitions/any" + } + ], + "additionalItems": false + }, + "func-notEquals": { + "title": "Not equals function", + "description": "This function compares two values (or expressions) for inequality", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "notEquals" + }, + { + "$ref": "#/definitions/any" + }, + { + "$ref": "#/definitions/any" + } + ], + "additionalItems": false + }, + "func-not": { + "title": "Not function", + "description": "This function inverts a boolean, returning true if given false, and vice versa.", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "not" + }, + { + "$ref": "#/definitions/boolean" + } + ], + "additionalItems": false + }, + "func-greaterThan": { + "title": "Greater than function", + "description": "This function compares two values (or expressions), returning true if the first argument is greater than the second", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "greaterThan" + }, + { + "$ref": "#/definitions/number" + }, + { + "$ref": "#/definitions/number" + } + ], + "additionalItems": false + }, + "func-greaterThanEq": { + "title": "Greater than or equals function", + "description": "This function compares two values (or expressions), returning true if the first argument is greater than or equals the second", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "greaterThanEq" + }, + { + "$ref": "#/definitions/number" + }, + { + "$ref": "#/definitions/number" + } + ], + "additionalItems": false + }, + "func-lessThan": { + "title": "Less than function", + "description": "This function compares two values (or expressions), returning true if the first argument is less than the second", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "lessThan" + }, + { + "$ref": "#/definitions/number" + }, + { + "$ref": "#/definitions/number" + } + ], + "additionalItems": false + }, + "func-lessThanEq": { + "title": "Less than or equals function", + "description": "This function compares two values (or expressions), returning true if the first argument is less than or equals the second", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "lessThanEq" + }, + { + "$ref": "#/definitions/number" + }, + { + "$ref": "#/definitions/number" + } + ], + "additionalItems": false + }, + "func-and": { + "title": "And combinator", + "description": "This function returns true if all the arguments (or expressions) are true", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "and" + }, + { + "$ref": "#/definitions/boolean" + } + ], + "additionalItems": { + "$ref": "#/definitions/boolean" + } + }, + "func-or": { + "title": "Or combinator", + "description": "This function returns true if any of the arguments (or expressions) are true", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "or" + }, + { + "$ref": "#/definitions/boolean" + } + ], + "additionalItems": { + "$ref": "#/definitions/boolean" + } + }, + "func-round": { + "title": "Round function", + "description": "This function rounds a number to the nearest integer, or to the specified number of decimals", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "round" + }, + { + "$ref": "#/definitions/number" + }, + { + "$ref": "#/definitions/number" + } + ], + "additionalItems": false + }, + "func-text": { + "title": "Text function", + "description": "This function retrieves the value of a text resource key, or returns the key if no text resource is found", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "text" + }, + { + "$ref": "#/definitions/string" + } + ], + "additionalItems": false + }, + "func-language": { + "title": "Language function", + "description": "This function retrieves the current language (usually 'nb', 'nn' or 'en')", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "language" + } + ], + "additionalItems": false + }, + "func-contains": { + "title": "Contains function", + "description": "This function checks if the first string contains the second string", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "contains" + }, + { + "$ref": "#/definitions/string" + }, + { + "$ref": "#/definitions/string" + } + ], + "additionalItems": false + }, + "func-notContains": { + "title": "Not contains function", + "description": "This function checks if the first string does not contain the second string", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "notContains" + }, + { + "$ref": "#/definitions/string" + }, + { + "$ref": "#/definitions/string" + } + ], + "additionalItems": false + }, + "func-startsWith": { + "title": "Starts with function", + "description": "This function checks if the first string starts with the second string", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "startsWith" + }, + { + "$ref": "#/definitions/string" + }, + { + "$ref": "#/definitions/string" + } + ], + "additionalItems": false + }, + "func-endsWith": { + "title": "Ends with function", + "description": "This function checks if the first string ends with the second string", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "endsWith" + }, + { + "$ref": "#/definitions/string" + }, + { + "$ref": "#/definitions/string" + } + ], + "additionalItems": false + }, + "func-stringLength": { + "title": "String length function", + "description": "This function returns the length of a string", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "stringLength" + }, + { + "$ref": "#/definitions/string" + } + ], + "additionalItems": false + }, + "func-commaContains": { + "title": "Comma contains function", + "description": "This function checks if the first comma-separated string contains the second string", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "commaContains" + }, + { + "$ref": "#/definitions/string" + }, + { + "$ref": "#/definitions/string" + } + ], + "additionalItems": false + }, + "func-lowerCase": { + "title": "Lower case function", + "description": "This function converts a string to lower case", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "lowerCase" + }, + { + "$ref": "#/definitions/string" + } + ], + "additionalItems": false + }, + "func-upperCase": { + "title": "Upper case function", + "description": "This function converts a string to upper case", + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "upperCase" + }, + { + "$ref": "#/definitions/string" + } + ], + "additionalItems": false + } + } +} diff --git a/frontend/packages/shared/src/utils/formValidationUtils/test-data/layout.schema.v1.json b/frontend/packages/shared/src/utils/formValidationUtils/test-data/layout.schema.v1.json new file mode 100644 index 00000000000..bcf71fbaadd --- /dev/null +++ b/frontend/packages/shared/src/utils/formValidationUtils/test-data/layout.schema.v1.json @@ -0,0 +1,1943 @@ +{ + "$id": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Altinn layout", + "description": "Schema that describes the layout configuration for Altinn applications.", + "type": "object", + "additionalProperties": true, + "properties": { + "data": { + "$ref": "#/definitions/data" + } + }, + "definitions": { + "data": { + "title": "The layout data", + "description": "Contains data describing the layout configuration.", + "type": "object", + "properties": { + "layout": { + "$ref": "#/definitions/layout" + } + } + }, + "layout": { + "title": "The layout", + "description": "Array of components to be presented in the layout.", + "type": "array", + "items": { + "$ref": "#/definitions/component" + } + }, + "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 ." + }, + "type": { + "type": "string", + "title": "Type", + "description": "The component type.", + "enum": [ + "ActionButton", + "AddressComponent", + "AttachmentList", + "Button", + "ButtonGroup", + "Checkboxes", + "Custom", + "Datepicker", + "Dropdown", + "FileUpload", + "FileUploadWithTag", + "Grid", + "Group", + "Header", + "Image", + "Input", + "InstanceInformation", + "InstantiationButton", + "IFrame", + "Likert", + "Link", + "List", + "MapComponent", + "MultipleSelect", + "NavigationButtons", + "NavigationBar", + "Panel", + "Paragraph", + "PrintButton", + "RadioButtons", + "Summary", + "TextArea" + ] + }, + "required": { + "title": "Required", + "description": "Boolean or expression indicating if the component is required when filling in the form. Defaults to false.", + "default": false, + "$ref": "expression.schema.v1.json#/definitions/boolean" + }, + "readOnly": { + "title": "Read only", + "description": "Boolean or expression indicating if the component should be presented as read only. Defaults to false.", + "default": false, + "$ref": "expression.schema.v1.json#/definitions/boolean" + }, + "renderAsSummary": { + "title": "Render as summary", + "description": "Boolean or expression indicating if the component should be rendered as a summary. Defaults to false.", + "default": false, + "$ref": "expression.schema.v1.json#/definitions/boolean" + }, + "hidden": { + "title": "Hidden", + "description": "Boolean value or expression indicating if the component should be hidden. Defaults to false.", + "default": false, + "$ref": "expression.schema.v1.json#/definitions/boolean" + }, + "textResourceBindings": { + "type": "object", + "title": "Text resource bindings", + "description": "Text resource bindings for a component.", + "additionalProperties": { + "$ref": "expression.schema.v1.json#/definitions/string" + }, + "examples": [ + { + "title": "some.text.binding", + "help": "some.other.text.binding" + } + ] + }, + "dataModelBindings": { + "type": "object", + "title": "Data model bindings", + "description": "Data model bindings for a component.", + "additionalProperties": { + "type": "string" + }, + "examples": [ + { + "simpleBinding": "some.data.binding" + } + ] + }, + "triggers": { + "$ref": "#/definitions/triggers" + }, + "labelSettings": { + "type": "object", + "title": "Label settings", + "description": "A collection of settings for how the component label should be rendered.", + "properties": { + "optionalIndicator": { + "type": "boolean", + "title": "Optional indicator", + "description": "Controls whether the text that is indicating that a field is optional should be displayed.", + "default": true + } + } + }, + "grid": { + "type": "object", + "title": "Grid", + "description": "Settings for the components grid. Used for controlling horizontal alignment.", + "$ref": "#/definitions/gridSettings", + "examples": [ + { + "xs": 12 + } + ] + }, + "pageBreak": { + "$ref": "#/definitions/pageBreak" + } + }, + "required": ["id", "type"], + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "ActionButton" + } + } + }, + "then": { + "$ref": "#/definitions/actionButtonComponent" + } + }, + { + "if": { + "properties": { + "type": { + "const": "AddressComponent" + } + } + }, + "then": { + "$ref": "#/definitions/addressComponent" + } + }, + { + "if": { + "properties": { + "type": { + "const": "AttachmentList" + } + } + }, + "then": { + "$ref": "#/definitions/attachmentListComponent" + } + }, + { + "if": { + "properties": { + "type": { + "const": "ButtonGroup" + } + } + }, + "then": { + "$ref": "#/definitions/buttonGroupComponent" + } + }, + { + "if": { + "properties": { + "type": { + "const": "Checkboxes" + } + } + }, + "then": { + "$ref": "#/definitions/radioAndCheckboxComponents" + } + }, + { + "if": { + "properties": { + "type": { + "const": "Custom" + } + } + }, + "then": { + "$ref": "#/definitions/customComponent" + } + }, + { + "if": { + "properties": { + "type": { + "const": "Datepicker" + } + } + }, + "then": { + "$ref": "#/definitions/datepickerComponent" + } + }, + { + "if": { + "properties": { + "type": { + "const": "Dropdown" + } + } + }, + "then": { + "$ref": "#/definitions/selectionComponents" + } + }, + { + "if": { + "properties": { + "type": { + "const": "FileUpload" + } + } + }, + "then": { + "$ref": "#/definitions/fileUploadComponent" + } + }, + { + "if": { + "properties": { + "type": { + "const": "FileUploadWithTag" + } + } + }, + "then": { + "$ref": "#/definitions/fileUploadWithTagComponent" + } + }, + { + "if": { + "properties": { + "type": { + "const": "Grid" + } + } + }, + "then": { + "$ref": "#/definitions/gridComponent" + } + }, + { + "if": { + "properties": { + "type": { + "const": "Group" + } + } + }, + "then": { + "$ref": "#/definitions/groupComponent" + } + }, + { + "if": { + "properties": { + "type": { + "const": "Image" + } + } + }, + "then": { + "$ref": "#/definitions/imageComponent" + } + }, + { + "if": { + "properties": { + "type": { + "const": "IFrame" + } + } + }, + "then": { + "$ref": "#/definitions/iframeComponent" + } + }, + { + "if": { + "properties": { + "type": { + "const": "Input" + } + } + }, + "then": { + "$ref": "#/definitions/inputComponent" + } + }, + { + "if": { + "properties": { + "type": { + "const": "TextArea" + } + } + }, + "then": { + "$ref": "#/definitions/textAreaComponent" + } + }, + { + "if": { + "properties": { + "type": { + "const": "InstanceInformation" + } + } + }, + "then": { + "$ref": "#/definitions/instanceInformationComponent" + } + }, + { + "if": { + "properties": { + "type": { + "const": "InstantiationButton" + } + } + }, + "then": { + "$ref": "#/definitions/instantiationButtonComponent" + } + }, + { + "if": { + "properties": { + "type": { + "const": "Likert" + } + } + }, + "then": { + "$ref": "#/definitions/radioAndCheckboxComponents" + } + }, + { + "if": { + "properties": { + "type": { + "const": "Link" + } + } + }, + "then": { + "$ref": "#/definitions/linkComponent" + } + }, + { + "if": { + "properties": { + "type": { + "const": "MultipleSelect" + } + } + }, + "then": { + "$ref": "#/definitions/selectionComponents" + } + }, + { + "if": { + "properties": { + "type": { + "const": "NavigationButtons" + } + } + }, + "then": { + "$ref": "#/definitions/navigationButtonsComponent" + } + }, + { + "if": { + "properties": { + "type": { + "const": "NavigationBar" + } + } + }, + "then": { + "$ref": "#/definitions/navigationBarComponent" + } + }, + { + "if": { + "properties": { + "type": { + "const": "RadioButtons" + } + } + }, + "then": { + "$ref": "#/definitions/radioAndCheckboxComponents" + } + }, + { + "if": { + "properties": { + "type": { + "const": "Summary" + } + } + }, + "then": { + "$ref": "#/definitions/summaryComponent" + } + }, + { + "if": { + "properties": { + "type": { + "const": "Header" + } + } + }, + "then": { + "$ref": "#/definitions/headerComponent" + } + }, + { + "if": { + "properties": { + "type": { + "const": "Panel" + } + } + }, + "then": { + "$ref": "#/definitions/panelComponent" + } + }, + { + "if": { + "properties": { + "type": { + "const": "List" + } + } + }, + "then": { + "$ref": "#/definitions/listComponent" + } + }, + { + "if": { + "properties": { + "type": { + "const": "MapComponent" + } + } + }, + "then": { + "$ref": "#/definitions/mapComponent" + } + } + ] + }, + "headerComponent": { + "properties": { + "size": { + "title": "Header size", + "description": "'L'=

    , 'M'=

    , 'S'=

    ", + "type": "string", + "enum": ["L", "M", "S", "h2", "h3", "h4"] + } + }, + "required": ["size"] + }, + "panelComponent": { + "properties": { + "variant": { + "title": "Panel variant", + "description": "Change the look of the panel.", + "type": "string", + "enum": ["info", "warning", "success"], + "default": "info" + }, + "showIcon": { + "title": "Show icon", + "description": "Boolean value indicating if the icon should be shown.", + "type": "boolean", + "default": true + } + } + }, + "fileUploadComponent": { + "properties": { + "maxFileSizeInMB": { + "title": "Maximum file size in MB", + "description": "Sets the maximum file size allowed in megabytes.", + "type": "integer", + "minimum": 0 + }, + "maxNumberOfAttachments": { + "title": "Maximum allowed attachments", + "description": "Sets the maximum number of attachments allowed to upload.", + "type": "integer", + "minimum": 0 + }, + "minNumberOfAttachments": { + "title": "Minimum allowed attachments", + "description": "Sets the minimum number of attachments to upload", + "type": "integer", + "minimum": 0 + }, + "displayMode": { + "title": "Display mode", + "description": "Sets the display mode for the file upload component.", + "type": "string", + "enum": ["simple", "list"] + }, + "hasCustomFileEndings": { + "title": "Has custom file endings", + "description": "Boolean value indicating if the component has valid file endings", + "type": "boolean" + }, + "validFileEndings": { + "title": "Valid file endings", + "description": "A separated string of valid file endings to upload. If not set all endings are accepted.", + "examples": [".csv", ".doc", ".docx", ".gif", ".jpeg", ".pdf", ".txt"], + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "displayMode", + "maxFileSizeInMB", + "maxNumberOfAttachments", + "minNumberOfAttachments" + ] + }, + "fileUploadWithTagComponent": { + "allOf": [ + { + "$ref": "#/definitions/fileUploadComponent" + } + ], + "properties": { + "optionsId": { + "type": "string", + "title": "Options ID", + "description": "Reference to connected options by id." + }, + "mapping": { + "$ref": "#/definitions/mapping", + "description": "Optionally used to map options" + } + }, + "required": ["optionsId"] + }, + "datepickerComponent": { + "properties": { + "minDate": { + "type": "string", + "title": "Minimum allowed date", + "description": "Sets the minimum allowed date. Can also use keyword 'today' to disable all past dates dynamically based on the current date. Defaults to 1900-01-01T12:00:00.000Z.", + "default": "1900-01-01T12:00:00.000Z" + }, + "maxDate": { + "type": "string", + "title": "Maximum allowed date", + "description": "Sets the maximum allowed date. Can also use keyword 'today' to disable all future dates dynamically based on the current date. Defaults to 2100-01-01T12:00:00.000Z.", + "default": "2100-01-01T12:00:00.000Z" + }, + "timeStamp": { + "type": "boolean", + "title": "Time stamp", + "description": "Boolean value indicating if the date time should be stored as a timeStamp. Defaults to true.\n If true: 'YYYY-MM-DDThh:mm:ss.sssZ', if false 'YYYY-MM-DD';", + "default": true + }, + "format": { + "type": "string", + "title": "Date format", + "description": "Long date format used when displaying the date to the user. The user date format from the locale will be prioritized over this setting.", + "examples": ["DD/MM/YYYY", "MM/DD/YYYY", "YYYY-MM-DD"], + "default": "DD.MM.YYYY" + } + }, + "required": [] + }, + "navigationButtonsComponent": { + "properties": { + "showBackButton": { + "type": "boolean", + "title": "Show back button", + "description": "Shows two buttons (back/next) instead of just 'next'." + } + } + }, + "navigationBarComponent": { + "properties": { + "compact": { + "type": "boolean", + "title": "Compact navbar menu", + "description": "Change appearance of navbar as compact in desktop view" + } + } + }, + "instanceInformationComponent": { + "properties": { + "elements": { + "title": "Instance information choices", + "description": "The properties to include in the instanceInformation summary", + "type": "object", + "properties": { + "dateSent": { + "title": "Date sent", + "description": "Date when the schema was sent.", + "type": "boolean", + "default": true + }, + "sender": { + "title": "Schema sender", + "description": "The sender of the schema.", + "type": "boolean", + "default": true + }, + "receiver": { + "title": "Schema receiver", + "description": "The receiver of the schema.", + "type": "boolean", + "default": true + }, + "referenceNumber": { + "title": "Schema reference number", + "description": "The reference number of the schema gathered from the instance Guid.", + "type": "boolean", + "default": true + } + } + } + } + }, + "instantiationButtonComponent": { + "properties": { + "mapping": { + "$ref": "#/definitions/mapping", + "description": "Creates a new app instance with data collected from a stateless part of the app." + } + } + }, + "mapComponent": { + "properties": { + "layers": { + "type": "object", + "title": "Layers", + "description": "Map layer", + "required": ["url"], + "properties": { + "url": { + "type": "string", + "title": "Map layer url", + "description": "Url to a map tile. {z}/{x}/{y} will be replaced with tile coordinates, {s} will be replaced with a random subdomain if subdomains are given" + }, + "attribution": { + "type": "string", + "title": "Attribution", + "description": "Ascribing a work or remark to a particular unit for recognition" + }, + "subdomains": { + "type": "array", + "title": "Subdomains", + "description": "List of subdomains. Used for balancing the load on different map tiling servers. A random one will replace {s} in the defined url.", + "items": { + "type": "string" + } + } + } + }, + "centerLocation": { + "type": "object", + "title": "Center location", + "description": "Center location of the map", + "properties": { + "latitude": { + "type": "number", + "title": "latitude", + "description": "Set the latitude coordinate" + }, + "longitude": { + "type": "number", + "title": "longitude", + "description": "Set the longitude coordinate" + } + } + }, + "zoom": { + "type": "number", + "title": "Zoom", + "description": "adjusts the default map-zoom" + } + } + }, + "gridValue": { + "type": "integer", + "maximum": 12, + "minimum": 1, + "examples": [12] + }, + "gridSettings": { + "allOf": [ + { + "$ref": "#/definitions/gridProps" + } + ], + "properties": { + "labelGrid": { + "title": "labelGrid", + "description": "Optional grid for the component label. Used in combination with innerGrid to align labels on the side.", + "examples": [ + { + "xs": 12 + } + ], + "$ref": "#/definitions/gridProps" + }, + "innerGrid": { + "title": "innerGrid", + "description": "Optional grid for inner component content like input field or dropdown. Used to avoid inner content filling the component width.", + "examples": [ + { + "xs": 12 + } + ], + "$ref": "#/definitions/gridProps" + } + } + }, + "gridProps": { + "properties": { + "xs": { + "$ref": "#/definitions/gridValue", + "title": "xs", + "description": "Grid breakpoint at 0px" + }, + "sm": { + "$ref": "#/definitions/gridValue", + "title": "sm", + "description": "Grid breakpoint at 600px" + }, + "md": { + "$ref": "#/definitions/gridValue", + "title": "md", + "description": "Grid breakpoint at 960px" + }, + "lg": { + "$ref": "#/definitions/gridValue", + "title": "lg", + "description": "Grid breakpoint at 1280px" + }, + "xl": { + "$ref": "#/definitions/gridValue", + "title": "xl", + "description": "Grid breakpoint at 1920px" + } + } + }, + "gridComponent": { + "properties": { + "rows": { + "title": "Rows", + "description": "An array of rows to be rendered in the grid.", + "type": "array", + "items": { + "$ref": "#/definitions/gridRow" + } + } + }, + "required": ["rows"] + }, + "gridRow": { + "properties": { + "header": { + "title": "Header?", + "description": "A boolean indicating if the row should be a header row", + "type": "boolean", + "default": false + }, + "readOnly": { + "title": "Read only?", + "description": "A boolean indicating if the row should be a read only row (yellow background)", + "type": "boolean", + "default": false + }, + "cells": { + "title": "Grid row cells", + "description": "An array of cells to be rendered in the row", + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/gridCellText" + }, + { + "$ref": "#/definitions/gridCellComponent" + }, + { + "$ref": "#/definitions/tableColumnOptions" + }, + { + "type": "null", + "title": "Empty cell" + } + ] + } + } + }, + "required": ["cells"] + }, + "gridCellText": { + "properties": { + "text": { + "title": "Text", + "description": "The text or text resource ID to be rendered in the cell", + "type": "string" + } + }, + "$ref": "#/definitions/tableColumnOptions", + "required": ["text"] + }, + "gridCellComponent": { + "properties": { + "component": { + "title": "Component ID", + "description": "The ID of the component to be rendered in the cell", + "type": "string" + } + }, + "$ref": "#/definitions/tableColumnOptions", + "required": ["component"] + }, + "buttonGroupComponent": { + "properties": { + "children": { + "title": "Children", + "description": "An array of the \"id\" of child components belonging to the button group.", + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + "required": ["children"] + }, + "groupComponent": { + "properties": { + "children": { + "title": "Children", + "description": "An array of the \"id\" of child components belonging to the group.", + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "hiddenRow": { + "title": "Hidden row", + "description": "Boolean to decide whether the row should be displayed.", + "$ref": "expression.schema.v1.json#/definitions/boolean" + }, + "edit": { + "title": "Edit", + "description": "Alternatives for edit view of repeating group", + "$ref": "#/definitions/groupEditOptions" + }, + "panel": { + "title": "Panel", + "description": "Alternatives for panel view of repeating group", + "$ref": "#/definitions/groupPanelOptions" + }, + "showGroupingIndicator": { + "title": "Show grouping indicator", + "description": "Boolean to decide whether a vertical line indicating grouping of fields should be visible. Only relevant for non-repeating groups.", + "type": "boolean", + "default": false + }, + "maxCount": { + "type": "integer", + "title": "Maximum count", + "description": "The maximum number of iterations of a group. Only relevant if group is repeating.", + "minimum": 0 + }, + "rowsBefore": { + "title": "Static rows before", + "description": "An array of rows to be rendered before the group table (using Grid component configuration)", + "type": "array", + "items": { + "$ref": "#/definitions/gridRow" + } + }, + "rowsAfter": { + "title": "Static rows after", + "description": "An array of rows to be rendered after the group table (using Grid component configuration)", + "type": "array", + "items": { + "$ref": "#/definitions/gridRow" + } + }, + "tableHeaders": { + "title": "Table Headers", + "description": "An array of the id of child components that should be included as table headers. If not defined all components are shown.", + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "tableColumns": { + "title": "Table Columns", + "description": "An object containing key-value pairs where the key is a table header and the value is an object containing settings for the headers column", + "type": "object", + "additionalProperties": { + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/tableColumnOptions" + }, + { + "type": "object", + "properties": { + "editInTable": { + "title": "Edit in table", + "description": "Boolean to decide whether the component should be editable in table view", + "default": false, + "type": "boolean" + }, + "showInExpandedEdit": { + "title": "Show in expanded edit", + "description": "Boolean to decide whether the component should be shown in the expanded edit view", + "default": true, + "type": "boolean" + } + } + } + ] + } + } + }, + "required": ["children"] + }, + "groupEditOptions": { + "properties": { + "mode": { + "title": "Edit mode", + "description": "Mode for how repeating group table is displayed in edit mode", + "type": "string", + "enum": ["hideTable", "likert", "showAll", "showTable", "onlyTable"] + }, + "filter": { + "title": "Filter", + "description": "Conditions for filtering visible items in repeating group", + "type": "array", + "items": { + "$ref": "#/definitions/groupFilterItem" + } + }, + "saveButton": { + "title": "Save button", + "description": "Boolean or expression indicating whether save button should be shown or not for a given row", + "$ref": "expression.schema.v1.json#/definitions/boolean" + }, + "saveAndNextButton": { + "title": "Save and open next button", + "description": "Boolean or expression indicating whether save and go to next button should be shown or not in addition to save and close button", + "$ref": "expression.schema.v1.json#/definitions/boolean" + }, + "deleteButton": { + "title": "Delete button", + "description": "Boolean or expression indicating whether delete button should be shown or not for a given row", + "$ref": "expression.schema.v1.json#/definitions/boolean" + }, + "editButton": { + "title": "Edit button", + "description": "Boolean or expression indicating whether edit button should be shown or not for a given row", + "$ref": "expression.schema.v1.json#/definitions/boolean" + }, + "multiPage": { + "title": "Multi-page", + "description": "Boolean value indicating if form components in edit mode should be shown over multiple pages/views.", + "type": "boolean" + }, + "addButton": { + "title": "Add button", + "description": "Boolean or expression indicating whether add new button should be shown or not under the table.", + "$ref": "expression.schema.v1.json#/definitions/boolean" + }, + "alwaysShowAddButton": { + "title": "Show add button on open group", + "description": "Boolean value indicating whether add new button should be shown or not under the table when a group is open.", + "type": "boolean", + "default": false + }, + "openByDefault": { + "title": "Open by default", + "description": "Boolean or string indicating if group should be opened by default. If no items exist: 'first', 'last', and true adds a new item. If items exist already, true does not open anything, but 'first' opens the first item, and 'last' opens the last item in the group.", + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": ["first", "last"] + } + ] + }, + "alertOnDelete": { + "title": "Alert on delete", + "description": "Boolean value indicating if warning popup should be displayed when attempting to delete a row", + "type": "boolean" + } + } + }, + "groupPanelOptions": { + "allOf": [ + { + "$ref": "#/definitions/panelComponent" + } + ], + "properties": { + "iconUrl": { + "title": "Icon url", + "description": "Url of the icon to be shown in panel. Can be relative if hosted by app or full if referencing a cdn or other hosting.", + "type": "string", + "examples": ["fancyIcon.svg", "https://cdn.example.com/fancyIcon.svg"] + }, + "iconAlt": { + "title": "Icon alt", + "description": "Alternative text for the icon. Only applicable if iconUrl is provided. Can be plain text or a text resource reference.", + "type": "string" + }, + "groupReference": { + "title": "Group reference", + "description": "Reference to the group that is being displayed in the panel. Used for referencing another repeating group context.", + "type": "object", + "properties": { + "group": { + "type": "string", + "title": "Group", + "description": "Group reference. Can be either the group id or the group data model binding.", + "examples": ["the-group-id", "some.model.theGroup"] + } + } + } + } + }, + "groupFilterItem": { + "properties": { + "key": { + "title": "Key", + "description": "Key representing field in data model to check.", + "type": "string" + }, + "value": { + "title": "Value", + "description": "Value to check against.", + "type": "string" + } + } + }, + "tableColumnOptions": { + "title": "Column options", + "description": "Column options for specified header.", + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/tableColumnTextOptions" + }, + { + "type": "object", + "properties": { + "width": { + "title": "Width", + "description": "Width of cell in % or 'auto'. Defaults to 'auto'", + "type": "string", + "pattern": "^([0-9]{1,2}%|100%|auto)$" + } + } + } + ] + }, + "tableColumnTextOptions": { + "properties": { + "alignText": { + "title": "Align Text", + "description": "Choose text alignment between 'left', 'center', or 'right' for text in table cells. Defaults to 'left' for text and 'right' for numbers.", + "type": "string", + "enum": ["left", "center", "right"] + }, + "textOverflow": { + "title": "Text Overflow", + "description": "Use this property to controll behaviour when text is too large to be displayed in table cell.", + "properties": { + "lineWrap": { + "title": "Line Wrap", + "description": "Toggle line wrapping on or off. Defaults to true", + "type": "boolean" + }, + "maxHeight": { + "title": "Max Height", + "description": "Determines the number of lines to display in table cell before hiding the rest of the text with an elipsis (...). Defaults to 2.", + "type": "number" + } + } + } + } + }, + "options": { + "properties": { + "label": { + "type": "string", + "title": "Label", + "description": "The option label. Can be plain text or a text resource binding." + }, + "value": { + "type": "string", + "title": "Value", + "description": "The option value." + }, + "description": { + "type": "string", + "title": "Description", + "description": "A description of the option displayed in Radio- and Checkbox groups. Can be plain text or a text resource binding." + }, + "helpText": { + "type": "string", + "title": "Help Text", + "description": "A help text for the option displayed in Radio- and Checkbox groups. Can be plain text or a text resource binding." + } + }, + "required": ["label", "value"] + }, + "triggers": { + "title": "Triggers", + "description": "An array of actions that should be triggered when data connected to this component changes.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "validation", + "validateRow", + "validatePage", + "validateAllPages", + "calculatePageOrder" + ] + } + }, + "selectionComponents": { + "properties": { + "optionsId": { + "type": "string", + "title": "Options ID", + "description": "Reference to connected options by id." + }, + "options": { + "type": "array", + "title": "Options", + "description": "An array of options. Only relevant if no optionsId is set.", + "items": { + "$ref": "#/definitions/options" + } + }, + "preselectedOptionIndex": { + "type": "integer", + "title": "Preselected option index", + "description": "Sets a preselected index.", + "minimum": 0 + }, + "secure": { + "type": "boolean", + "title": "Secure Options", + "description": "Boolean value indicating if the options should be instance aware. Defaults to false. See more on docs: https://docs.altinn.studio/app/development/data/options/" + }, + "source": { + "type": "object", + "title": "Source", + "description": "Object to define a data model source to be used as basis for options. Can not be used if options or optionId is set. See more on docs: https://docs.altinn.studio/app/development/data/options/", + "properties": { + "group": { + "type": "string", + "title": "Group", + "description": "The repeating group to base options on.", + "examples": ["model.some.group"] + }, + "label": { + "type": "string", + "title": "Label", + "description": "Reference to a text resource to be used as the option label.", + "examples": ["some.text.key"] + }, + "value": { + "type": "string", + "title": "Label", + "description": "Field in the group that should be used as value", + "examples": ["model.some.group[{0}].someField"] + }, + "description": { + "type": "string", + "title": "Description", + "description": "A description of the option displayed in Radio- and Checkbox groups. Can be plain text or a text resource binding.", + "examples": ["some.text.key", "My Description"] + }, + "helpText": { + "type": "string", + "title": "Help Text", + "description": "A help text for the option displayed in Radio- and Checkbox groups. Can be plain text or a text resource binding.", + "examples": ["some.text.key", "My Help Text"] + } + }, + "required": ["group", "label", "value"] + }, + "mapping": { + "$ref": "#/definitions/mapping", + "description": "Optionally used to map options" + }, + "autocomplete": { + "$ref": "#/definitions/autocomplete" + } + } + }, + "radioAndCheckboxComponents": { + "allOf": [ + { + "$ref": "#/definitions/selectionComponents" + } + ], + "properties": { + "layout": { + "type": "string", + "enum": ["column", "row", "table"], + "title": "Layout", + "description": "Define the layout style for the options" + } + } + }, + "linkComponent": { + "properties": { + "style": { + "type": "string", + "title": "Style", + "description": "The style of the button", + "enum": ["primary", "secondary", "link"] + }, + "openInNewTab": { + "type": "boolean", + "title": "Open in new tab", + "description": "Boolean value indicating if the link should be opened in a new tab. Defaults to false." + } + }, + "required": ["style"] + }, + "addressComponent": { + "properties": { + "simplified": { + "type": "boolean", + "title": "Simplified", + "description": "Boolean value indicating if the address component should be shown in simple mode.", + "default": false + }, + "saveWhileTyping": { + "$ref": "#/definitions/saveWhileTyping" + } + } + }, + "customComponent": { + "properties": { + "tagName": { + "type": "string", + "title": "Tag name", + "description": "Web component tag name to use" + } + }, + "required": ["tagName"] + }, + "actionButtonComponent": { + "properties": { + "action": { + "type": "string", + "title": "Action", + "description": "The action to be triggered when the button is clicked.", + "examples": ["sign, confirm, reject"] + }, + "buttonStyle": { + "type": "string", + "title": "Button style", + "description": "The style of the button.", + "enum": ["primary", "secondary"] + } + } + }, + "summaryComponent": { + "properties": { + "componentRef": { + "type": "string", + "title": "Component reference", + "description": "String value indicating which layout component (by ID) the summary is for." + }, + "pageRef": { + "type": "string", + "title": "Page reference", + "description": "String value indicating which layout page the referenced component is defined on." + }, + "largeGroup": { + "type": "boolean", + "title": "Large group", + "description": "Boolean value indicating if summary of repeating group should be displayed in large format. Useful for displaying summary with nested groups." + }, + "excludedChildren": { + "type": "array", + "title": "Excluded child components", + "description": "Array of component ids that should not be shown in a repeating group's summary" + }, + "display": { + "type": "object", + "title": "Display properties", + "description": "Optional properties to configure how summary is displayed", + "properties": { + "hideChangeButton": { + "type": "boolean", + "title": "Hide change button", + "description": "Set to true if the change button should be hidden for the summary component. False by default." + }, + "hideBottomBorder": { + "type": "boolean", + "title": "Hide bottom border", + "description": "Set to true to hide the blue dashed border below the summary component. False by default." + }, + "useComponentGrid": { + "type": "boolean", + "title": "Use component grid", + "description": "Set to true to allow summary component to use the grid setup of the referenced component. For group summary, this will apply for all group child components." + } + } + } + } + }, + "attachmentListComponent": { + "properties": { + "dataTypeIds": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Data type IDs", + "description": "List of data type IDs for the attachment list to show.", + "examples": [["SomeDataType", "SomeOtherDataType"]] + }, + "includePDF": { + "type": "boolean", + "title": "Include PDF as attachments", + "description": "Set the flag if the list of attachments should include PDF of answers.", + "default": false + } + } + }, + "imageComponent": { + "properties": { + "image": { + "type": "object", + "title": "Image properties", + "description": "Set of options for image field.", + "properties": { + "src": { + "title": "Image source", + "description": "", + "type": "object", + "properties": { + "nb": { + "type": "string", + "title": "Bokmål" + }, + "nn": { + "type": "string", + "title": "Nynorsk" + }, + "en": { + "type": "string", + "title": "English" + } + }, + "additionalProperties": true + }, + "width": { + "type": "string", + "title": "Image width", + "examples": ["100%"] + }, + "align": { + "type": "string", + "title": "Align image", + "enum": [ + "flex-start", + "center", + "flex-end", + "space-between", + "space-around", + "space-evenly" + ] + } + }, + "required": ["src", "width", "align"] + } + } + }, + "inputComponent": { + "properties": { + "formatting": { + "title": "Input formatting", + "description": "Set of options for formatting input fields.", + "$ref": "#/definitions/inputFormatting" + }, + "saveWhileTyping": { + "$ref": "#/definitions/saveWhileTyping" + }, + "variant": { + "type": "string", + "title": "Input Variant", + "description": "An enum to choose if the inputfield it is a normal textfield or a searchbar", + "enum": ["text", "search"] + }, + "autocomplete": { + "$ref": "#/definitions/autocomplete" + }, + "maxLength": { + "$ref": "#/definitions/maxLength" + } + } + }, + "textAreaComponent": { + "properties": { + "saveWhileTyping": { + "$ref": "#/definitions/saveWhileTyping" + }, + "autocomplete": { + "$ref": "#/definitions/autocomplete" + }, + "maxLength": { + "$ref": "#/definitions/maxLength" + } + } + }, + "saveWhileTyping": { + "title": "Automatic saving while typing", + "description": "Boolean or number. True = feature on (default), false = feature off (saves on focus blur), number = timeout in milliseconds (400 by default)", + "default": true, + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "number" + } + ] + }, + "maxLength": { + "title": "Maximum length", + "description": "Maximum length of input field", + "type": "number" + }, + "inputFormatting": { + "type": "object", + "properties": { + "currency": { + "type": "string", + "title": "Language-sensitive number formatting based on currency", + "description": "Enables currency along with thousand and decimal separators to be language sensitive based on selected app language. They are configured in number property. Note: parts that already exist in number property are not overridden by this prop.", + "enum": [ + "AED", + "AFN", + "ALL", + "AMD", + "ANG", + "AOA", + "ARS", + "AUD", + "AWG", + "AZN", + "BAM", + "BBD", + "BDT", + "BGN", + "BHD", + "BIF", + "BMD", + "BND", + "BOB", + "BOV", + "BRL", + "BSD", + "BTN", + "BWP", + "BYN", + "BZD", + "CAD", + "CDF", + "CHE", + "CHF", + "CHW", + "CLF", + "CLP", + "CNY", + "COP", + "COU", + "CRC", + "CUC", + "CUP", + "CVE", + "CZK", + "DJF", + "DKK", + "DOP", + "DZD", + "EGP", + "ERN", + "ETB", + "EUR", + "FJD", + "FKP", + "GBP", + "GEL", + "GHS", + "GIP", + "GMD", + "GNF", + "GTQ", + "GYD", + "HKD", + "HNL", + "HTG", + "HUF", + "IDR", + "ILS", + "INR", + "IQD", + "IRR", + "ISK", + "JMD", + "JOD", + "JPY", + "KES", + "KGS", + "KHR", + "KMF", + "KPW", + "KRW", + "KWD", + "KYD", + "KZT", + "LAK", + "LBP", + "LKR", + "LRD", + "LSL", + "LYD", + "MAD", + "MDL", + "MGA", + "MKD", + "MMK", + "MNT", + "MOP", + "MRU", + "MUR", + "MVR", + "MWK", + "MXN", + "MXV", + "MYR", + "MZN", + "NAD", + "NGN", + "NIO", + "NOK", + "NPR", + "NZD", + "OMR", + "PAB", + "PEN", + "PGK", + "PHP", + "PKR", + "PLN", + "PYG", + "QAR", + "RON", + "RSD", + "RUB", + "RWF", + "SAR", + "SBD", + "SCR", + "SDG", + "SEK", + "SGD", + "SHP", + "SLE", + "SLL", + "SOS", + "SRD", + "SSP", + "STN", + "SVC", + "SYP", + "SZL", + "THB", + "TJS", + "TMT", + "TND", + "TOP", + "TRY", + "TTD", + "TWD", + "TZS", + "UAH", + "UGX", + "USD", + "USN", + "UYI", + "UYU", + "UYW", + "UZS", + "VED", + "VES", + "VND", + "VUV", + "WST", + "XAF", + "XCD", + "XDR", + "XOF", + "XPF", + "XSU", + "XUA", + "YER", + "ZAR", + "ZMW", + "ZWL" + ] + }, + "unit": { + "title": "Language-sensitive number formatting based on unit", + "type": "string", + "description": "Enables unit along with thousand and decimal separators to be language sensitive based on selected app language. They are configured in number property. Note: parts that already exist in number property are not overridden by this prop.", + "enum": [ + "celsius", + "centimeter", + "day", + "degree", + "foot", + "gram", + "hectare", + "hour", + "inch", + "kilogram", + "kilometer", + "liter", + "meter", + "milliliter", + "millimeter", + "millisecond", + "minute", + "month", + "percent", + "second", + "week", + "year" + ] + }, + "position": { + "type": "string", + "title": "Position of the currency/unit symbol (only when using currency or unit options)", + "description": "Display the unit as prefix or suffix. Default is prefix", + "enum": ["prefix", "suffix"] + }, + "number": { + "$ref": "https://altinncdn.no/schemas/json/component/number-format.schema.v1.json" + }, + "align": { + "type": "string", + "title": "Align input", + "description": "The alignment for Input field (eg. right aligning a series of numbers).", + "enum": ["left", "center", "right"] + } + } + }, + "mapping": { + "type": "object", + "title": "Mapping", + "examples": [ + { + "some.source.field": "key1", + "some.other.source": "key2" + } + ], + "additionalProperties": { + "type": "string" + } + }, + "iframeComponent": { + "type": "object", + "properties": { + "sandbox": { + "type": "object", + "title": "Sandbox", + "description": "Controls the sandbox attribute on the iframe. See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#sandbox", + "properties": { + "allowPopups": { + "type": "boolean", + "title": "Allow popups", + "description": "Sets \"allow-popups\" in the sandbox attribute on the iframe. See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#sandbox", + "default": false + }, + "allowPopupsToEscapeSandbox": { + "type": "boolean", + "title": "Allow popups to escape sandbox", + "description": "Sets \"allow-popups-to-escape-sandbox\" in the sandbox attribute on the iframe. See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#sandbox", + "default": false + } + } + } + } + }, + "listComponent": { + "type": "object", + "properties": { + "tableHeaders": { + "type": "object", + "title": "Table Headers", + "examples": [ + { + "productId": "product.id", + "description": "Beskrivelse av produkt" + } + ], + "description": "An object where the fields in the datalist is mapped to headers. Must correspond to datalist representing a row. Can be added to the resource files to change between languages." + }, + "tableHeadersMobile": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Table Headers Mobile", + "description": "An array of strings representing the columns that is chosen to be shown in the mobile view." + }, + "sortableColumns": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Sortable Columns", + "description": "An array of the columns that is going to be sortable. The column has to be represented by the the headername that is written in tableHeaders." + }, + "pagination": { + "title": "Pagination", + "$ref": "#/definitions/paginationProperties" + }, + "dataListId": { + "type": "string", + "title": "List ID", + "description": "The Id of the list. This id is used to retrive the datalist from the backend." + }, + "secure": { + "type": "boolean", + "title": "Secure ListItems", + "description": "Boolean value indicating if the options should be instance aware. Defaults to false." + }, + "bindingToShowInSummary": { + "type": "string", + "title": "Binding to show in summary", + "description": "The value of this binding will be shown in the summary component for the list. This binding must be one of the specified bindings under dataModelBindings." + }, + "mapping": { + "$ref": "#/definitions/mapping", + "description": "(Optional) Used to map data model paths to query parameters when fetching list data." + } + }, + "required": ["dataListId"] + }, + "paginationProperties": { + "type": "object", + "properties": { + "alternatives": { + "type": "array", + "items": { + "type": "number" + }, + "title": "Alternatives", + "description": "List of page sizes the user can choose from. Make sure to test the performance of the largest number of items per page you are allowing." + }, + "default": { + "type": "number", + "title": "Default", + "description": "The pagination size that is set to default." + } + }, + "required": ["alternatives", "default"] + }, + "pageBreak": { + "type": "object", + "properties": { + "breakBefore": { + "title": "Page break before", + "description": "PDF only: Value or expression indicating whether a page break should be added before the component. Can be either: 'auto' (default), 'always', or 'avoid'.", + "examples": ["auto", "always", "avoid"], + "default": "auto", + "$ref": "expression.schema.v1.json#/definitions/string" + }, + "breakAfter": { + "title": "Page break after", + "description": "PDF only: Value or expression indicating whether a page break should be added after the component. Can be either: 'auto' (default), 'always', or 'avoid'.", + "examples": ["auto", "always", "avoid"], + "default": "auto", + "$ref": "expression.schema.v1.json#/definitions/string" + } + } + }, + "autocomplete": { + "title": "HTML attribute: autocomplete", + "description": "The HTML autocomplete attribute lets web developers specify what if any permission the user agent has to provide automated assistance in filling out form field values, as well as guidance to the browser as to the type of information expected in the field.", + "type": "string", + "enum": [ + "on", + "off", + "name", + "honorific-prefix", + "given-name", + "additional-name", + "family-name", + "honorific-suffix", + "nickname", + "email", + "username", + "new-password", + "current-password", + "one-time-code", + "organization-title", + "organization", + "street-address", + "address-line1", + "address-line2", + "address-line3", + "address-level4", + "address-level3", + "address-level2", + "address-level1", + "country", + "country-name", + "postal-code", + "cc-name", + "cc-given-name", + "cc-additional-name", + "cc-family-name", + "cc-number", + "cc-exp", + "cc-exp-month", + "cc-exp-year", + "cc-csc", + "cc-type", + "transaction-currency", + "transaction-amount", + "language", + "bday", + "bday-day", + "bday-month", + "bday-year", + "sex", + "tel", + "tel-country-code", + "tel-national", + "tel-area-code", + "tel-local", + "tel-extension", + "url", + "photo" + ] + } + } +} diff --git a/frontend/packages/shared/src/utils/formValidationUtils/test-data/number-format.schema.v1.json b/frontend/packages/shared/src/utils/formValidationUtils/test-data/number-format.schema.v1.json new file mode 100644 index 00000000000..608f11b1389 --- /dev/null +++ b/frontend/packages/shared/src/utils/formValidationUtils/test-data/number-format.schema.v1.json @@ -0,0 +1,169 @@ +{ + "$id": "https://altinncdn.no/schemas/json/component/number-format.schema.v1.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Input number formatting", + "description": "Schema that describes the options that can be configured for number formatting on an `input` component, based on react-number-format package. For complete list of available options, see https://s-yadav.github.io/react-number-format/docs/props", + "type": "object", + "additionalProperties": true, + "anyOf": [ + { + "properties": { + "allowedDecimalSeparators": { + "title": "Allowed decimal separators", + "description": "Characters which when pressed result in a decimal separator. When missing, decimalSeparator and '.' are used", + "type": "array", + "items": { + "type": "string", + "maxLength": 1 + }, + "examples": [[",", ".", "/"]] + }, + "allowEmptyFormatting": { + "type": "boolean", + "default": false + }, + "allowLeadingZeros": { + "title": "Allow leading zeros", + "description": "Allow leading zeros at beginning of number", + "type": "boolean", + "default": false + }, + "allowNegative": { + "title": "Allow negative", + "description": "Allow negative numbers (Only when format option is not provided)", + "type": "boolean", + "default": true + }, + "decimalScale": { + "title": "Decimal scale", + "description": "If defined it limits to given decimal scale.", + "type": "number", + "examples": [1, 2, 3] + }, + "decimalSeparator": { + "title": "Decimal separator", + "description": "Support decimal point on a number. Single character string.", + "type": "string", + "maxLength": 1, + "default": "." + }, + "fixedDecimalScale": { + "title": "Fixed decimal scale", + "description": "Used together with decimalScale. If true it adds 0s to match given decimal scale.", + "type": "boolean", + "default": false + }, + "format": { + "type": "boolean", + "default": false + }, + "mask": { + "type": "boolean", + "default": false + }, + "prefix": { + "title": "Prefix", + "description": "Add a prefix before the number", + "type": "string", + "examples": ["$", "kr", "-", "(+47) "] + }, + "suffix": { + "title": "Suffix", + "description": "Add a suffix after the number", + "type": "string", + "examples": ["%", "kr", "kg"] + }, + "thousandSeparator": { + "title": "Thousand separator", + "description": "Add thousand separators on number. Single character string or boolean true (true is default to ,)", + "type": ["string", "boolean"], + "maxLength": 1, + "examples": [true, ",", "."] + } + }, + "if": { + "required": ["fixedDecimalScale"], + "properties": { + "fixedDecimalScale": { + "const": true + } + } + }, + "then": { + "required": ["decimalScale"] + } + }, + { + "properties": { + "allowedDecimalSeparators": { + "type": "boolean", + "default": false + }, + "allowEmptyFormatting": { + "title": "Allow empty formatting", + "description": "Apply formatting to empty inputs", + "type": "boolean", + "default": false + }, + "allowLeadingZeros": { + "type": "boolean", + "default": false + }, + "allowNegative": { + "type": "boolean", + "default": false + }, + "decimalScale": { + "type": "boolean", + "default": false + }, + "decimalSeparator": { + "type": "boolean", + "default": false + }, + "fixedDecimalScale": { + "type": "boolean", + "default": false + }, + "format": { + "title": "Format", + "description": "Format given as hash string, to allow number input in place of hash.", + "type": "string", + "examples": ["### ### ###", "+47 ### ## ###", "##-##-##-##"] + }, + "mask": { + "title": "Mask", + "description": "Mask to show in place of non-entered values", + "type": "string", + "examples": ["_"], + "default": " " + }, + "prefix": { + "type": "boolean", + "default": false + }, + "suffix": { + "type": "boolean", + "default": false + }, + "thousandSeparator": { + "type": "boolean", + "default": false + } + }, + "if": { + "anyOf": [ + { + "required": ["mask"] + }, + { + "required": ["allowEmptyFormatting"] + } + ] + }, + "then": { + "required": ["format"] + } + } + ] +} diff --git a/frontend/packages/text-editor/src/TextEntry.test.tsx b/frontend/packages/text-editor/src/TextEntry.test.tsx index 4c8781c756c..96d44970bc0 100644 --- a/frontend/packages/text-editor/src/TextEntry.test.tsx +++ b/frontend/packages/text-editor/src/TextEntry.test.tsx @@ -1,8 +1,7 @@ import React from 'react'; -import { act, screen } from '@testing-library/react'; +import { act, screen, render } from '@testing-library/react'; import type { TextEntryProps } from './TextEntry'; import { TextEntry } from './TextEntry'; -import { renderWithMockStore } from '../../ux-editor/src/testing/mocks'; import userEvent from '@testing-library/user-event'; import { textMock } from '../../../testing/mocks/i18nMock'; @@ -15,13 +14,13 @@ describe('TextEntry', () => { afterEach(jest.clearAllMocks); it('should render the TextEntry component', () => { - render(); + renderTextEntry(); expect(screen.getByText('Hello')).toBeInTheDocument(); }); it("should not call upsertTextResource when textEntryValue is '' ", async () => { const user = userEvent.setup(); - render(); + renderTextEntry(); const inputText1 = screen.getByRole('textbox', { name: 'nb translation' }); await act(() => user.clear(inputText1)); expect(mockUpsertTextResource).toHaveBeenCalledTimes(0); @@ -29,7 +28,7 @@ describe('TextEntry', () => { it("should return nothing when textEntryValue is '' ", async () => { const user = userEvent.setup(); - render(); + renderTextEntry(); const inputText2 = screen.getByRole('textbox', { name: 'nb translation' }); await act(() => user.clear(inputText2)); expect(textEntryValue).toEqual(''); @@ -37,7 +36,7 @@ describe('TextEntry', () => { it('should toggle validation error message when textEntryValue changes from empty to has value', async () => { const user = userEvent.setup(); - render(); + renderTextEntry(); const inputText3 = screen.getByRole('textbox', { name: 'nb translation' }); await act(() => user.clear(inputText3)); expect(textId).toEqual(APP_NAME); @@ -48,7 +47,7 @@ describe('TextEntry', () => { it('shouls not display validation error message when textId equal to APP_NAME but textEntryValue is not empty', async () => { const user = userEvent.setup(); - render(); + renderTextEntry(); const inputText4 = screen.getByRole('textbox', { name: 'nb translation' }); await act(() => user.type(inputText4, 'Hello')); expect(textId).toEqual(APP_NAME); @@ -56,7 +55,7 @@ describe('TextEntry', () => { }); }); -const render = async (props: Partial = {}) => { +const renderTextEntry = async (props: Partial = {}) => { const allProps: TextEntryProps = { textId: 'appName', lang: 'nb', @@ -66,5 +65,5 @@ const render = async (props: Partial = {}) => { ...props, }; - return renderWithMockStore()(); + return render(); }; diff --git a/frontend/packages/ux-editor-v3/README.md b/frontend/packages/ux-editor-v3/README.md new file mode 100644 index 00000000000..7ef2881c2cb --- /dev/null +++ b/frontend/packages/ux-editor-v3/README.md @@ -0,0 +1 @@ +# Tjeneste 3.0 react POC diff --git a/frontend/packages/ux-editor-v3/jest.config.js b/frontend/packages/ux-editor-v3/jest.config.js new file mode 100644 index 00000000000..990bd442804 --- /dev/null +++ b/frontend/packages/ux-editor-v3/jest.config.js @@ -0,0 +1 @@ +module.exports = require('../../jest.config'); diff --git a/frontend/packages/ux-editor-v3/package.json b/frontend/packages/ux-editor-v3/package.json new file mode 100644 index 00000000000..1d667865ce5 --- /dev/null +++ b/frontend/packages/ux-editor-v3/package.json @@ -0,0 +1,36 @@ +{ + "name": "ux-editor-v3", + "description": "", + "version": "1.0.1", + "author": "Altinn", + "dependencies": { + "@mui/material": "5.15.5", + "@reduxjs/toolkit": "1.9.7", + "@studio/icons": "workspace:^", + "axios": "1.6.5", + "classnames": "2.5.1", + "react": "18.2.0", + "react-dnd": "16.0.1", + "react-dnd-html5-backend": "16.0.1", + "react-dom": "18.2.0", + "react-modal": "3.16.1", + "react-redux": "8.1.3", + "react-select": "5.8.0", + "redux": "4.2.1", + "reselect": "4.1.8", + "typescript": "5.3.3", + "uuid": "9.0.1" + }, + "devDependencies": { + "@redux-devtools/extension": "3.0.0", + "jest": "29.7.0" + }, + "license": "3-Clause BSD", + "main": "index.js", + "peerDependencies": { + "webpack": "5.89.0" + }, + "scripts": { + "test": "jest --maxWorkers=50%" + } +} diff --git a/frontend/packages/ux-editor-v3/src/App.test.tsx b/frontend/packages/ux-editor-v3/src/App.test.tsx new file mode 100644 index 00000000000..12c127639a0 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/App.test.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import { formLayoutSettingsMock, renderWithProviders } from './testing/mocks'; +import { App } from './App'; +import { textMock } from '../../../testing/mocks/i18nMock'; +import { typedLocalStorage } from 'app-shared/utils/webStorage'; +import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext'; +import { appStateMock } from './testing/stateMocks'; +import type { AppContextProps } from './AppContext'; +import ruleHandlerMock from './testing/ruleHandlerMock'; +import { layoutSetsMock } from './testing/layoutMock'; + +const { selectedLayoutSet } = appStateMock.formDesigner.layout; + +const renderApp = ( + queries: Partial = {}, + appContextProps: Partial = {}, +) => { + return renderWithProviders(, { + queries, + appContextProps, + }); +}; + +describe('App', () => { + it('should render the spinner', () => { + renderApp({}, { selectedLayoutSet }); + expect(screen.getByText(textMock('general.loading'))).toBeInTheDocument(); + }); + + it('should render the component', async () => { + const mockQueries: Partial = { + getInstanceIdForPreview: jest.fn().mockImplementation(() => Promise.resolve('test')), + getRuleModel: jest.fn().mockImplementation(() => Promise.resolve(ruleHandlerMock)), + getLayoutSets: jest.fn().mockImplementation(() => Promise.resolve(layoutSetsMock)), + getFormLayoutSettings: jest + .fn() + .mockImplementation(() => Promise.resolve(formLayoutSettingsMock)), + }; + renderApp(mockQueries, { selectedLayoutSet }); + await waitFor(() => + expect(screen.queryByText(textMock('general.loading'))).not.toBeInTheDocument(), + ); + }); + + it('Removes the preview layout set from local storage if it does not exist', async () => { + const removeSelectedLayoutSetMock = jest.fn(); + const layoutSetThatDoesNotExist = 'layout-set-that-does-not-exist'; + const mockQueries: Partial = { + getInstanceIdForPreview: jest.fn().mockImplementation(() => Promise.resolve('test')), + getRuleModel: jest.fn().mockImplementation(() => Promise.resolve(ruleHandlerMock)), + getLayoutSets: jest.fn().mockImplementation(() => Promise.resolve(layoutSetsMock)), + getFormLayoutSettings: jest + .fn() + .mockImplementation(() => Promise.resolve(formLayoutSettingsMock)), + }; + renderApp(mockQueries, { + selectedLayoutSet: layoutSetThatDoesNotExist, + removeSelectedLayoutSet: removeSelectedLayoutSetMock, + }); + await waitFor(() => + expect(screen.queryByText(textMock('general.loading'))).not.toBeInTheDocument(), + ); + expect(removeSelectedLayoutSetMock).toHaveBeenCalledTimes(1); + }); + + it('Does not remove the preview layout set from local storage if it exists', async () => { + const removeSelectedLayoutSetMock = jest.fn(); + const mockQueries: Partial = { + getInstanceIdForPreview: jest.fn().mockImplementation(() => Promise.resolve('test')), + getRuleModel: jest.fn().mockImplementation(() => Promise.resolve(ruleHandlerMock)), + getLayoutSets: jest.fn().mockImplementation(() => Promise.resolve(layoutSetsMock)), + getFormLayoutSettings: jest + .fn() + .mockImplementation(() => Promise.resolve(formLayoutSettingsMock)), + }; + jest.spyOn(typedLocalStorage, 'getItem').mockReturnValue(selectedLayoutSet); + renderApp(mockQueries, { + selectedLayoutSet, + removeSelectedLayoutSet: removeSelectedLayoutSetMock, + }); + await waitFor(() => + expect(screen.queryByText(textMock('general.loading'))).not.toBeInTheDocument(), + ); + expect(removeSelectedLayoutSetMock).not.toHaveBeenCalled(); + }); + + it('Renders the unsupported version message if the version is not supported', async () => { + renderApp( + { + getAppVersion: jest + .fn() + .mockImplementation(() => + Promise.resolve({ backendVersion: '7.15.1', frontendVersion: '4.0.0-rc1' }), + ), + }, + { selectedLayoutSet }, + ); + + expect( + await screen.findByText( + textMock('ux_editor.unsupported_version_message_title', { version: 'V4' }), + ), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/ux-editor-v3/src/App.tsx b/frontend/packages/ux-editor-v3/src/App.tsx new file mode 100644 index 00000000000..dbf33f9da21 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/App.tsx @@ -0,0 +1,104 @@ +import React, { useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { FormDesigner } from './containers/FormDesigner'; +import { useText } from './hooks'; +import { StudioPageSpinner } from '@studio/components'; +import { ErrorPage } from './components/ErrorPage'; +import { useDatamodelMetadataQuery } from './hooks/queries/useDatamodelMetadataQuery'; +import { selectedLayoutNameSelector } from './selectors/formLayoutSelectors'; +import { useWidgetsQuery } from './hooks/queries/useWidgetsQuery'; +import { useTextResourcesQuery } from 'app-shared/hooks/queries/useTextResourcesQuery'; +import { useLayoutSetsQuery } from './hooks/queries/useLayoutSetsQuery'; +import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; +import { useAppContext } from './hooks/useAppContext'; +import { FormContextProvider } from './containers/FormContext'; +import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; +import { UnsupportedVersionMessage } from './components/UnsupportedVersionMessage'; +import { useAppVersionQuery } from 'app-shared/hooks/queries/useAppVersionQuery'; + +/** + * This is the main React component responsible for controlling + * the mode of the application and loading initial data for the + * application + */ + +export function App() { + const t = useText(); + const { org, app } = useStudioUrlParams(); + const selectedLayout = useSelector(selectedLayoutNameSelector); + const { selectedLayoutSet, setSelectedLayoutSet, removeSelectedLayoutSet } = useAppContext(); + const { data: layoutSets, isSuccess: areLayoutSetsFetched } = useLayoutSetsQuery(org, app); + const { isSuccess: areWidgetsFetched, isError: widgetFetchedError } = useWidgetsQuery(org, app); + const { isSuccess: isDatamodelFetched, isError: dataModelFetchedError } = + useDatamodelMetadataQuery(org, app); + const { isSuccess: areTextResourcesFetched } = useTextResourcesQuery(org, app); + const { data: appVersion } = useAppVersionQuery(org, app); + + useEffect(() => { + if ( + areLayoutSetsFetched && + selectedLayoutSet && + (!layoutSets || !layoutSets.sets.map((set) => set.id).includes(selectedLayoutSet)) + ) + removeSelectedLayoutSet(); + }, [ + areLayoutSetsFetched, + layoutSets, + selectedLayoutSet, + setSelectedLayoutSet, + removeSelectedLayoutSet, + ]); + + const componentIsReady = areWidgetsFetched && isDatamodelFetched && areTextResourcesFetched; + + const componentHasError = dataModelFetchedError || widgetFetchedError; + + const mapErrorToDisplayError = (): { title: string; message: string } => { + const defaultTitle = t('general.fetch_error_title'); + const defaultMessage = t('general.fetch_error_message'); + + const createErrorMessage = (resource: string): { title: string; message: string } => ({ + title: `${defaultTitle} ${resource}`, + message: defaultMessage, + }); + + if (dataModelFetchedError) { + return createErrorMessage(t('general.dataModel')); + } + if (widgetFetchedError) { + return createErrorMessage(t('general.widget')); + } + + return createErrorMessage(t('general.unknown_error')); + }; + + useEffect(() => { + if (selectedLayoutSet === null && layoutSets) { + // Only set layout set if layout sets exists and there is no layout set selected yet + setSelectedLayoutSet(layoutSets.sets[0].id); + } + }, [setSelectedLayoutSet, selectedLayoutSet, layoutSets, app]); + + if ( + appVersion?.frontendVersion?.startsWith('4') && + !shouldDisplayFeature('shouldOverrideAppFrontendCheck') + ) { + return ( + + ); + } + + if (componentHasError) { + const mappedError = mapErrorToDisplayError(); + return ; + } + + if (componentIsReady) { + return ( + + + + ); + } + return ; +} diff --git a/frontend/packages/ux-editor-v3/src/AppContext.ts b/frontend/packages/ux-editor-v3/src/AppContext.ts new file mode 100644 index 00000000000..eaf0fedb330 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/AppContext.ts @@ -0,0 +1,11 @@ +import type { RefObject } from 'react'; +import { createContext } from 'react'; + +export interface AppContextProps { + previewIframeRef: RefObject; + selectedLayoutSet: string; + setSelectedLayoutSet: (layoutSet: string) => void; + removeSelectedLayoutSet: () => void; +} + +export const AppContext = createContext(null); diff --git a/frontend/packages/ux-editor-v3/src/SubApp.test.tsx b/frontend/packages/ux-editor-v3/src/SubApp.test.tsx new file mode 100644 index 00000000000..628baeff97e --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/SubApp.test.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react'; +import React from 'react'; +import { SubApp } from './SubApp'; +import { render, screen, within } from '@testing-library/react'; + +const providerTestId = 'provider'; +const appTestId = 'app'; +jest.mock('./AppContext', () => ({ + AppContext: { + Provider: ({ children }: { children: ReactNode }) => { + return
    {children}
    ; + }, + }, +})); +jest.mock('./App', () => ({ + App: () => { + return
    App
    ; + }, +})); + +describe('SubApp', () => { + it('Renders the app within the AppContext provider', () => { + render(); + const provider = screen.getByTestId(providerTestId); + expect(provider).toBeInTheDocument(); + expect(within(provider).getByTestId(appTestId)).toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/ux-editor-v3/src/SubApp.tsx b/frontend/packages/ux-editor-v3/src/SubApp.tsx new file mode 100644 index 00000000000..d2288698d6b --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/SubApp.tsx @@ -0,0 +1,32 @@ +import React, { useRef } from 'react'; +import { Provider } from 'react-redux'; +import { App } from './App'; +import { setupStore } from './store'; +import './styles/index.css'; +import { AppContext } from './AppContext'; +import { useReactiveLocalStorage } from 'app-shared/hooks/useReactiveLocalStorage'; +import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; + +const store = setupStore(); + +export const SubApp = () => { + const previewIframeRef = useRef(null); + const { app } = useStudioUrlParams(); + const [selectedLayoutSet, setSelectedLayoutSet, removeSelectedLayoutSet] = + useReactiveLocalStorage('layoutSet/' + app, null); + + return ( + + + + + + ); +}; diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/ConfPageToolbar.tsx b/frontend/packages/ux-editor-v3/src/components/Elements/ConfPageToolbar.tsx new file mode 100644 index 00000000000..b161961e62d --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/ConfPageToolbar.tsx @@ -0,0 +1,44 @@ +import React, { useState } from 'react'; +import type { ComponentType } from 'app-shared/types/ComponentType'; +import type { IToolbarElement } from '../../types/global'; +import { InformationPanelComponent } from '../toolbar/InformationPanelComponent'; +import { ToolbarItem } from './ToolbarItem'; +import { confOnScreenComponents } from '../../data/formItemConfig'; +import { getComponentTitleByComponentType } from '../../utils/language'; +import { mapComponentToToolbarElement } from '../../utils/formLayoutUtils'; +import { useTranslation } from 'react-i18next'; + +export const ConfPageToolbar = () => { + const [anchorElement, setAnchorElement] = useState(null); + const [compSelForInfoPanel, setCompSelForInfoPanel] = useState(null); + const { t } = useTranslation(); + const componentList: IToolbarElement[] = confOnScreenComponents.map(mapComponentToToolbarElement); + const handleComponentInformationOpen = (component: ComponentType, event: any) => { + setCompSelForInfoPanel(component); + setAnchorElement(event.currentTarget); + }; + + const handleComponentInformationClose = () => { + setCompSelForInfoPanel(null); + setAnchorElement(null); + }; + return ( + <> + {componentList.map((component: IToolbarElement) => ( + + ))} + + + ); +}; diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/ConfigureLayoutSetPanel.module.css b/frontend/packages/ux-editor-v3/src/components/Elements/ConfigureLayoutSetPanel.module.css new file mode 100644 index 00000000000..7ed59ccfaa2 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/ConfigureLayoutSetPanel.module.css @@ -0,0 +1,25 @@ +.configureLayoutSetButton { + text-align: left; +} + +.configureLayoutSet { + display: flex; + margin: 10px; +} + +.configureLayoutSetInfo { + max-width: 500px; + border-radius: 20px; + padding: 10px; +} + +.informationButton { + width: 25px; + height: 25px; + margin-top: 5px; +} + +.label { + display: block; + margin-bottom: 8px; +} diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/ConfigureLayoutSetPanel.tsx b/frontend/packages/ux-editor-v3/src/components/Elements/ConfigureLayoutSetPanel.tsx new file mode 100644 index 00000000000..d37706a75d9 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/ConfigureLayoutSetPanel.tsx @@ -0,0 +1,167 @@ +import type { ChangeEvent, KeyboardEvent, MouseEvent } from 'react'; +import React, { useEffect, useState, useRef, useCallback, useId } from 'react'; +import { useTranslation, Trans } from 'react-i18next'; +import classes from './ConfigureLayoutSetPanel.module.css'; +import { useConfigureLayoutSetMutation } from '../../hooks/mutations/useConfigureLayoutSetMutation'; +import { Paragraph, Textfield } from '@digdir/design-system-react'; +import { Popover } from '@mui/material'; +import { InformationIcon } from '@navikt/aksel-icons'; +import { altinnDocsUrl } from 'app-shared/ext-urls'; +import { validateLayoutNameAndLayoutSetName } from '../../utils/validationUtils/validateLayoutNameAndLayoutSetName'; +import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; +import { StudioButton } from '@studio/components'; + +export const ConfigureLayoutSetPanel = () => { + const inputLayoutSetNameId = useId(); + const { org, app } = useStudioUrlParams(); + const { t } = useTranslation(); + const configureLayoutSetMutation = useConfigureLayoutSetMutation(org, app); + const [anchorEl, setAnchorEl] = useState(null); + const [popoverOpen, setPopoverOpen] = useState(false); + const [layoutSetName, setLayoutSetName] = useState(''); + const [editLayoutSetName, setEditLayoutSetName] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const configPanelRef = useRef(null); + + const handleConfigureLayoutSet = async (): Promise => { + if (layoutSetName === '') { + setErrorMessage(t('left_menu.pages_error_empty')); + } else { + await configureLayoutSetMutation.mutateAsync({ layoutSetName }); + } + }; + + const handleTogglePopOver = (event?: MouseEvent): void => { + setAnchorEl(event ? event.currentTarget : null); + setPopoverOpen(!!event); + }; + + const handleKeyPress = (event: KeyboardEvent) => { + const shouldSave = event.key === 'Enter'; + if (shouldSave) { + handleConfigureLayoutSet(); + setEditLayoutSetName(false); + return; + } + + const shouldCancel = event.key === 'Escape'; + if (shouldCancel) { + closePanelAndResetLayoutSetName(); + } + }; + + const handleClickOutside = useCallback((event: Event): void => { + const target = event.target as HTMLElement; + + // If the click is outside the configPanelRef, close the panel and reset the layoutSetName + if (!configPanelRef.current?.contains(target)) { + closePanelAndResetLayoutSetName(); + } + }, []); + + const closePanelAndResetLayoutSetName = (): void => { + setEditLayoutSetName(false); + setLayoutSetName(''); + }; + + useEffect(() => { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [handleClickOutside]); + + const toggleConfigureLayoutSetName = (): void => { + setEditLayoutSetName((prevEditLayoutSetName) => !prevEditLayoutSetName); + }; + + const handleOnNameChange = (event: ChangeEvent): void => { + // The Regex below replaces all illegal characters with a dash + const newNameCandidate = event.target.value.replace(/[/\\?%*:|"<>]/g, '-').trim(); + + const error = validateLayoutSetName(newNameCandidate); + + if (error) { + setErrorMessage(error); + return; + } + + setErrorMessage(''); + setLayoutSetName(newNameCandidate); + }; + + const validateLayoutSetName = (newLayoutSetName?: string): string | null => { + if (!newLayoutSetName) { + return t('left_menu.pages_error_empty'); + } + + if (newLayoutSetName.length >= 30) { + return t('left_menu.pages_error_length'); + } + + if (!validateLayoutNameAndLayoutSetName(newLayoutSetName)) { + return t('left_menu.pages_error_format'); + } + return null; + }; + + return ( +
    + {editLayoutSetName ? ( +
    + + + {errorMessage && ( + + {errorMessage} + + )} +
    + ) : ( + + {t('left_menu.configure_layout_sets')} + + )} +
    + +
    + {popoverOpen && ( + + handleTogglePopOver()} + > + + + + + + )} +
    + ); +}; diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/DefaultToolbar.css b/frontend/packages/ux-editor-v3/src/components/Elements/DefaultToolbar.css new file mode 100644 index 00000000000..9b853eee1d2 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/DefaultToolbar.css @@ -0,0 +1,16 @@ +.a-item { + display: flex; + user-select: none; + padding: 0.5rem !important; + margin: 0 0 0.5rem 0 !important; + align-items: flex-start; + align-content: flex-start; + line-height: 1.5; + border-radius: 3px; + background: #fff; + border: 1px solid #ddd; +} + +.a-item + .a-item-clone { + display: none !important; +} diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/DefaultToolbar.module.css b/frontend/packages/ux-editor-v3/src/components/Elements/DefaultToolbar.module.css new file mode 100644 index 00000000000..40b5307cef6 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/DefaultToolbar.module.css @@ -0,0 +1,12 @@ +.accordionItem > div { + border-bottom: none !important; +} + +.accordionHeader > button { + border: none; + padding: var(--fds-spacing-3) var(--fds-spacing-5); +} + +.accordionContent { + padding: 0; +} diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/DefaultToolbar.tsx b/frontend/packages/ux-editor-v3/src/components/Elements/DefaultToolbar.tsx new file mode 100644 index 00000000000..d0ca40d4079 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/DefaultToolbar.tsx @@ -0,0 +1,91 @@ +import React, { useState } from 'react'; +import type { ComponentType } from 'app-shared/types/ComponentType'; +import type { IToolbarElement } from '../../types/global'; +import { CollapsableMenus } from '../../types/global'; +import { InformationPanelComponent } from '../toolbar/InformationPanelComponent'; +import { mapComponentToToolbarElement } from '../../utils/formLayoutUtils'; +import './DefaultToolbar.css'; +import classes from './DefaultToolbar.module.css'; +import { useTranslation } from 'react-i18next'; +import { schemaComponents, textComponents, advancedItems } from '../../data/formItemConfig'; +import type { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; +import { Accordion } from '@digdir/design-system-react'; +import { getCollapsableMenuTitleByType } from '../../utils/language'; +import { ToolbarItem } from './ToolbarItem'; +import { getComponentTitleByComponentType } from '../../utils/language'; + +export function DefaultToolbar() { + const [compInfoPanelOpen, setCompInfoPanelOpen] = useState(false); + const [compSelForInfoPanel, setCompSelForInfoPanel] = useState(null); + const [anchorElement, setAnchorElement] = useState(null); + + const { t } = useTranslation(); + // TODO: Uncomment when widgets are implemented + // const { org, app } = useParams(); + // const { data: widgetsList } = useWidgetsQuery(org, app); + + const componentList: IToolbarElement[] = schemaComponents.map(mapComponentToToolbarElement); + const textComponentList: IToolbarElement[] = textComponents.map(mapComponentToToolbarElement); + const advancedComponentsList: IToolbarElement[] = advancedItems.map(mapComponentToToolbarElement); + // TODO: Uncomment when widgets are implemented + // const widgetComponentsList: IToolbarElement[] = widgetsList.map( + // (widget) => mapWidgetToToolbarElement(widget, t) + // ); + + const allComponentLists: KeyValuePairs = { + [CollapsableMenus.Components]: componentList, + [CollapsableMenus.Texts]: textComponentList, + [CollapsableMenus.AdvancedComponents]: advancedComponentsList, + // TODO: Uncomment when widgets are implemented + // [CollapsableMenus.Widgets]: widgetComponentsList, + // [CollapsableMenus.ThirdParty]: thirdPartyComponentList, + }; + + const handleComponentInformationOpen = (component: ComponentType, event: any) => { + setCompInfoPanelOpen(true); + setCompSelForInfoPanel(component); + setAnchorElement(event.currentTarget); + }; + + const handleComponentInformationClose = () => { + setCompInfoPanelOpen(false); + setCompSelForInfoPanel(null); + setAnchorElement(null); + }; + + return ( + <> + {Object.values(CollapsableMenus).map((key: CollapsableMenus) => { + return ( + + + + {getCollapsableMenuTitleByType(key, t)} + + + {allComponentLists[key].map((component: IToolbarElement) => ( + + ))} + + + + ); + })} + + + ); +} diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/Elements.module.css b/frontend/packages/ux-editor-v3/src/components/Elements/Elements.module.css new file mode 100644 index 00000000000..c51c270534a --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/Elements.module.css @@ -0,0 +1,23 @@ +.root { + background: var(--fds-semantic-surface-neutral-subtle); + flex: var(--elements-width-fraction); +} + +.pagesContent { + padding: var(--fds-spacing-2); +} + +.pagesContent .addButton { + padding-bottom: var(--fds-spacing-3); + padding-top: var(--fds-spacing-2); +} + +.componentsHeader { + margin: var(--fds-spacing-3); + padding-bottom: 10px; + border-bottom: 2px solid var(--semantic-surface-neutral-subtle-hover); +} + +.noPageSelected { + padding-inline: var(--fds-spacing-3); +} diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/Elements.tsx b/frontend/packages/ux-editor-v3/src/components/Elements/Elements.tsx new file mode 100644 index 00000000000..47cccec0cd0 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/Elements.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { ConfPageToolbar } from './ConfPageToolbar'; +import { DefaultToolbar } from './DefaultToolbar'; +import { Heading, Paragraph } from '@digdir/design-system-react'; +import { useText } from '../../hooks'; +import { selectedLayoutNameSelector } from '../../selectors/formLayoutSelectors'; +import { useFormLayoutSettingsQuery } from '../../hooks/queries/useFormLayoutSettingsQuery'; +import { useLayoutSetsQuery } from '../../hooks/queries/useLayoutSetsQuery'; +import { LayoutSetsContainer } from './LayoutSetsContainer'; +import { ConfigureLayoutSetPanel } from './ConfigureLayoutSetPanel'; +import { Accordion } from '@digdir/design-system-react'; +import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; +import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; +import classes from './Elements.module.css'; +import { useAppContext } from '../../hooks/useAppContext'; + +export const Elements = () => { + const { org, app } = useStudioUrlParams(); + const selectedLayout: string = useSelector(selectedLayoutNameSelector); + const { selectedLayoutSet } = useAppContext(); + const layoutSetsQuery = useLayoutSetsQuery(org, app); + const { data: formLayoutSettings } = useFormLayoutSettingsQuery(org, app, selectedLayoutSet); + const receiptName = formLayoutSettings?.receiptLayoutName; + const layoutSetNames = layoutSetsQuery?.data?.sets; + + const hideComponents = selectedLayout === 'default' || selectedLayout === undefined; + + const t = useText(); + + return ( +
    + {shouldDisplayFeature('configureLayoutSet') && layoutSetNames ? ( + + ) : ( + + )} + + {shouldDisplayFeature('configureLayoutSet') && ( + 0}> + {t('left_menu.layout_sets')} + + {layoutSetNames ? : } + + + )} + +
    + + {t('left_menu.components')} + + {hideComponents ? ( + + {t('left_menu.no_components_selected')} + + ) : receiptName === selectedLayout ? ( + + ) : ( + + )} +
    +
    + ); +}; diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/LayoutSetsContainer.module.css b/frontend/packages/ux-editor-v3/src/components/Elements/LayoutSetsContainer.module.css new file mode 100644 index 00000000000..ebe13d456a7 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/LayoutSetsContainer.module.css @@ -0,0 +1,4 @@ +.dropDownContainer { + margin: var(--fds-spacing-5); + margin-bottom: 5px; +} diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/LayoutSetsContainer.test.tsx b/frontend/packages/ux-editor-v3/src/components/Elements/LayoutSetsContainer.test.tsx new file mode 100644 index 00000000000..6bb626a81b4 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/LayoutSetsContainer.test.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { act, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { LayoutSetsContainer } from './LayoutSetsContainer'; +import { queryClientMock } from 'app-shared/mocks/queryClientMock'; +import { renderWithMockStore } from '../../testing/mocks'; +import { layoutSetsMock } from '../../testing/layoutMock'; +import type { AppContextProps } from '../../AppContext'; +import { appStateMock } from '../../testing/stateMocks'; +import { QueryKey } from 'app-shared/types/QueryKey'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: jest.fn(), +})); +// Test data +const org = 'org'; +const app = 'app'; +const layoutSetName1 = layoutSetsMock.sets[0].id; +const layoutSetName2 = layoutSetsMock.sets[1].id; +const { selectedLayoutSet } = appStateMock.formDesigner.layout; +const setSelectedLayoutSetMock = jest.fn(); + +describe('LayoutSetsContainer', () => { + it('renders component', async () => { + render(); + + expect(await screen.findByRole('option', { name: layoutSetName1 })).toBeInTheDocument(); + expect(await screen.findByRole('option', { name: layoutSetName2 })).toBeInTheDocument(); + }); + + it('NativeSelect should be rendered', async () => { + render(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('Should update selected layout set when set is clicked in native select', async () => { + render(); + const user = userEvent.setup(); + await act(() => user.selectOptions(screen.getByRole('combobox'), layoutSetName2)); + expect(setSelectedLayoutSetMock).toHaveBeenCalledTimes(1); + }); +}); + +const render = () => { + queryClientMock.setQueryData([QueryKey.LayoutSets, org, app], layoutSetsMock); + const appContextProps: Partial = { + selectedLayoutSet: selectedLayoutSet, + setSelectedLayoutSet: setSelectedLayoutSetMock, + }; + return renderWithMockStore({}, {}, undefined, appContextProps)(); +}; diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/LayoutSetsContainer.tsx b/frontend/packages/ux-editor-v3/src/components/Elements/LayoutSetsContainer.tsx new file mode 100644 index 00000000000..9883442b1a4 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/LayoutSetsContainer.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { useLayoutSetsQuery } from '../../hooks/queries/useLayoutSetsQuery'; +import { NativeSelect } from '@digdir/design-system-react'; +import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; +import { useText } from '../../hooks'; +import classes from './LayoutSetsContainer.module.css'; +import { useAppContext } from '../../hooks/useAppContext'; + +export function LayoutSetsContainer() { + const { org, app } = useStudioUrlParams(); + const layoutSetsQuery = useLayoutSetsQuery(org, app); + const layoutSetNames = layoutSetsQuery.data?.sets?.map((set) => set.id); + const t = useText(); + const { selectedLayoutSet, setSelectedLayoutSet } = useAppContext(); + + const onLayoutSetClick = (set: string) => { + if (selectedLayoutSet !== set) { + setSelectedLayoutSet(set); + } + }; + + if (!layoutSetNames) return null; + + return ( +
    + onLayoutSetClick(event.target.value)} + value={selectedLayoutSet} + > + {layoutSetNames.map((set: string) => { + return ( + + ); + })} + +
    + ); +} diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/PageElement.module.css b/frontend/packages/ux-editor-v3/src/components/Elements/PageElement.module.css new file mode 100644 index 00000000000..e4743b774f0 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/PageElement.module.css @@ -0,0 +1,44 @@ +.elementContainer { + display: flex; + align-items: center; +} + +.selected .elementContainer { + font-weight: 700; +} + +.pageContainer { + flex: 1; +} + +.pageButton { + cursor: pointer; + padding: var(--fds-spacing-3); +} + +.selected .pageButton { + font-weight: 700; +} + +.pageField { + padding: var(--fds-spacing-1); +} + +.ellipsisButton { + margin-left: 1.2rem; + visibility: hidden; +} + +.elementContainer:hover .ellipsisButton { + visibility: visible; +} + +.errorMessage { + font-size: 13px; + font-weight: 400; + padding-top: 6px; +} + +.invalid { + color: red; +} diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/ToolbarItem.tsx b/frontend/packages/ux-editor-v3/src/components/Elements/ToolbarItem.tsx new file mode 100644 index 00000000000..083fa43d458 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/ToolbarItem.tsx @@ -0,0 +1,34 @@ +import type { MouseEvent } from 'react'; +import React from 'react'; +import { ToolbarItemComponent } from '../toolbar/ToolbarItemComponent'; +import type { ComponentType } from 'app-shared/types/ComponentType'; +import { DragAndDropTree } from 'app-shared/components/DragAndDropTree'; + +interface IToolbarItemProps { + text: string; + notDraggable?: boolean; + onClick: (type: ComponentType, event: MouseEvent) => void; + componentType: ComponentType; + icon?: React.ComponentType; +} + +export const ToolbarItem = ({ + notDraggable, + componentType, + onClick, + text, + icon, +}: IToolbarItemProps) => { + return ( +
    + notDraggable={notDraggable} payload={componentType}> + + +
    + ); +}; diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/index.ts b/frontend/packages/ux-editor-v3/src/components/Elements/index.ts new file mode 100644 index 00000000000..447fae37844 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/index.ts @@ -0,0 +1 @@ +export { Elements } from './Elements'; diff --git a/frontend/packages/ux-editor-v3/src/components/ErrorPage/ErrorPage.module.css b/frontend/packages/ux-editor-v3/src/components/ErrorPage/ErrorPage.module.css new file mode 100644 index 00000000000..672039db6f4 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/ErrorPage/ErrorPage.module.css @@ -0,0 +1,3 @@ +.container { + padding: 18px; +} diff --git a/frontend/packages/ux-editor-v3/src/components/ErrorPage/ErrorPage.tsx b/frontend/packages/ux-editor-v3/src/components/ErrorPage/ErrorPage.tsx new file mode 100644 index 00000000000..2d29c0a2eb6 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/ErrorPage/ErrorPage.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import classes from './ErrorPage.module.css'; + +type ErrorPageProps = { + title: string; + message: string; +}; +export const ErrorPage = ({ title, message }: ErrorPageProps): JSX.Element => { + return ( +
    +

    {title}

    +

    {message}

    +
    + ); +}; diff --git a/frontend/packages/ux-editor-v3/src/components/ErrorPage/index.ts b/frontend/packages/ux-editor-v3/src/components/ErrorPage/index.ts new file mode 100644 index 00000000000..6e8d01e81e3 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/ErrorPage/index.ts @@ -0,0 +1 @@ +export { ErrorPage } from './ErrorPage'; diff --git a/frontend/packages/ux-editor-v3/src/components/FormComponent/DragHandle.module.css b/frontend/packages/ux-editor-v3/src/components/FormComponent/DragHandle.module.css new file mode 100644 index 00000000000..648a18eec0a --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/FormComponent/DragHandle.module.css @@ -0,0 +1,24 @@ +.handle { + --point-size: 3px; + border-width: 0; + width: var(--drag-handle-inner-width, var(--drag-handle-width, 25px)); + display: flex; + align-items: center; + justify-content: center; + cursor: move; + height: 100%; +} + +.points { + display: grid; + grid-template: var(--point-size) / var(--point-size) var(--point-size); + gap: var(--point-size); + margin: auto; +} + +.points span { + background: #00000040; + width: var(--point-size); + height: var(--point-size); + border-radius: 50%; +} diff --git a/frontend/packages/ux-editor-v3/src/components/FormComponent/DragHandle.tsx b/frontend/packages/ux-editor-v3/src/components/FormComponent/DragHandle.tsx new file mode 100644 index 00000000000..c38683faafa --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/FormComponent/DragHandle.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import classes from './DragHandle.module.css'; + +export const DragHandle = () => ( +
    + + + + + + + + +
    +); diff --git a/frontend/packages/ux-editor-v3/src/components/FormComponent/FormComponent.module.css b/frontend/packages/ux-editor-v3/src/components/FormComponent/FormComponent.module.css new file mode 100644 index 00000000000..889178b3a8c --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/FormComponent/FormComponent.module.css @@ -0,0 +1,97 @@ +.wrapper { + align-items: stretch; + display: flex; +} + +.formComponentWithHandle { + align-items: stretch; + border-bottom-left-radius: 5px; + border-top-left-radius: 5px; + display: flex; + flex-direction: row; + flex: 1; +} + +.editMode .formComponentWithHandle, +.previewMode .formComponentWithHandle { + border: 1px dashed transparent; +} + +.editMode .formComponentWithHandle { + border-color: #008fd6; + box-shadow: 0 0 4px #1eadf740; + border-radius: 5px; +} + +.dragHandle { + background-color: #00000010; + border-bottom-left-radius: 5px; + border-top-left-radius: 5px; + width: var(--drag-handle-width); +} + +.dragHandle, +.buttons { + visibility: hidden; +} + +.editMode .dragHandle, +.editMode .buttons, +.wrapper:hover .dragHandle, +.wrapper:hover .buttons { + visibility: visible; +} + +.buttons:has(button[aria-expanded='true']) { + visibility: visible; +} + +.editMode .dragHandle { + --drag-handle-border-left-width: 6px; + --drag-handle-inner-width: calc(var(--drag-handle-width) - var(--drag-handle-border-left-width)); + border-left: var(--drag-handle-border-left-width) solid #008fd6; + box-sizing: border-box; +} + +.formComponentWithHandle:has(.dragHandle:hover) { + box-shadow: 0 0 0.4rem rgba(0, 0, 0, 0.25); +} + +.formComponent { + background-color: #fff; + border: 1px solid #6a6a6a; + color: #022f51; + flex: 1; + padding: 1rem; + cursor: pointer; +} + +.editMode .formComponent, +.previewMode .formComponent { + border: 0; +} + +.previewMode:not(.editMode):hover .formComponent { + background-color: #00000010; + border-radius: 5px; +} + +.buttons { + display: flex; + flex-direction: column; + margin-left: var(--buttons-distance); + gap: var(--buttons-distance); +} + +.formComponentTitle { + margin-top: 0.6rem; + color: #022f51; + align-items: center; + display: flex; + gap: 0.5rem; +} + +.formComponentTitle .icon { + font-size: 2rem; + display: inline-flex; +} diff --git a/frontend/packages/ux-editor-v3/src/components/FormComponent/FormComponent.test.tsx b/frontend/packages/ux-editor-v3/src/components/FormComponent/FormComponent.test.tsx new file mode 100644 index 00000000000..49989bf86ed --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/FormComponent/FormComponent.test.tsx @@ -0,0 +1,235 @@ +import React from 'react'; +import { act, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { DndProvider } from 'react-dnd'; +import type { IFormComponentProps } from './FormComponent'; +import { FormComponent } from './FormComponent'; +import { + renderHookWithMockStore, + renderWithMockStore, + textLanguagesMock, +} from '../../testing/mocks'; +import { component1IdMock, component1Mock } from '../../testing/layoutMock'; +import { textMock } from '../../../../../testing/mocks/i18nMock'; +import { useTextResourcesQuery } from 'app-shared/hooks/queries/useTextResourcesQuery'; +import type { ITextResource } from 'app-shared/types/global'; +import { useDeleteFormComponentMutation } from '../../hooks/mutations/useDeleteFormComponentMutation'; +import type { UseMutationResult } from '@tanstack/react-query'; +import type { IInternalLayout } from '../../types/global'; + +const user = userEvent.setup(); + +// Test data: +const org = 'org'; +const app = 'app'; +const testTextResourceKey = 'test-key'; +const testTextResourceValue = 'test-value'; +const emptyTextResourceKey = 'empty-key'; +const testTextResource: ITextResource = { id: testTextResourceKey, value: testTextResourceValue }; +const emptyTextResource: ITextResource = { id: emptyTextResourceKey, value: '' }; +const nbTextResources: ITextResource[] = [testTextResource, emptyTextResource]; +const handleEditMock = jest.fn().mockImplementation(() => Promise.resolve()); +const handleSaveMock = jest.fn(); +const debounceSaveMock = jest.fn(); +const handleDiscardMock = jest.fn(); + +jest.mock('../../hooks/mutations/useDeleteFormComponentMutation'); +const mockDeleteFormComponent = jest.fn(); +const mockUseDeleteFormComponentMutation = useDeleteFormComponentMutation as jest.MockedFunction< + typeof useDeleteFormComponentMutation +>; +mockUseDeleteFormComponentMutation.mockReturnValue({ + mutate: mockDeleteFormComponent, +} as unknown as UseMutationResult); + +describe('FormComponent', () => { + it('should render the component', async () => { + await render(); + + expect(screen.getByRole('button', { name: textMock('general.delete') })).toBeInTheDocument(); + }); + + it('should edit the component when clicking on the component', async () => { + await render(); + + const component = screen.getByRole('listitem'); + await act(() => user.click(component)); + + expect(handleSaveMock).toHaveBeenCalledTimes(1); + expect(handleEditMock).toHaveBeenCalledTimes(1); + }); + + describe('Delete confirmation dialog', () => { + afterEach(jest.clearAllMocks); + + it('should open the confirmation dialog when clicking the delete button', async () => { + await render(); + + const deleteButton = screen.getByRole('button', { name: textMock('general.delete') }); + await act(() => user.click(deleteButton)); + + const dialog = screen.getByRole('dialog'); + expect(dialog).toBeInTheDocument(); + + const text = await screen.findByText(textMock('ux_editor.component_deletion_text')); + expect(text).toBeInTheDocument(); + + const confirmButton = screen.getByRole('button', { + name: textMock('ux_editor.component_deletion_confirm'), + }); + expect(confirmButton).toBeInTheDocument(); + + const cancelButton = screen.getByRole('button', { name: textMock('general.cancel') }); + expect(cancelButton).toBeInTheDocument(); + }); + + it('should confirm and close the dialog when clicking the confirm button', async () => { + await render(); + + const deleteButton = screen.getByRole('button', { name: textMock('general.delete') }); + await act(() => user.click(deleteButton)); + + const confirmButton = screen.getByRole('button', { + name: textMock('ux_editor.component_deletion_confirm'), + }); + await act(() => user.click(confirmButton)); + + expect(mockDeleteFormComponent).toHaveBeenCalledWith(component1IdMock); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('should close the confirmation dialog when clicking the cancel button', async () => { + await render(); + + const deleteButton = screen.getByRole('button', { name: textMock('general.delete') }); + await act(() => user.click(deleteButton)); + + const cancelButton = screen.getByRole('button', { name: textMock('general.cancel') }); + await act(() => user.click(cancelButton)); + + expect(mockDeleteFormComponent).toHaveBeenCalledTimes(0); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('should call "handleDiscard" when "isEditMode: true"', async () => { + await render({ isEditMode: true, handleDiscard: handleDiscardMock }); + + const deleteButton = screen.getByRole('button', { name: textMock('general.delete') }); + await act(() => user.click(deleteButton)); + + const confirmButton = screen.getByRole('button', { + name: textMock('ux_editor.component_deletion_confirm'), + }); + await act(() => user.click(confirmButton)); + + expect(mockDeleteFormComponent).toHaveBeenCalledTimes(1); + expect(handleDiscardMock).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('should close when clicking outside the popover', async () => { + await render(); + + const deleteButton = screen.getByRole('button', { name: textMock('general.delete') }); + await act(() => user.click(deleteButton)); + + await act(() => user.click(document.body)); + + expect(mockDeleteFormComponent).toHaveBeenCalledTimes(0); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + }); + + describe('title', () => { + it('should display the title', async () => { + await render({ + component: { + ...component1Mock, + textResourceBindings: { + title: testTextResourceKey, + }, + }, + }); + + expect(screen.getByText(testTextResourceValue)).toBeInTheDocument(); + }); + + it('should display the component type when the title is empty', async () => { + await render({ + component: { + ...component1Mock, + textResourceBindings: { + title: emptyTextResourceKey, + }, + }, + }); + + expect(screen.getByRole('listitem')).toHaveTextContent( + textMock('ux_editor.component_title.Input'), + ); + }); + + it('should display the component type when the title is undefined', async () => { + await render({ + component: { + ...component1Mock, + textResourceBindings: { + title: undefined, + }, + }, + }); + + expect(screen.getByRole('listitem')).toHaveTextContent( + textMock('ux_editor.component_title.Input'), + ); + }); + }); + + describe('icon', () => { + it('should display the icon', async () => { + await render({ + component: { + ...component1Mock, + icon: 'Icon', + }, + }); + + expect(screen.getByTitle(textMock('ux_editor.component_title.Input'))).toBeInTheDocument(); + }); + }); +}); + +const waitForData = async () => { + const { result: texts } = renderHookWithMockStore( + {}, + { + getTextResources: jest + .fn() + .mockImplementation(() => Promise.resolve({ language: 'nb', resources: nbTextResources })), + getTextLanguages: jest.fn().mockImplementation(() => Promise.resolve(textLanguagesMock)), + }, + )(() => useTextResourcesQuery(org, app)).renderHookResult; + await waitFor(() => expect(texts.current.isSuccess).toBe(true)); +}; + +const render = async (props: Partial = {}) => { + const allProps: IFormComponentProps = { + id: component1IdMock, + isEditMode: false, + component: component1Mock, + handleEdit: handleEditMock, + handleSave: handleSaveMock, + debounceSave: debounceSaveMock, + handleDiscard: handleDiscardMock, + ...props, + }; + + await waitForData(); + + return renderWithMockStore()( + + + , + ); +}; diff --git a/frontend/packages/ux-editor-v3/src/components/FormComponent/FormComponent.tsx b/frontend/packages/ux-editor-v3/src/components/FormComponent/FormComponent.tsx new file mode 100644 index 00000000000..1a0e2049a7d --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/FormComponent/FormComponent.tsx @@ -0,0 +1,123 @@ +import React, { memo, useState } from 'react'; +import '../../styles/index.css'; +import classes from './FormComponent.module.css'; +import cn from 'classnames'; +import type { FormComponent as IFormComponent } from '../../types/FormComponent'; +import { StudioButton } from '@studio/components'; +import type { ConnectDragSource } from 'react-dnd'; +import { DEFAULT_LANGUAGE } from 'app-shared/constants'; +import { DragHandle } from './DragHandle'; +import type { ITextResource } from 'app-shared/types/global'; +import { TrashIcon } from '@navikt/aksel-icons'; +import { formItemConfigs } from '../../data/formItemConfig'; +import { getComponentTitleByComponentType, getTextResource, truncate } from '../../utils/language'; +import { textResourcesByLanguageSelector } from '../../selectors/textResourceSelectors'; +import { useDeleteFormComponentMutation } from '../../hooks/mutations/useDeleteFormComponentMutation'; +import { useTextResourcesSelector } from '../../hooks'; +import { useTranslation } from 'react-i18next'; +import { AltinnConfirmDialog } from 'app-shared/components'; +import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; +import { useAppContext } from '../../hooks/useAppContext'; + +export interface IFormComponentProps { + component: IFormComponent; + dragHandleRef?: ConnectDragSource; + handleDiscard: () => void; + handleEdit: (component: IFormComponent) => void; + handleSave: () => Promise; + debounceSave: (id?: string, updatedForm?: IFormComponent) => Promise; + id: string; + isEditMode: boolean; +} + +export const FormComponent = memo(function FormComponent({ + component, + dragHandleRef, + handleDiscard, + handleEdit, + handleSave, + id, + isEditMode, +}: IFormComponentProps) { + const { t } = useTranslation(); + const { org, app } = useStudioUrlParams(); + + const textResources: ITextResource[] = useTextResourcesSelector( + textResourcesByLanguageSelector(DEFAULT_LANGUAGE), + ); + const { selectedLayoutSet } = useAppContext(); + const [isConfirmDeleteDialogOpen, setIsConfirmDeleteDialogOpen] = useState(); + const Icon = formItemConfigs[component.type]?.icon; + + const { mutate: deleteFormComponent } = useDeleteFormComponentMutation( + org, + app, + selectedLayoutSet, + ); + + const handleDelete = (): void => { + deleteFormComponent(id); + if (isEditMode) handleDiscard(); + }; + + const textResource = getTextResource(component.textResourceBindings?.title, textResources); + + return ( +
    ) => { + event.stopPropagation(); + if (isEditMode) return; + await handleSave(); + handleEdit(component); + }} + aria-labelledby={`${id}-title`} + > +
    +
    + +
    +
    +
    + + {Icon && ( + + )} + + + {textResource + ? truncate(textResource, 80) + : getComponentTitleByComponentType(component.type, t) || + t('ux_editor.component_unknown')} + +
    +
    +
    +
    + setIsConfirmDeleteDialogOpen(false)} + trigger={ + } + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + setIsConfirmDeleteDialogOpen((prevState) => !prevState); + }} + tabIndex={0} + title={t('general.delete')} + variant='tertiary' + size='small' + /> + } + > +

    {t('ux_editor.component_deletion_text')}

    +
    +
    +
    + ); +}); diff --git a/frontend/packages/ux-editor-v3/src/components/FormComponent/index.ts b/frontend/packages/ux-editor-v3/src/components/FormComponent/index.ts new file mode 100644 index 00000000000..5d5f04a9c68 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/FormComponent/index.ts @@ -0,0 +1,2 @@ +export { FormComponent } from './FormComponent'; +export type { IFormComponentProps } from './FormComponent'; diff --git a/frontend/packages/ux-editor-v3/src/components/FormField.tsx b/frontend/packages/ux-editor-v3/src/components/FormField.tsx new file mode 100644 index 00000000000..2b04fb062e8 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/FormField.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import type { FormFieldProps } from 'app-shared/components/FormField'; +import { FormField as FF } from 'app-shared/components/FormField'; +import { useLayoutSchemaQuery } from '../hooks/queries/useLayoutSchemaQuery'; + +export const FormField = ( + props: FormFieldProps, +): JSX.Element => { + const [{ data: layoutSchema }] = useLayoutSchemaQuery(); + return ; +}; diff --git a/frontend/packages/ux-editor-v3/src/components/Preview/Preview.module.css b/frontend/packages/ux-editor-v3/src/components/Preview/Preview.module.css new file mode 100644 index 00000000000..8f7156dbccd --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Preview/Preview.module.css @@ -0,0 +1,43 @@ +.root { + display: flex; + flex-direction: column; + flex: var(--preview-width-fraction); +} + +.openPreviewButton { + writing-mode: vertical-lr; + text-transform: uppercase; + border-radius: 0; +} + +.closePreviewButton { + position: absolute; +} + +.iframeContainer { + display: flex; + justify-content: center; + flex: 1; + background-color: var(--fds-semantic-surface-neutral-dark); +} + +.iframe { + border: 0; + margin: 0 auto; +} + +.previewArea { + display: flex; + flex-direction: column; + height: 100%; +} + +.iframe.mobile { + --phone-width: 390px; + width: var(--phone-width); +} + +.iframe.desktop { + width: 100%; + height: 100%; +} diff --git a/frontend/packages/ux-editor-v3/src/components/Preview/Preview.test.tsx b/frontend/packages/ux-editor-v3/src/components/Preview/Preview.test.tsx new file mode 100644 index 00000000000..4479b818a97 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Preview/Preview.test.tsx @@ -0,0 +1,74 @@ +import React, { createRef } from 'react'; +import { Preview } from './Preview'; +import { act, screen } from '@testing-library/react'; +import { queryClientMock } from 'app-shared/mocks/queryClientMock'; +import { renderWithMockStore } from '../../testing/mocks'; +import type { IAppState } from '../../types/global'; +import { textMock } from '../../../../../testing/mocks/i18nMock'; +import userEvent from '@testing-library/user-event'; + +describe('Preview', () => { + it('Renders an iframe with the ref from AppContext', () => { + const previewIframeRef = createRef(); + renderWithMockStore({}, {}, queryClientMock, { previewIframeRef })(); + expect(screen.getByTitle(textMock('ux_editor.preview'))).toBe(previewIframeRef.current); + }); + + it('should be able to toggle between mobile and desktop view', async () => { + const user = userEvent.setup(); + const previewIframeRef = createRef(); + renderWithMockStore({}, {}, queryClientMock, { previewIframeRef })(); + + const switchButton = screen.getByRole('checkbox', { + name: textMock('ux_editor.mobilePreview'), + }); + + expect(switchButton).not.toBeChecked(); + + await act(() => user.click(switchButton)); + expect(switchButton).toBeChecked(); + }); + + it('should render a message when no page is selected', () => { + const mockedLayout = { layout: { selectedLayout: undefined } } as IAppState['formDesigner']; + renderWithMockStore({ formDesigner: mockedLayout }, {}, queryClientMock)(); + expect(screen.getByText(textMock('ux_editor.no_components_selected'))).toBeInTheDocument(); + }); + + it('Renders the information alert with preview being limited', () => { + const previewIframeRef = createRef(); + renderWithMockStore({}, {}, queryClientMock, { previewIframeRef })(); + + const previewLimitationsAlert = screen.getByText(textMock('preview.limitations_info')); + expect(previewLimitationsAlert).toBeInTheDocument(); + }); + + it('should not display open preview button if preview is open', () => { + const previewIframeRef = createRef(); + renderWithMockStore({}, {}, queryClientMock, { previewIframeRef })(); + + const showPreviewButton = screen.queryByRole('button', { + name: textMock('ux_editor.open_preview'), + }); + + expect(showPreviewButton).not.toBeInTheDocument(); + }); + + it('should be possible to toggle preview window', async () => { + const user = userEvent.setup(); + const previewIframeRef = createRef(); + renderWithMockStore({}, {}, queryClientMock, { previewIframeRef })(); + + const hidePreviewButton = screen.getByRole('button', { + name: textMock('ux_editor.close_preview'), + }); + await act(() => user.click(hidePreviewButton)); + expect(hidePreviewButton).not.toBeInTheDocument(); + + const showPreviewButton = screen.getByRole('button', { + name: textMock('ux_editor.open_preview'), + }); + await act(() => user.click(showPreviewButton)); + expect(showPreviewButton).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/ux-editor-v3/src/components/Preview/Preview.tsx b/frontend/packages/ux-editor-v3/src/components/Preview/Preview.tsx new file mode 100644 index 00000000000..00037c9671d --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Preview/Preview.tsx @@ -0,0 +1,90 @@ +import React, { useState } from 'react'; +import classes from './Preview.module.css'; +import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; +import { useSelector } from 'react-redux'; +import cn from 'classnames'; +import { selectedLayoutNameSelector } from '../../selectors/formLayoutSelectors'; +import { useTranslation } from 'react-i18next'; +import { useAppContext } from '../../hooks/useAppContext'; +import { useUpdate } from 'app-shared/hooks/useUpdate'; +import { previewPage } from 'app-shared/api/paths'; +import { Paragraph } from '@digdir/design-system-react'; +import { StudioButton, StudioCenter } from '@studio/components'; +import type { SupportedView } from './ViewToggler/ViewToggler'; +import { ViewToggler } from './ViewToggler/ViewToggler'; +import { ArrowRightIcon } from '@studio/icons'; +import { PreviewLimitationsInfo } from 'app-shared/components/PreviewLimitationsInfo/PreviewLimitationsInfo'; + +export const Preview = () => { + const { t } = useTranslation(); + const [isPreviewHidden, setIsPreviewHidden] = useState(false); + const layoutName = useSelector(selectedLayoutNameSelector); + const noPageSelected = layoutName === 'default' || layoutName === undefined; + + const togglePreview = (): void => { + setIsPreviewHidden((prev: boolean) => !prev); + }; + + return isPreviewHidden ? ( + + {t('ux_editor.open_preview')} + + ) : ( +
    + } + title={t('ux_editor.close_preview')} + className={classes.closePreviewButton} + onClick={togglePreview} + /> + {noPageSelected ? : } +
    + ); +}; + +// Message to display when no page is selected +const NoSelectedPageMessage = () => { + const { t } = useTranslation(); + return ( + + {t('ux_editor.no_components_selected')} + + ); +}; + +// The actual preview frame that displays the selected page +const PreviewFrame = () => { + const { org, app } = useStudioUrlParams(); + const [viewportToSimulate, setViewportToSimulate] = useState('desktop'); + const { selectedLayoutSet } = useAppContext(); + const { t } = useTranslation(); + const { previewIframeRef } = useAppContext(); + const layoutName = useSelector(selectedLayoutNameSelector); + + useUpdate(() => { + previewIframeRef.current?.contentWindow?.location.reload(); + }, [layoutName, previewIframeRef]); + + return ( +
    + +
    +
    +