-
+
{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 (
-
- );
-}
-
-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 (
+
+ );
+};
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 (
+
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/Preview/ViewToggler/ViewToggler.module.css b/frontend/packages/ux-editor-v3/src/components/Preview/ViewToggler/ViewToggler.module.css
new file mode 100644
index 00000000000..4b6f8087f33
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/Preview/ViewToggler/ViewToggler.module.css
@@ -0,0 +1,11 @@
+.root {
+ display: flex;
+ justify-content: flex-end;
+ padding: 4px 2rem;
+ box-sizing: border-box;
+ border-bottom: 1px solid var(--fds-semantic-border-divider-default);
+}
+
+.toggler {
+ width: max-content;
+}
diff --git a/frontend/packages/ux-editor-v3/src/components/Preview/ViewToggler/ViewToggler.test.tsx b/frontend/packages/ux-editor-v3/src/components/Preview/ViewToggler/ViewToggler.test.tsx
new file mode 100644
index 00000000000..a39280e9fd9
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/Preview/ViewToggler/ViewToggler.test.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { textMock } from '../../../../../../testing/mocks/i18nMock';
+
+import { ViewToggler } from './ViewToggler';
+
+describe('ViewToggler', () => {
+ it('should render desktop view as default', () => {
+ render( {}} />);
+
+ const switchButton = screen.getByRole('checkbox', {
+ name: textMock('ux_editor.mobilePreview'),
+ });
+ expect(switchButton).not.toBeChecked();
+ });
+
+ it('should render mobile view when initialView is mobile', () => {
+ render( {}} />);
+
+ const switchButton = screen.getByRole('checkbox', {
+ name: textMock('ux_editor.mobilePreview'),
+ });
+
+ expect(switchButton).toBeChecked();
+ });
+
+ it('should emit onChange with value "mobile" or "desktop" when toggled', async () => {
+ const user = userEvent.setup();
+ const onChangeMock = jest.fn();
+ render();
+
+ const switchButton = screen.getByRole('checkbox', {
+ name: textMock('ux_editor.mobilePreview'),
+ });
+
+ await user.click(switchButton);
+ expect(onChangeMock).toHaveBeenCalledWith('mobile');
+
+ await user.click(switchButton);
+ expect(onChangeMock).toHaveBeenCalledWith('desktop');
+ });
+});
diff --git a/frontend/packages/ux-editor-v3/src/components/Preview/ViewToggler/ViewToggler.tsx b/frontend/packages/ux-editor-v3/src/components/Preview/ViewToggler/ViewToggler.tsx
new file mode 100644
index 00000000000..859d5176291
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/Preview/ViewToggler/ViewToggler.tsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import { Switch } from '@digdir/design-system-react';
+import { useTranslation } from 'react-i18next';
+
+import classes from './ViewToggler.module.css';
+
+export type SupportedView = 'mobile' | 'desktop';
+
+type ViewTogglerProps = {
+ initialView?: SupportedView;
+ onChange: (view: SupportedView) => void;
+};
+export const ViewToggler = ({ initialView = 'desktop', onChange }: ViewTogglerProps) => {
+ const { t } = useTranslation();
+
+ const isMobileInitially = initialView === 'mobile';
+
+ const handleViewToggle = (e: React.ChangeEvent): void => {
+ const isMobile = e.target.checked;
+ onChange(isMobile ? 'mobile' : 'desktop');
+ };
+
+ return (
+
+
+ {t('ux_editor.mobilePreview')}
+
+
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/Preview/index.ts b/frontend/packages/ux-editor-v3/src/components/Preview/index.ts
new file mode 100644
index 00000000000..8cd2ced646c
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/Preview/index.ts
@@ -0,0 +1 @@
+export { Preview } from './Preview';
diff --git a/frontend/packages/ux-editor-v3/src/components/Properties/Calculations.module.css b/frontend/packages/ux-editor-v3/src/components/Properties/Calculations.module.css
new file mode 100644
index 00000000000..d5d5d62fea3
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/Properties/Calculations.module.css
@@ -0,0 +1,13 @@
+.calculations {
+ display: flex;
+ flex-direction: column;
+ gap: 2rem;
+}
+
+.header {
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
diff --git a/frontend/packages/ux-editor-v3/src/components/Properties/Calculations.test.tsx b/frontend/packages/ux-editor-v3/src/components/Properties/Calculations.test.tsx
new file mode 100644
index 00000000000..b7594e64be8
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/Properties/Calculations.test.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { Calculations } from './Calculations';
+import { FormContext } from '../../containers/FormContext';
+import { formContextProviderMock } from '../../testing/formContextMocks';
+import { textMock } from '../../../../../testing/mocks/i18nMock';
+import type { FormComponent } from '../../types/FormComponent';
+
+describe('Calculations', () => {
+ it('should render unknown component when components is unknown for Studio', () => {
+ const formType = 'randomUnknownComponent' as unknown as FormComponent;
+ studioRender({ form: { ...formContextProviderMock.form, type: formType } });
+ expect(
+ screen.getByText(
+ textMock('ux_editor.edit_component.unknown_component', {
+ componentName: formType,
+ }),
+ ),
+ );
+ });
+});
+
+const getCalculationsWithMockedFormContext = (props: Partial = {}) => {
+ return (
+
+
+
+ );
+};
+const studioRender = (props: Partial = {}) => {
+ return render(getCalculationsWithMockedFormContext(props));
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/Properties/Calculations.tsx b/frontend/packages/ux-editor-v3/src/components/Properties/Calculations.tsx
new file mode 100644
index 00000000000..f6fd49b5c93
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/Properties/Calculations.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import classes from './Calculations.module.css';
+import { StudioButton } from '@studio/components';
+import { PlusIcon } from '@navikt/aksel-icons';
+import { RuleModal } from '../toolbar/RuleModal';
+import { OldDynamicsInfo } from './OldDynamicsInfo';
+import { Divider } from 'app-shared/primitives';
+import { useText } from '../../hooks';
+import { useFormContext } from '../../containers/FormContext';
+import { formItemConfigs } from '../../data/formItemConfig';
+import { UnknownComponentAlert } from '../UnknownComponentAlert';
+
+export const Calculations = () => {
+ const { form } = useFormContext();
+
+ const [modalOpen, setModalOpen] = React.useState(false);
+ const t = useText();
+
+ const isUnknownInternalComponent: boolean = form && !formItemConfigs[form.type];
+ if (isUnknownInternalComponent) {
+ return ;
+ }
+
+ return (
+
+
+
+ {t('right_menu.rules_calculations')}
+ }
+ onClick={() => setModalOpen(true)}
+ variant='tertiary'
+ size='small'
+ />
+
+
setModalOpen(false)}
+ handleOpen={() => setModalOpen(true)}
+ />
+
+
+
+
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/Properties/ConditionalRendering.module.css b/frontend/packages/ux-editor-v3/src/components/Properties/ConditionalRendering.module.css
new file mode 100644
index 00000000000..f7caa7e6a63
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/Properties/ConditionalRendering.module.css
@@ -0,0 +1,17 @@
+.conditionalRendering {
+ display: flex;
+ flex-direction: column;
+ gap: 2rem;
+}
+
+.header {
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+.dynamicsVersionCheckBox {
+ z-index: 1;
+}
diff --git a/frontend/packages/ux-editor-v3/src/components/Properties/ConditionalRendering.tsx b/frontend/packages/ux-editor-v3/src/components/Properties/ConditionalRendering.tsx
new file mode 100644
index 00000000000..ceea68a4037
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/Properties/ConditionalRendering.tsx
@@ -0,0 +1,56 @@
+import React, { useState } from 'react';
+import { Alert } from '@digdir/design-system-react';
+import classes from './ConditionalRendering.module.css';
+import { PlusIcon } from '@navikt/aksel-icons';
+import { ConditionalRenderingModal } from '../toolbar/ConditionalRenderingModal';
+import { OldDynamicsInfo } from './OldDynamicsInfo';
+import { Divider } from 'app-shared/primitives';
+import { useText } from '../../hooks';
+import { Trans } from 'react-i18next';
+import { altinnDocsUrl } from 'app-shared/ext-urls';
+import { StudioButton } from '@studio/components';
+
+export const ConditionalRendering = () => {
+ const [modalOpen, setModalOpen] = useState(false);
+ const t = useText();
+ return (
+
+
+
+
+ {t('right_menu.rules_conditional_rendering')}
+ }
+ onClick={() => setModalOpen(true)}
+ variant='tertiary'
+ size='small'
+ />
+
+
+ setModalOpen(false)}
+ handleOpen={() => setModalOpen(true)}
+ />
+
+
+
+
+
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/Properties/Content.test.tsx b/frontend/packages/ux-editor-v3/src/components/Properties/Content.test.tsx
new file mode 100644
index 00000000000..aabed980cfd
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/Properties/Content.test.tsx
@@ -0,0 +1,120 @@
+import React from 'react';
+import { Content } from './Content';
+import { act, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { textMock } from '../../../../../testing/mocks/i18nMock';
+import { FormContext } from '../../containers/FormContext';
+import {
+ component1IdMock,
+ component1Mock,
+ container1IdMock,
+ layoutMock,
+} from '../../testing/layoutMock';
+import type { IAppDataState } from '../../features/appData/appDataReducers';
+import type { ITextResourcesState } from '../../features/appData/textResources/textResourcesSlice';
+import { renderWithMockStore, renderHookWithMockStore } from '../../testing/mocks';
+import { appDataMock, textResourcesMock } from '../../testing/stateMocks';
+import { formContextProviderMock } from '../../testing/formContextMocks';
+import { useLayoutSchemaQuery } from '../../hooks/queries/useLayoutSchemaQuery';
+
+const user = userEvent.setup();
+
+// Test data:
+const textResourceEditTestId = 'text-resource-edit';
+
+// Mocks:
+jest.mock('../TextResourceEdit', () => ({
+ TextResourceEdit: () => ,
+}));
+
+describe('ContentTab', () => {
+ afterEach(jest.clearAllMocks);
+
+ describe('when editing a text resource', () => {
+ it('should render the component', async () => {
+ await render({ props: {}, editId: 'test' });
+ expect(screen.getByTestId(textResourceEditTestId)).toBeInTheDocument();
+ });
+ });
+
+ describe('when editing a container', () => {
+ const props = {
+ formId: container1IdMock,
+ form: { ...layoutMock.containers[container1IdMock], id: 'id' },
+ };
+
+ it('should render the component', async () => {
+ await render({ props });
+ expect(
+ screen.getByText(textMock('ux_editor.modal_properties_group_change_id')),
+ ).toBeInTheDocument();
+ });
+
+ it('should auto-save when updating a field', async () => {
+ await render({ props });
+
+ const idInput = screen.getByLabelText(textMock('ux_editor.modal_properties_group_change_id'));
+ await act(() => user.type(idInput, 'test'));
+
+ expect(formContextProviderMock.handleUpdate).toHaveBeenCalledTimes(4);
+ expect(formContextProviderMock.debounceSave).toHaveBeenCalledTimes(4);
+ });
+ });
+
+ describe('when editing a component', () => {
+ const props = {
+ formId: component1IdMock,
+ form: { ...component1Mock, dataModelBindings: {} },
+ };
+
+ it('should render the component', async () => {
+ jest.spyOn(console, 'error').mockImplementation(); // Silence error from Select component
+ await render({ props });
+ expect(
+ screen.getByText(textMock('ux_editor.modal_properties_component_change_id')),
+ ).toBeInTheDocument();
+ });
+
+ it('should auto-save when updating a field', async () => {
+ await render({ props });
+
+ const idInput = screen.getByLabelText(
+ textMock('ux_editor.modal_properties_component_change_id'),
+ );
+ await act(() => user.type(idInput, 'test'));
+
+ expect(formContextProviderMock.handleUpdate).toHaveBeenCalledTimes(4);
+ expect(formContextProviderMock.debounceSave).toHaveBeenCalledTimes(4);
+ });
+ });
+});
+
+const waitForData = async () => {
+ const layoutSchemaResult = renderHookWithMockStore()(() => useLayoutSchemaQuery())
+ .renderHookResult.result;
+ await waitFor(() => expect(layoutSchemaResult.current[0].isSuccess).toBe(true));
+};
+
+const render = async ({ props = {}, editId }: { props: Partial; editId?: string }) => {
+ const textResources: ITextResourcesState = {
+ ...textResourcesMock,
+ currentEditId: editId,
+ };
+ const appData: IAppDataState = {
+ ...appDataMock,
+ textResources,
+ };
+
+ await waitForData();
+
+ return renderWithMockStore({ appData })(
+
+
+ ,
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/Properties/Content.tsx b/frontend/packages/ux-editor-v3/src/components/Properties/Content.tsx
new file mode 100644
index 00000000000..32744d080dd
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/Properties/Content.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import { TextResourceEdit } from '../TextResourceEdit';
+import { EditFormComponent } from '../config/EditFormComponent';
+import { EditFormContainer } from '../config/EditFormContainer';
+import { getCurrentEditId } from '../../selectors/textResourceSelectors';
+import { useSelector } from 'react-redux';
+import { useFormContext } from '../../containers/FormContext';
+import { useTranslation } from 'react-i18next';
+import { isContainer } from '../../utils/formItemUtils';
+
+export const Content = () => {
+ const { formId, form, handleUpdate, debounceSave } = useFormContext();
+ const editId = useSelector(getCurrentEditId);
+ const { t } = useTranslation();
+
+ if (editId) return ;
+ if (!formId || !form) return t('right_menu.content_empty');
+
+ return (
+ <>
+ {isContainer(form) ? (
+ {
+ handleUpdate(updatedContainer);
+ debounceSave(formId, updatedContainer);
+ }}
+ />
+ ) : (
+ {
+ handleUpdate(updatedComponent);
+ debounceSave(formId, updatedComponent);
+ }}
+ />
+ )}
+ >
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/Properties/Dynamics.test.tsx b/frontend/packages/ux-editor-v3/src/components/Properties/Dynamics.test.tsx
new file mode 100644
index 00000000000..a6570293664
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/Properties/Dynamics.test.tsx
@@ -0,0 +1,88 @@
+import React from 'react';
+import { act, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { FormContext } from '../../containers/FormContext';
+import { renderWithMockStore } from '../../testing/mocks';
+import { formContextProviderMock } from '../../testing/formContextMocks';
+import { Dynamics } from './Dynamics';
+import { textMock } from '../../../../../testing/mocks/i18nMock';
+import type { WindowWithRuleModel } from '../../hooks/queries/useRuleModelQuery';
+import type { FormComponent } from '../../types/FormComponent';
+
+const user = userEvent.setup();
+
+// Test data:
+const conditionalRenderingTestId = 'conditional-rendering';
+const expressionsTestId = 'expressions';
+
+// Mocks:
+jest.mock('./ConditionalRendering', () => ({
+ ConditionalRendering: () => ,
+}));
+jest.mock('../config/Expressions', () => ({
+ Expressions: () => ,
+}));
+
+describe('Dynamics', () => {
+ afterEach(jest.clearAllMocks);
+
+ it('should render new expressions editor by default', async () => {
+ await render();
+ expect(screen.getByTestId(expressionsTestId)).toBeInTheDocument();
+ expect(screen.queryByTestId(conditionalRenderingTestId)).not.toBeInTheDocument();
+ });
+
+ it('should not render switch if ruleHandler is not found', async () => {
+ await render();
+ const oldDynamicsSwitch = screen.queryByRole('checkbox', {
+ name: textMock('right_menu.show_old_dynamics'),
+ });
+ expect(oldDynamicsSwitch).not.toBeInTheDocument();
+ });
+
+ it('should render default unchecked switch if ruleHandler is found', async () => {
+ (window as WindowWithRuleModel).conditionalRuleHandlerObject = {};
+ await render();
+ const oldDynamicsSwitch = screen.getByRole('checkbox', {
+ name: textMock('right_menu.show_old_dynamics'),
+ });
+ expect(oldDynamicsSwitch).toBeInTheDocument();
+ expect(oldDynamicsSwitch).not.toBeChecked();
+ });
+
+ it('should render old dynamics when enabling switch if ruleHandler is found', async () => {
+ (window as WindowWithRuleModel).conditionalRuleHandlerObject = {};
+ await render();
+ const oldDynamicsSwitch = screen.getByRole('checkbox', {
+ name: textMock('right_menu.show_old_dynamics'),
+ });
+ await act(() => user.click(oldDynamicsSwitch));
+ expect(screen.queryByTestId(expressionsTestId)).not.toBeInTheDocument();
+ expect(screen.getByTestId(conditionalRenderingTestId)).toBeInTheDocument();
+ });
+
+ it('should render unknown component alert when component is unknown for Studio', async () => {
+ const formType = 'randomUnknownComponent' as unknown as FormComponent;
+ await render({ form: { ...formContextProviderMock.form, type: formType } });
+ expect(
+ screen.getByText(
+ textMock('ux_editor.edit_component.unknown_component', {
+ componentName: formType,
+ }),
+ ),
+ );
+ });
+});
+
+const render = async (props: Partial = {}) => {
+ return renderWithMockStore({})(
+
+
+ ,
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/Properties/Dynamics.tsx b/frontend/packages/ux-editor-v3/src/components/Properties/Dynamics.tsx
new file mode 100644
index 00000000000..6bda1bde887
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/Properties/Dynamics.tsx
@@ -0,0 +1,44 @@
+import React, { useState } from 'react';
+import { Switch } from '@digdir/design-system-react';
+import { ConditionalRendering } from './ConditionalRendering';
+import { Expressions } from '../config/Expressions';
+import { useText } from '../../hooks';
+import type { WindowWithRuleModel } from '../../hooks/queries/useRuleModelQuery';
+import { useFormContext } from '../../containers/FormContext';
+import { formItemConfigs } from '../../data/formItemConfig';
+import { UnknownComponentAlert } from '../UnknownComponentAlert';
+
+export const Dynamics = () => {
+ const { formId, form } = useFormContext();
+
+ const [showOldExpressions, setShowOldExpressions] = useState(false);
+ const t = useText();
+
+ const handleToggleOldDynamics = (event: React.ChangeEvent) => {
+ setShowOldExpressions(event.target.checked);
+ };
+
+ const conditionalRulesExist =
+ (window as WindowWithRuleModel).conditionalRuleHandlerObject !== undefined;
+
+ const isUnknownInternalComponent: boolean = form && !formItemConfigs[form.type];
+ if (isUnknownInternalComponent) {
+ return ;
+ }
+
+ return (
+ <>
+ {conditionalRulesExist && (
+
+ {t('right_menu.show_old_dynamics')}
+
+ )}
+ {showOldExpressions ? : }
+ >
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/Properties/OldDynamicsInfo.module.css b/frontend/packages/ux-editor-v3/src/components/Properties/OldDynamicsInfo.module.css
new file mode 100644
index 00000000000..962186aa8dc
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/Properties/OldDynamicsInfo.module.css
@@ -0,0 +1,21 @@
+.header {
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+.externalLink::after {
+ display: none !important;
+}
+
+.externalLinkIcon {
+ margin-left: 0.5rem;
+}
+
+.textLink {
+ cursor: pointer;
+ text-decoration-color: #000;
+ text-decoration: underline;
+}
diff --git a/frontend/packages/ux-editor-v3/src/components/Properties/OldDynamicsInfo.test.tsx b/frontend/packages/ux-editor-v3/src/components/Properties/OldDynamicsInfo.test.tsx
new file mode 100644
index 00000000000..47d0a0ff9e1
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/Properties/OldDynamicsInfo.test.tsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import { screen } from '@testing-library/react';
+import { renderWithMockStore } from '../../testing/mocks';
+import { textMock } from '../../../../../testing/mocks/i18nMock';
+import { OldDynamicsInfo } from './OldDynamicsInfo';
+import type { AppContextProps } from '../../AppContext';
+import { AppContext } from '../../AppContext';
+import { appContextMock } from '../../testing/appContextMock';
+
+describe('OldDynamicsInfo', () => {
+ it('should render OldDynamicsInfo with all texts', async () => {
+ await render();
+ expect(screen.getByText(textMock('right_menu.dynamics_description'))).toBeInTheDocument();
+ expect(screen.getByText(textMock('right_menu.dynamics_edit'))).toBeInTheDocument();
+ expect(screen.getByText(textMock('right_menu.dynamics_edit_comment'))).toBeInTheDocument();
+ });
+
+ it('should have layoutSetName as part of link to gitea when app has layout sets', async () => {
+ await render();
+ const editLink = screen.getByText(textMock('right_menu.dynamics_edit'));
+ expect(editLink).toHaveAttribute(
+ 'href',
+ expect.stringContaining(appContextMock.selectedLayoutSet),
+ );
+ });
+
+ it('should have simple url to edit file in gitea when app does not have layout sets', async () => {
+ await render({ selectedLayoutSet: null });
+ const editLink = screen.getByText(textMock('right_menu.dynamics_edit'));
+ expect(editLink).toHaveAttribute('href', expect.stringContaining('App/ui/RuleHandler.js'));
+ });
+});
+
+const render = async (props: Partial = {}) => {
+ return renderWithMockStore({})(
+
+
+ ,
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/Properties/OldDynamicsInfo.tsx b/frontend/packages/ux-editor-v3/src/components/Properties/OldDynamicsInfo.tsx
new file mode 100644
index 00000000000..61b5aac15d5
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/Properties/OldDynamicsInfo.tsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import classes from './OldDynamicsInfo.module.css';
+import { ExternalLinkIcon } from '@navikt/aksel-icons';
+import { useTranslation } from 'react-i18next';
+import { giteaEditLink, altinnDocsUrl } from 'app-shared/ext-urls';
+import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams';
+import { useAppContext } from '../../hooks/useAppContext';
+import { Link } from '@digdir/design-system-react';
+
+export const OldDynamicsInfo = () => {
+ const { t } = useTranslation();
+ const { selectedLayoutSet } = useAppContext();
+ const { app, org } = useStudioUrlParams();
+ const dynamicLocation = selectedLayoutSet
+ ? `App/ui/${selectedLayoutSet}/RuleHandler.js`
+ : 'App/ui/RuleHandler.js';
+ return (
+
+
{t('right_menu.dynamics')}
+
+
+ {t('right_menu.dynamics_description')}
+
+
+ {t('right_menu.dynamics_link')}
+
+
+
+
+
+
+ {t('right_menu.dynamics_edit')}
+ {' '}
+ {t('right_menu.dynamics_edit_comment')}
+
+
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/Properties/Properties.module.css b/frontend/packages/ux-editor-v3/src/components/Properties/Properties.module.css
new file mode 100644
index 00000000000..b98bdc313b6
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/Properties/Properties.module.css
@@ -0,0 +1,4 @@
+.root {
+ background: var(--fds-semantic-surface-neutral-subtle);
+ flex: var(--properties-width-fraction);
+}
diff --git a/frontend/packages/ux-editor-v3/src/components/Properties/Properties.test.tsx b/frontend/packages/ux-editor-v3/src/components/Properties/Properties.test.tsx
new file mode 100644
index 00000000000..57bbe9cd464
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/Properties/Properties.test.tsx
@@ -0,0 +1,133 @@
+import React from 'react';
+import { Properties } from './Properties';
+import { render as rtlRender, act, screen, waitFor } from '@testing-library/react';
+import { mockUseTranslation } from '../../../../../testing/mocks/i18nMock';
+import { FormContext } from '../../containers/FormContext';
+import userEvent from '@testing-library/user-event';
+import { formContextProviderMock } from '../../testing/formContextMocks';
+
+const user = userEvent.setup();
+
+// Test data:
+const contentText = 'Innhold';
+const dynamicsText = 'Dynamikk';
+const calculationsText = 'Beregninger';
+const texts = {
+ 'right_menu.content': contentText,
+ 'right_menu.dynamics': dynamicsText,
+ 'right_menu.calculations': calculationsText,
+};
+
+const contentTestId = 'content';
+const conditionalRenderingTestId = 'conditional-rendering';
+const expressionsTestId = 'expressions';
+const calculationsTestId = 'calculations';
+
+// Mocks:
+jest.mock('./Content', () => ({
+ Content: () => ,
+}));
+jest.mock('./ConditionalRendering', () => ({
+ ConditionalRendering: () => ,
+}));
+jest.mock('../config/Expressions', () => ({
+ Expressions: () => ,
+}));
+jest.mock('./Calculations', () => ({
+ Calculations: () => ,
+}));
+jest.mock('react-i18next', () => ({ useTranslation: () => mockUseTranslation(texts) }));
+
+describe('Properties', () => {
+ describe('Content', () => {
+ it('Closes content on load', () => {
+ render();
+ const button = screen.queryByRole('button', { name: contentText });
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('Toggles content when clicked', async () => {
+ render();
+ const button = screen.queryByRole('button', { name: contentText });
+ await act(() => user.click(button));
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+ await act(() => user.click(button));
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('Opens content when a component is selected', async () => {
+ const { rerender } = render();
+ rerender(getComponent({ formId: 'test' }));
+ const button = screen.queryByRole('button', { name: contentText });
+ await waitFor(() => expect(button).toHaveAttribute('aria-expanded', 'true'));
+ });
+ });
+
+ describe('Dynamics', () => {
+ it('Closes dynamics on load', () => {
+ render();
+ const button = screen.queryByRole('button', { name: dynamicsText });
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('Toggles dynamics when clicked', async () => {
+ render();
+ const button = screen.queryByRole('button', { name: dynamicsText });
+ await act(() => user.click(button));
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+ await act(() => user.click(button));
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('Shows new dynamics by default', async () => {
+ const { rerender } = render();
+ rerender(getComponent({ formId: 'test' }));
+ const dynamicsButton = screen.queryByRole('button', { name: dynamicsText });
+ await act(() => user.click(dynamicsButton));
+ const newDynamics = screen.getByTestId(expressionsTestId);
+ expect(newDynamics).toBeInTheDocument();
+ });
+ });
+
+ describe('Calculations', () => {
+ it('Closes calculations on load', () => {
+ render();
+ const button = screen.queryByRole('button', { name: calculationsText });
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('Toggles calculations when clicked', async () => {
+ render();
+ const button = screen.queryByRole('button', { name: calculationsText });
+ await act(() => user.click(button));
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+ await act(() => user.click(button));
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+ });
+ });
+
+ it('Renders accordion', () => {
+ const formIdMock = 'test-id';
+ render({ formId: formIdMock });
+ expect(screen.getByText(contentText)).toBeInTheDocument();
+ expect(screen.getByText(dynamicsText)).toBeInTheDocument();
+ expect(screen.getByText(calculationsText)).toBeInTheDocument();
+ expect(screen.getByTestId(contentTestId)).toBeInTheDocument();
+ expect(screen.getByTestId(expressionsTestId)).toBeInTheDocument();
+ expect(screen.getByTestId(calculationsTestId)).toBeInTheDocument();
+ });
+});
+
+const getComponent = (formContextProps: Partial = {}) => (
+
+
+
+);
+
+const render = (formContextProps: Partial = {}) =>
+ rtlRender(getComponent(formContextProps));
diff --git a/frontend/packages/ux-editor-v3/src/components/Properties/Properties.tsx b/frontend/packages/ux-editor-v3/src/components/Properties/Properties.tsx
new file mode 100644
index 00000000000..5879dff6ebd
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/Properties/Properties.tsx
@@ -0,0 +1,60 @@
+import React, { useEffect } from 'react';
+import { Calculations } from './Calculations';
+import { Content } from './Content';
+import { useTranslation } from 'react-i18next';
+import { Accordion } from '@digdir/design-system-react';
+import { useFormContext } from '../../containers/FormContext';
+import classes from './Properties.module.css';
+import { Dynamics } from './Dynamics';
+
+export const Properties = () => {
+ const { t } = useTranslation();
+ const { formId } = useFormContext();
+ const formIdRef = React.useRef(formId);
+
+ const [openList, setOpenList] = React.useState([]);
+
+ useEffect(() => {
+ if (formIdRef.current !== formId) {
+ formIdRef.current = formId;
+ if (formId && openList.length === 0) setOpenList(['content']);
+ }
+ }, [formId, openList.length]);
+
+ const toggleOpen = (id: string) => {
+ if (openList.includes(id)) {
+ setOpenList(openList.filter((item) => item !== id));
+ } else {
+ setOpenList([...openList, id]);
+ }
+ };
+
+ return (
+
+
+
+ toggleOpen('content')}>
+ {t('right_menu.content')}
+
+
+
+
+
+
+ toggleOpen('dynamics')}>
+ {t('right_menu.dynamics')}
+
+ {formId && }
+
+
+ toggleOpen('calculations')}>
+ {t('right_menu.calculations')}
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/Properties/index.ts b/frontend/packages/ux-editor-v3/src/components/Properties/index.ts
new file mode 100644
index 00000000000..0240b553329
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/Properties/index.ts
@@ -0,0 +1 @@
+export { Properties } from './Properties';
diff --git a/frontend/packages/ux-editor-v3/src/components/TextResource.module.css b/frontend/packages/ux-editor-v3/src/components/TextResource.module.css
new file mode 100644
index 00000000000..110d7dc5e36
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/TextResource.module.css
@@ -0,0 +1,111 @@
+.root {
+ --frame-offset: 4px;
+ border-radius: 2px;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.root.isEditing {
+ outline-offset: var(--frame-offset);
+ outline: 1px dashed #008fd6;
+}
+
+.root.isSearching {
+ --search-background-color: #1eadf718;
+ background-color: var(--search-background-color);
+ box-shadow: 0 0 0 var(--frame-offset) var(--search-background-color);
+}
+
+.root.previewMode:not(.isSearching):not(:hover) .button {
+ visibility: hidden;
+}
+
+.paragraph {
+ word-break: break-word;
+}
+
+.label {
+ margin: 0;
+}
+
+.description {
+ margin: 0;
+ font-size: 1.4rem;
+}
+
+.textResource {
+ align-items: stretch;
+ display: inline-flex;
+}
+
+.buttonsWrapper {
+ display: inline-block;
+ position: relative;
+}
+
+.buttons {
+ display: inline-flex;
+ gap: 0.5rem;
+ left: 0;
+ margin-left: 1rem;
+ position: relative;
+ top: 0;
+}
+
+.previewMode .buttons {
+ position: absolute;
+}
+
+.root .button {
+ /* .root must be set to make it override design system settings */
+ --icon-size: 1em;
+ height: 1.5em;
+ padding: 0.25em;
+ width: 1.5em;
+}
+
+.placeholder {
+ color: #c9c9c9;
+}
+
+.searchContainer {
+ display: inline-flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.searchContainer .select {
+ flex: 1;
+}
+
+.textOption {
+ --spacing: 3px;
+ align-items: stretch;
+ display: inline-flex;
+ flex-direction: column;
+ gap: var(--spacing);
+ margin: var(--spacing) 0;
+ overflow: hidden;
+ white-space: nowrap;
+ width: 100%;
+}
+
+.textOptionId {
+ display: inline-block;
+ font-weight: bold;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.textOptionValue {
+ display: inline-block;
+ margin: 0;
+ overflow: hidden;
+ padding-left: 0;
+ text-overflow: ellipsis;
+}
+
+.textOptionValue.empty {
+ color: #888;
+}
diff --git a/frontend/packages/ux-editor-v3/src/components/TextResource.test.tsx b/frontend/packages/ux-editor-v3/src/components/TextResource.test.tsx
new file mode 100644
index 00000000000..81f22cee2e9
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/TextResource.test.tsx
@@ -0,0 +1,272 @@
+import React from 'react';
+import userEvent from '@testing-library/user-event';
+import type { ITextResource, ITextResourcesWithLanguage } from 'app-shared/types/global';
+import { queryClientMock } from 'app-shared/mocks/queryClientMock';
+import type { TextResourceProps } from './TextResource';
+import { TextResource } from './TextResource';
+import { renderHookWithMockStore, renderWithMockStore, textLanguagesMock } from '../testing/mocks';
+import { useLayoutSchemaQuery } from '../hooks/queries/useLayoutSchemaQuery';
+import { act, screen, waitFor } from '@testing-library/react';
+import { textMock } from '../../../../testing/mocks/i18nMock';
+import { useTextResourcesQuery } from 'app-shared/hooks/queries/useTextResourcesQuery';
+import { DEFAULT_LANGUAGE } from 'app-shared/constants';
+import { typedLocalStorage } from 'app-shared/utils/webStorage';
+import { addFeatureFlagToLocalStorage } from 'app-shared/utils/featureToggleUtils';
+
+const user = userEvent.setup();
+
+// Test data:
+const org = 'org';
+const app = 'app';
+const handleIdChange = jest.fn();
+const defaultProps: TextResourceProps = { handleIdChange };
+
+const textResources: ITextResource[] = [
+ { id: '1', value: 'Text 1' },
+ { id: '2', value: 'Text 2' },
+ { id: '3', value: 'Text 3' },
+];
+
+describe('TextResource', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ queryClientMock.clear();
+ typedLocalStorage.removeItem('featureFlags');
+ });
+
+ it('Renders add button when no resource id is given', async () => {
+ await render();
+ expect(screen.getByLabelText(textMock('general.add'))).toBeInTheDocument();
+ });
+
+ it('Calls handleIdChange and dispatches correct actions when add button is clicked', async () => {
+ const { store } = await render();
+ await act(() => user.click(screen.getByLabelText(textMock('general.add'))));
+ expect(handleIdChange).toHaveBeenCalledTimes(1);
+ const actions = store.getActions();
+ expect(actions).toHaveLength(1);
+ expect(actions[0].type).toBe('textResources/setCurrentEditId');
+ });
+
+ it('Calls handleIdChange and dispatches correct actions with expected id when add button is clicked', async () => {
+ const { store } = await render({
+ generateIdOptions: {
+ componentId: 'test-id',
+ layoutId: 'Page1',
+ textResourceKey: 'title',
+ },
+ });
+ await act(() => user.click(screen.getByLabelText(textMock('general.add'))));
+ expect(handleIdChange).toHaveBeenCalledTimes(1);
+ expect(handleIdChange).toHaveBeenCalledWith('Page1.test-id.title');
+ const actions = store.getActions();
+ expect(actions).toHaveLength(1);
+ expect(actions[0].type).toBe('textResources/setCurrentEditId');
+ });
+
+ it('Renders placeholder text when no resource id is given', async () => {
+ const placeholder = 'Legg til tekst her';
+ await render({ placeholder });
+ expect(screen.getByText(placeholder)).toBeInTheDocument();
+ });
+
+ it('Renders placeholder text when resource with given id is empty', async () => {
+ const placeholder = 'Legg til tekst her';
+ const textResourceId = 'some-id';
+ const textResource: ITextResource = { id: textResourceId, value: '' };
+ await render({ placeholder, textResourceId }, [textResource]);
+ expect(screen.getByText(placeholder)).toBeInTheDocument();
+ });
+
+ it('Renders placeholder text when resource with given id does not exist', async () => {
+ const placeholder = 'Legg til tekst her';
+ const textResourceId = 'some-id';
+ await render({ placeholder, textResourceId });
+ expect(screen.getByText(placeholder)).toBeInTheDocument();
+ });
+
+ it('Renders value of resource with given id', async () => {
+ const textResourceId = 'some-id';
+ const value = 'Lorem ipsum dolor sit amet';
+ const textResource: ITextResource = { id: textResourceId, value };
+ await render({ textResourceId }, [textResource]);
+ expect(screen.getByText(value)).toBeInTheDocument();
+ });
+
+ it('Does not render placeholder text when resource with given id has a value', async () => {
+ const placeholder = 'Legg til tekst her';
+ const textResourceId = 'some-id';
+ const textResource: ITextResource = { id: textResourceId, value: 'Lipsum' };
+ await render({ placeholder, textResourceId }, [textResource]);
+ expect(screen.queryByText(placeholder)).not.toBeInTheDocument();
+ });
+
+ it('Renders edit button when valid resource id is given', async () => {
+ const textResourceId = 'some-id';
+ const value = 'Lorem ipsum dolor sit amet';
+ const textResource: ITextResource = { id: textResourceId, value };
+ await render({ textResourceId }, [textResource]);
+ expect(screen.getByLabelText(textMock('general.edit'))).toBeInTheDocument();
+ });
+
+ it('Dispatches correct action and does not call handleIdChange when edit button is clicked', async () => {
+ const textResourceId = 'some-id';
+ const value = 'Lorem ipsum dolor sit amet';
+ const textResource: ITextResource = { id: textResourceId, value };
+ const { store } = await render({ textResourceId }, [textResource]);
+ await act(() => user.click(screen.getByLabelText(textMock('general.edit'))));
+ expect(handleIdChange).toHaveBeenCalledTimes(0);
+ const actions = store.getActions();
+ expect(actions).toHaveLength(1);
+ expect(actions[0].type).toBe('textResources/setCurrentEditId');
+ expect(actions[0].payload).toBe(textResourceId);
+ });
+
+ it('Renders label if given', async () => {
+ const label = 'Lorem ipsum';
+ await render({ label });
+ expect(screen.getByText(label)).toBeInTheDocument();
+ });
+
+ it('Renders description if given', async () => {
+ const description = 'Lorem ipsum dolor sit amet.';
+ await render({ description });
+ expect(screen.getByText(description)).toBeInTheDocument();
+ });
+
+ it('Does not render search section by default', async () => {
+ await render();
+ expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
+ });
+
+ it('Renders search section when search button is clicked', async () => {
+ await renderAndOpenSearchSection();
+ await expect(screen.getByRole('combobox')).toBeInTheDocument();
+ });
+
+ it('Renders correct number of options in search section', async () => {
+ await renderAndOpenSearchSection();
+ const combobox = screen.getByRole('combobox');
+ expect(combobox).toBeInTheDocument();
+ await act(() => user.click(combobox));
+ expect(screen.getAllByRole('option')).toHaveLength(textResources.length + 1); // + 1 because of the "none" option
+ });
+
+ it('Calls handleIdChange when selection in search section is changed', async () => {
+ await renderAndOpenSearchSection();
+ await act(() =>
+ user.click(
+ screen.getByRole('combobox', { name: textMock('ux_editor.search_text_resources_label') }),
+ ),
+ );
+ await act(() => user.click(screen.getByRole('option', { name: textResources[1].id })));
+ expect(handleIdChange).toHaveBeenCalledTimes(1);
+ expect(handleIdChange).toHaveBeenCalledWith(textResources[1].id);
+ });
+
+ it('Calls handleIdChange with undefined when "none" is selected', async () => {
+ await renderAndOpenSearchSection();
+ await act(() =>
+ user.click(
+ screen.getByRole('combobox', { name: textMock('ux_editor.search_text_resources_label') }),
+ ),
+ );
+ await act(() =>
+ user.click(
+ screen.getByRole('option', { name: textMock('ux_editor.search_text_resources_none') }),
+ ),
+ );
+ expect(handleIdChange).toHaveBeenCalledTimes(1);
+ expect(handleIdChange).toHaveBeenCalledWith(undefined);
+ });
+
+ it('Closes search section when close button is clicked', async () => {
+ await renderAndOpenSearchSection();
+ await act(() =>
+ user.click(screen.getByLabelText(textMock('ux_editor.search_text_resources_close'))),
+ );
+ expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
+ });
+
+ it('Renders confirm dialog when delete button is clicked', async () => {
+ await render({ textResourceId: 'test', handleRemoveTextResource: jest.fn() });
+ await act(() => user.click(screen.getByRole('button', { name: textMock('general.delete') })));
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(
+ screen.getByText(textMock('ux_editor.text_resource_bindings.delete_confirm')),
+ ).toBeInTheDocument();
+ });
+
+ it('Calls handleRemoveTextResourceBinding is called when confirm delete button is clicked', async () => {
+ const handleRemoveTextResource = jest.fn();
+ await render({ handleRemoveTextResource, textResourceId: 'test' });
+ await act(() => user.click(screen.getByRole('button', { name: textMock('general.delete') })));
+ await act(() =>
+ user.click(
+ screen.getByRole('button', {
+ name: textMock('ux_editor.text_resource_bindings.delete_confirm'),
+ }),
+ ),
+ );
+ expect(handleRemoveTextResource).toHaveBeenCalledTimes(1);
+ });
+
+ it('Does not call handleRemoveTextResourceBinding is called when cancel delete button is clicked', async () => {
+ const handleRemoveTextResource = jest.fn();
+ await render({ handleRemoveTextResource, textResourceId: 'test' });
+ await act(() => user.click(screen.getByRole('button', { name: textMock('general.delete') })));
+ await act(() => user.click(screen.getByRole('button', { name: textMock('general.cancel') })));
+ expect(handleRemoveTextResource).not.toHaveBeenCalled();
+ });
+
+ it('Renders delete button as disabled when no handleRemoveTextResource is given', async () => {
+ await render();
+ expect(screen.getByRole('button', { name: textMock('general.delete') })).toBeDisabled();
+ });
+
+ it('Renders delete button as disabled when handleRemoveTextResource is given, but no resource id is given', async () => {
+ await render({ handleRemoveTextResource: jest.fn() });
+ expect(screen.getByRole('button', { name: textMock('general.delete') })).toBeDisabled();
+ });
+
+ it('Renders delete button as enabled when handleRemoveTextResource and resource id is given', async () => {
+ await render({ textResourceId: 'test', handleRemoveTextResource: jest.fn() });
+ expect(screen.getByRole('button', { name: textMock('general.delete') })).toBeEnabled();
+ });
+
+ it('Renders delete button as enabled when handleRemoveTextResource is given and componentConfigBeta feature flag is enabled', async () => {
+ addFeatureFlagToLocalStorage('componentConfigBeta');
+ await render({ textResourceId: 'test', handleRemoveTextResource: jest.fn() });
+ expect(screen.getByRole('button', { name: textMock('general.delete') })).toBeEnabled();
+ });
+});
+
+const renderAndOpenSearchSection = async () => {
+ await render(undefined, textResources);
+ await act(() => user.click(screen.getByLabelText(textMock('general.search'))));
+};
+
+const waitForData = async (resources: ITextResource[]) => {
+ const { result } = renderHookWithMockStore(
+ {},
+ {
+ getTextResources: jest.fn().mockImplementation(() =>
+ Promise.resolve({
+ language: DEFAULT_LANGUAGE,
+ resources,
+ }),
+ ),
+ getTextLanguages: jest.fn().mockImplementation(() => Promise.resolve(textLanguagesMock)),
+ },
+ )(() => useTextResourcesQuery(org, app)).renderHookResult;
+ const layoutSchemaResult = renderHookWithMockStore()(() => useLayoutSchemaQuery())
+ .renderHookResult.result;
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ await waitFor(() => expect(layoutSchemaResult.current[0].isSuccess).toBe(true));
+};
+
+const render = async (props: Partial = {}, resources: ITextResource[] = []) => {
+ await waitForData(resources);
+
+ return renderWithMockStore()();
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/TextResource.tsx b/frontend/packages/ux-editor-v3/src/components/TextResource.tsx
new file mode 100644
index 00000000000..2cff0882d5d
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/TextResource.tsx
@@ -0,0 +1,245 @@
+import React, { useState } from 'react';
+import type { LegacySingleSelectOption } from '@digdir/design-system-react';
+import { LegacySelect, Paragraph } from '@digdir/design-system-react';
+import {
+ MagnifyingGlassIcon,
+ PencilIcon,
+ PlusIcon,
+ TrashIcon,
+ XMarkIcon,
+} from '@navikt/aksel-icons';
+import classes from './TextResource.module.css';
+import { useDispatch, useSelector } from 'react-redux';
+import { setCurrentEditId } from '../features/appData/textResources/textResourcesSlice';
+import { DEFAULT_LANGUAGE } from 'app-shared/constants';
+import {
+ allTextResourceIdsWithTextSelector,
+ getCurrentEditId,
+ textResourceByLanguageAndIdSelector,
+} from '../selectors/textResourceSelectors';
+import { generateRandomId } from 'app-shared/utils/generateRandomId';
+import { generateTextResourceId } from '../utils/generateId';
+import { useText } from '../hooks';
+import { prepend } from 'app-shared/utils/arrayUtils';
+import cn from 'classnames';
+import type { ITextResource } from 'app-shared/types/global';
+import { useTextResourcesSelector } from '../hooks';
+import { FormField } from './FormField';
+import { AltinnConfirmDialog } from 'app-shared/components/AltinnConfirmDialog';
+import { useTranslation } from 'react-i18next';
+import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils';
+import { StudioButton } from '@studio/components';
+
+export interface TextResourceProps {
+ description?: string;
+ handleIdChange: (id: string) => void;
+ handleRemoveTextResource?: () => void;
+ label?: string;
+ placeholder?: string;
+ previewMode?: boolean;
+ textResourceId?: string;
+ generateIdOptions?: GenerateTextResourceIdOptions;
+}
+
+export interface GenerateTextResourceIdOptions {
+ componentId: string;
+ layoutId: string;
+ textResourceKey: string;
+}
+
+export const generateId = (options?: GenerateTextResourceIdOptions) => {
+ if (!options) {
+ return generateRandomId(12);
+ }
+ return generateTextResourceId(options.layoutId, options.componentId, options.textResourceKey);
+};
+
+export const TextResource = ({
+ description,
+ handleIdChange,
+ handleRemoveTextResource,
+ label,
+ placeholder,
+ previewMode,
+ textResourceId,
+ generateIdOptions,
+}: TextResourceProps) => {
+ const dispatch = useDispatch();
+
+ const textResource: ITextResource = useTextResourcesSelector(
+ textResourceByLanguageAndIdSelector(DEFAULT_LANGUAGE, textResourceId),
+ );
+ const textResources: ITextResource[] = useTextResourcesSelector(
+ allTextResourceIdsWithTextSelector(DEFAULT_LANGUAGE),
+ );
+ const { t } = useTranslation();
+ const [isSearchMode, setIsSearchMode] = useState(false);
+ const [isConfirmDeleteDialogOpen, setIsConfirmDeleteDialogOpen] = useState(false);
+
+ const editId = useSelector(getCurrentEditId);
+ const setEditId = (id: string) => dispatch(setCurrentEditId(id));
+ const isEditing = textResourceId && editId === textResourceId;
+
+ const handleEditButtonClick = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ if (textResourceId) {
+ setEditId(textResourceId);
+ } else {
+ const id = generateId(generateIdOptions);
+ handleIdChange(id);
+ setEditId(id);
+ }
+ };
+
+ const handleDeleteButtonClick = (event: React.MouseEvent) => {
+ handleRemoveTextResource();
+ };
+
+ const searchOptions: LegacySingleSelectOption[] = prepend(
+ textResources.map((tr) => ({
+ label: tr.id,
+ value: tr.id,
+ formattedLabel: ,
+ keywords: [tr.id, tr.value],
+ })),
+ { label: t('ux_editor.search_text_resources_none'), value: '' },
+ );
+
+ const renderTextResource = () => (
+
+ {label && {label}}
+ {description && {description}}
+ {isSearchMode && (
+
+
+ handleIdChange(id === '' ? undefined : id)}
+ options={searchOptions}
+ value={textResource?.id ?? ''}
+ />
+
+ }
+ onClick={() => setIsSearchMode(false)}
+ title={t('ux_editor.search_text_resources_close')}
+ variant='tertiary'
+ size='small'
+ />
+
+ )}
+
+ {textResource?.value ? (
+ {textResource.value}
+ ) : (
+ {placeholder}
+ )}
+
+
+ {textResource?.value ? (
+ }
+ onClick={handleEditButtonClick}
+ title={t('general.edit')}
+ variant='tertiary'
+ size='small'
+ />
+ ) : (
+ }
+ onClick={handleEditButtonClick}
+ title={t('general.add')}
+ variant='tertiary'
+ size='small'
+ />
+ )}
+ }
+ onClick={() => setIsSearchMode(true)}
+ title={t('general.search')}
+ variant='tertiary'
+ size='small'
+ />
+ setIsConfirmDeleteDialogOpen(false)}
+ trigger={
+ }
+ onClick={() => setIsConfirmDeleteDialogOpen(true)}
+ title={t('general.delete')}
+ variant='tertiary'
+ size='small'
+ />
+ }
+ >
+
+
{t('ux_editor.text_resource_bindings.delete_confirm_question')}
+
{t('ux_editor.text_resource_bindings.delete_info')}
+
+
+
+
+
+
+ );
+
+ return previewMode ? (
+ renderTextResource()
+ ) : (
+ renderTextResource()}
+ />
+ );
+};
+
+export interface TextResourceOptionProps {
+ textResource: ITextResource;
+}
+
+export const TextResourceOption = ({ textResource }: TextResourceOptionProps) => {
+ const t = useText();
+ return (
+
+ {textResource.id}
+
+ {textResource.value || t('ux_editor.no_text')}
+
+
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/TextResourceEdit.module.css b/frontend/packages/ux-editor-v3/src/components/TextResourceEdit.module.css
new file mode 100644
index 00000000000..4fb7d408ff6
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/TextResourceEdit.module.css
@@ -0,0 +1,5 @@
+.textBoxList {
+ display: flex;
+ flex-direction: column;
+ gap: 2rem;
+}
diff --git a/frontend/packages/ux-editor-v3/src/components/TextResourceEdit.test.tsx b/frontend/packages/ux-editor-v3/src/components/TextResourceEdit.test.tsx
new file mode 100644
index 00000000000..d86f0ef0493
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/TextResourceEdit.test.tsx
@@ -0,0 +1,193 @@
+import type { RefObject } from 'react';
+import React, { createRef } from 'react';
+import type { IAppDataState } from '../features/appData/appDataReducers';
+import type { ITextResourcesState } from '../features/appData/textResources/textResourcesSlice';
+import type { ITextResources, ITextResourcesWithLanguage } from 'app-shared/types/global';
+import userEvent from '@testing-library/user-event';
+import { TextResourceEdit } from './TextResourceEdit';
+import { queriesMock } from 'app-shared/mocks/queriesMock';
+import { queryClientMock } from 'app-shared/mocks/queryClientMock';
+import { renderHookWithMockStore, renderWithMockStore, textLanguagesMock } from '../testing/mocks';
+import { appDataMock, textResourcesMock } from '../testing/stateMocks';
+import { act, fireEvent, screen, waitFor } from '@testing-library/react';
+import { mockUseTranslation } from '../../../../testing/mocks/i18nMock';
+import { useTextResourcesQuery } from 'app-shared/hooks/queries/useTextResourcesQuery';
+import { appContextMock } from '../testing/appContextMock';
+
+const user = userEvent.setup();
+
+// Test data:
+const org = 'org';
+const app = 'app';
+const legendText = 'Rediger tekst';
+const descriptionText = 'Tekstens ID: {{id}}';
+const nbText = 'Bokmål';
+const nnText = 'Nynorsk';
+const enText = 'Engelsk';
+const closeText = 'Lukk';
+const texts = {
+ 'general.close': closeText,
+ 'language.nb': nbText,
+ 'language.nn': nnText,
+ 'language.en': enText,
+ 'ux_editor.edit_text_resource': legendText,
+ 'ux_editor.field_id': descriptionText,
+};
+
+// Mocks:
+jest.mock('react-i18next', () => ({ useTranslation: () => mockUseTranslation(texts) }));
+
+describe('TextResourceEdit', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ queryClientMock.clear();
+ });
+
+ it('Does not render anything if edit id is undefined', async () => {
+ await render();
+ expect(screen.queryByText(legendText)).not.toBeInTheDocument();
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: closeText })).not.toBeInTheDocument();
+ });
+
+ it('Renders correctly when a valid edit id is given', async () => {
+ const id = 'some-id';
+ const valueNb = 'Norge';
+ const valueNn = 'Noreg';
+ const valueEn = 'Norway';
+ const resources: ITextResources = {
+ nb: [{ id, value: valueNb }],
+ nn: [{ id, value: valueNn }],
+ en: [{ id, value: valueEn }],
+ };
+ await render(resources, id);
+ expect(screen.getByText(legendText)).toBeInTheDocument();
+ expect(screen.getAllByRole('textbox')).toHaveLength(3);
+ expect(screen.getByLabelText(nbText)).toHaveValue(valueNb);
+ expect(screen.getByLabelText(nnText)).toHaveValue(valueNn);
+ expect(screen.getByLabelText(enText)).toHaveValue(valueEn);
+ expect(screen.getByRole('button', { name: closeText })).toBeInTheDocument();
+ });
+
+ it('Calls upsertTextResources with correct parameters when a text is changed', async () => {
+ const id = 'some-id';
+ const value = 'Lorem';
+ const additionalValue = ' ipsum';
+ const resources: ITextResources = { nb: [{ id, value }] };
+ await render(resources, id);
+ const textBox = screen.getByLabelText(nbText);
+ await act(() => user.type(textBox, additionalValue));
+ await act(() => user.tab());
+ expect(queriesMock.upsertTextResources).toHaveBeenCalledTimes(1);
+ expect(queriesMock.upsertTextResources).toHaveBeenCalledWith(org, app, 'nb', {
+ [id]: value + additionalValue,
+ });
+ });
+
+ it('Check if reload is called when text is updated', async () => {
+ const id = 'some-id';
+ const value = 'Lorem';
+ const additionalValue = ' ipsum';
+ const resources: ITextResources = { nb: [{ id, value }] };
+ const previewIframeRefMock = createRef();
+ const reload = jest.fn();
+ const previewIframeRef: RefObject = {
+ current: {
+ ...previewIframeRefMock.current,
+ contentWindow: {
+ ...previewIframeRefMock.current?.contentWindow,
+ location: {
+ ...previewIframeRefMock.current?.contentWindow?.location,
+ reload,
+ },
+ },
+ },
+ };
+ await render(resources, id, previewIframeRef);
+ const textBox = screen.getByLabelText(nbText);
+ await act(async () => user.type(textBox, additionalValue));
+ await act(async () => user.tab());
+ expect(reload).toHaveBeenCalledTimes(1);
+ });
+
+ it('upsertTextResources should not be called when the text is NOT changed', async () => {
+ const id = 'some-id';
+ const value = 'Lorem';
+ const resources: ITextResources = { nb: [{ id, value }] };
+ await render(resources, id);
+ const textBox = screen.getByLabelText(nbText);
+ await act(() => user.clear(textBox));
+ await act(() => user.type(textBox, value));
+ await act(() => user.tab());
+ expect(queriesMock.upsertTextResources).not.toHaveBeenCalled();
+ });
+
+ it('upsertTextResources should not be called when the text resource does not exist and the text is empty', async () => {
+ const id = 'some-id';
+ const resources: ITextResources = { nb: [] };
+ await render(resources, id);
+ const textBox = screen.getByLabelText(nbText);
+ await act(() => user.clear(textBox));
+ await act(() => user.tab());
+ expect(queriesMock.upsertTextResources).not.toHaveBeenCalled();
+ });
+
+ it('Does not throw any error when the user clicks inside and outside the text field without modifying the text', async () => {
+ const id = 'some-id';
+ const value = 'Lorem';
+ const resources: ITextResources = { nb: [{ id, value }] };
+ await render(resources, id);
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
+ const textBox = screen.getByLabelText(nbText);
+ fireEvent.click(textBox);
+ fireEvent.click(document.body);
+ expect(consoleErrorSpy).not.toHaveBeenCalled();
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('Dispatches correct action when the close button is clicked', async () => {
+ const id = 'some-id';
+ const value = 'Lorem';
+ const resources = { nb: [{ id, value }] };
+ const { store } = await render(resources, id);
+ await act(() => user.click(screen.getByRole('button', { name: closeText })));
+ const actions = store.getActions();
+ expect(actions).toHaveLength(1);
+ expect(actions[0].type).toBe('textResources/setCurrentEditId');
+ expect(actions[0].payload).toBeUndefined();
+ });
+});
+
+const render = async (
+ resources: ITextResources = {},
+ editId?: string,
+ previewIframeRef: RefObject = appContextMock.previewIframeRef,
+) => {
+ const textResources: ITextResourcesState = {
+ ...textResourcesMock,
+ currentEditId: editId,
+ };
+
+ const appData: IAppDataState = {
+ ...appDataMock,
+ textResources,
+ };
+
+ const { result } = renderHookWithMockStore(
+ { appData },
+ {
+ getTextLanguages: jest.fn().mockImplementation(() => Promise.resolve(textLanguagesMock)),
+ getTextResources: (_o, _a, lang) =>
+ Promise.resolve({
+ language: lang,
+ resources: resources[lang] || [],
+ }),
+ },
+ undefined,
+ { previewIframeRef },
+ )(() => useTextResourcesQuery(org, app)).renderHookResult;
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ return renderWithMockStore({ appData }, {}, undefined, { previewIframeRef })(
+ ,
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/TextResourceEdit.tsx b/frontend/packages/ux-editor-v3/src/components/TextResourceEdit.tsx
new file mode 100644
index 00000000000..58867d71994
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/TextResourceEdit.tsx
@@ -0,0 +1,101 @@
+import React, { useEffect, useState } from 'react';
+import classes from './TextResourceEdit.module.css';
+import type { ITextResource } from 'app-shared/types/global';
+import { Fieldset, LegacyTextArea } from '@digdir/design-system-react';
+import { XMarkIcon } from '@navikt/aksel-icons';
+import { getAllLanguages, getCurrentEditId } from '../selectors/textResourceSelectors';
+import { setCurrentEditId } from '../features/appData/textResources/textResourcesSlice';
+import { useDispatch, useSelector } from 'react-redux';
+import { useTextResourcesSelector } from '../hooks';
+import { useUpsertTextResourcesMutation } from 'app-shared/hooks/mutations';
+import { useTranslation } from 'react-i18next';
+import { useTextResourcesQuery } from 'app-shared/hooks/queries';
+import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams';
+import { useAppContext } from '../hooks/useAppContext';
+import { StudioButton } from '@studio/components';
+
+export const TextResourceEdit = () => {
+ const dispatch = useDispatch();
+ const editId = useSelector(getCurrentEditId);
+ const { org, app } = useStudioUrlParams();
+ const { data: textResources } = useTextResourcesQuery(org, app);
+ const languages: string[] = useTextResourcesSelector(getAllLanguages);
+ const setEditId = (id: string) => dispatch(setCurrentEditId(id));
+ const { t } = useTranslation();
+
+ if (!editId) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+export interface TextBoxProps {
+ language: string;
+ t: (key: string) => string;
+ textResource?: ITextResource;
+ textResourceId: string;
+}
+
+const TextBox = ({ language, t, textResource, textResourceId }: TextBoxProps) => {
+ const { org, app } = useStudioUrlParams();
+ const { mutate } = useUpsertTextResourcesMutation(org, app);
+
+ const { previewIframeRef } = useAppContext();
+ const textResourceValue = textResource?.value || '';
+
+ const updateTextResource = (text: string) => {
+ if (text === textResourceValue) return;
+
+ mutate({
+ language,
+ textResources: [{ id: textResourceId, value: text, variables: textResource?.variables }],
+ });
+ previewIframeRef.current?.contentWindow.location.reload();
+ };
+
+ const [value, setValue] = useState(textResourceValue);
+
+ useEffect(() => {
+ setValue(textResourceValue);
+ }, [textResourceValue]);
+
+ return (
+
+ updateTextResource((e.target as HTMLTextAreaElement).value)}
+ onChange={(e) => setValue((e.target as HTMLTextAreaElement).value)}
+ value={value}
+ />
+
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/TextResourceOption.test.tsx b/frontend/packages/ux-editor-v3/src/components/TextResourceOption.test.tsx
new file mode 100644
index 00000000000..54da2f6286a
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/TextResourceOption.test.tsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import type { ITextResource } from 'app-shared/types/global';
+import type { TextResourceOptionProps } from './TextResource';
+import { TextResourceOption } from './TextResource';
+import { render as renderRtl, screen } from '@testing-library/react';
+import { mockUseTranslation } from '../../../../testing/mocks/i18nMock';
+
+// Test data:
+const id = 'testid';
+const value = 'testvalue';
+const textResource: ITextResource = { id, value };
+const defaultProps: TextResourceOptionProps = { textResource };
+const noTextText = 'Ingen tekst';
+const texts = { 'ux_editor.no_text': noTextText };
+
+jest.mock('react-i18next', () => ({ useTranslation: () => mockUseTranslation(texts) }));
+
+describe('TextResourceOption', () => {
+ it('Renders id and value', () => {
+ render();
+ expect(screen.getByText(id)).toBeInTheDocument();
+ expect(screen.getByText(value)).toBeInTheDocument();
+ });
+
+ it('Renders "no text" text when there is no text value', () => {
+ render({ textResource: { id, value: '' } });
+ expect(screen.getByText(id)).toBeInTheDocument();
+ expect(screen.getByText(noTextText)).toBeInTheDocument();
+ });
+});
+
+const render = (props?: Partial) =>
+ renderRtl();
diff --git a/frontend/packages/ux-editor-v3/src/components/UnknownComponentAlert/UnknownComponentAlert.test.tsx b/frontend/packages/ux-editor-v3/src/components/UnknownComponentAlert/UnknownComponentAlert.test.tsx
new file mode 100644
index 00000000000..87786c22908
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/UnknownComponentAlert/UnknownComponentAlert.test.tsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { UnknownComponentAlert } from './UnknownComponentAlert';
+import { textMock } from '../../../../../testing/mocks/i18nMock';
+
+describe('UnknownComponentAlert', () => {
+ it('should render information about unknown component', () => {
+ render();
+ expect(
+ screen.getByText(
+ textMock('ux_editor.edit_component.unknown_component', {
+ componentName: 'UnknownComponentName',
+ }),
+ ),
+ );
+ });
+
+ it('should be possible to pass native HTML attributes', () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole('alert')).toHaveClass('myCustomClass');
+ });
+});
diff --git a/frontend/packages/ux-editor-v3/src/components/UnknownComponentAlert/UnknownComponentAlert.tsx b/frontend/packages/ux-editor-v3/src/components/UnknownComponentAlert/UnknownComponentAlert.tsx
new file mode 100644
index 00000000000..57ea58affa6
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/UnknownComponentAlert/UnknownComponentAlert.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import type { AlertProps } from '@digdir/design-system-react';
+import { Alert } from '@digdir/design-system-react';
+import { useTranslation } from 'react-i18next';
+
+export type UnknownComponentAlertProps = {
+ componentName: string;
+} & AlertProps;
+export const UnknownComponentAlert = ({
+ componentName,
+ ...rest
+}: UnknownComponentAlertProps): JSX.Element => {
+ const { t } = useTranslation();
+ return (
+
+ {t('ux_editor.edit_component.unknown_component', {
+ componentName,
+ })}
+
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/UnknownComponentAlert/index.ts b/frontend/packages/ux-editor-v3/src/components/UnknownComponentAlert/index.ts
new file mode 100644
index 00000000000..f689cde3088
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/UnknownComponentAlert/index.ts
@@ -0,0 +1 @@
+export { UnknownComponentAlert, type UnknownComponentAlertProps } from './UnknownComponentAlert';
diff --git a/frontend/packages/ux-editor-v3/src/components/UnsupportedVersionMessage/UnsupportedVersionMessage.module.css b/frontend/packages/ux-editor-v3/src/components/UnsupportedVersionMessage/UnsupportedVersionMessage.module.css
new file mode 100644
index 00000000000..063da51dc70
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/UnsupportedVersionMessage/UnsupportedVersionMessage.module.css
@@ -0,0 +1,11 @@
+.wrapper {
+ display: flex;
+ height: calc(100vh - var(--header-height));
+ align-items: center;
+ justify-content: center;
+}
+
+.message {
+ max-width: 600px;
+ width: 100%;
+}
diff --git a/frontend/packages/ux-editor-v3/src/components/UnsupportedVersionMessage/UnsupportedVersionMessage.test.tsx b/frontend/packages/ux-editor-v3/src/components/UnsupportedVersionMessage/UnsupportedVersionMessage.test.tsx
new file mode 100644
index 00000000000..bf94ea1ba93
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/UnsupportedVersionMessage/UnsupportedVersionMessage.test.tsx
@@ -0,0 +1,76 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import type { UnsupportedVersionMessageProps } from './UnsupportedVersionMessage';
+import { UnsupportedVersionMessage } from './UnsupportedVersionMessage';
+import { textMock } from '../../../../../testing/mocks/i18nMock';
+
+describe('UnsupportedVersionMessage', () => {
+ it('should render without crashing', () => {
+ const { baseElement } = renderUnsupportedVersionMessage();
+ expect(baseElement).toBeTruthy();
+ });
+
+ it('should render the correct title', () => {
+ renderUnsupportedVersionMessage();
+ expect(
+ screen.getByText(
+ textMock('ux_editor.unsupported_version_message_title', { version: '2.0.0' }),
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it('should correctly render body text for too-old category', () => {
+ const category = 'too-old';
+ const version = 'v1';
+ const closestSupportedVersion = 'v2';
+ renderUnsupportedVersionMessage({ category, version, closestSupportedVersion });
+ expect(
+ screen.getByText(
+ textMock('ux_editor.unsupported_version_message.too_old_1', {
+ version,
+ closestSupportedVersion,
+ }),
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it('should correctly render body text for too-new category', () => {
+ const category = 'too-new';
+ const version = 'v4';
+ const closestSupportedVersion = 'v3';
+ renderUnsupportedVersionMessage({ category, version, closestSupportedVersion });
+ expect(
+ screen.getByText(
+ textMock('ux_editor.unsupported_version_message.too_new_1', {
+ version,
+ closestSupportedVersion,
+ }),
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ textMock('ux_editor.unsupported_version_message.too_new_2', {
+ version,
+ closestSupportedVersion,
+ }),
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ textMock('ux_editor.unsupported_version_message.too_new_3', {
+ version,
+ closestSupportedVersion,
+ }),
+ ),
+ ).toBeInTheDocument();
+ });
+
+ const renderUnsupportedVersionMessage = (props: Partial = {}) => {
+ const defaultProps: UnsupportedVersionMessageProps = {
+ version: '2.0.0',
+ closestSupportedVersion: '1.0.0',
+ category: 'too-old',
+ };
+ return render();
+ };
+});
diff --git a/frontend/packages/ux-editor-v3/src/components/UnsupportedVersionMessage/UnsupportedVersionMessage.tsx b/frontend/packages/ux-editor-v3/src/components/UnsupportedVersionMessage/UnsupportedVersionMessage.tsx
new file mode 100644
index 00000000000..ea05de3a7ba
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/UnsupportedVersionMessage/UnsupportedVersionMessage.tsx
@@ -0,0 +1,48 @@
+import React from 'react';
+
+import classes from './UnsupportedVersionMessage.module.css';
+import { Alert, Heading, Paragraph } from '@digdir/design-system-react';
+import { useTranslation } from 'react-i18next';
+
+export interface UnsupportedVersionMessageProps {
+ version: string;
+ closestSupportedVersion: string;
+ category: 'too-old' | 'too-new';
+}
+
+const getBodyTextKeys = (category: 'too-old' | 'too-new') => {
+ if (category === 'too-old') {
+ return ['ux_editor.unsupported_version_message.too_old_1'];
+ }
+
+ return [
+ 'ux_editor.unsupported_version_message.too_new_1',
+ 'ux_editor.unsupported_version_message.too_new_2',
+ 'ux_editor.unsupported_version_message.too_new_3',
+ ];
+};
+
+export function UnsupportedVersionMessage({
+ version,
+ closestSupportedVersion,
+ category,
+}: UnsupportedVersionMessageProps) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ {t('ux_editor.unsupported_version_message_title', { version })}
+
+ {getBodyTextKeys(category).map((key) => {
+ return (
+
+ {t(key, { version, closestSupportedVersion })}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/frontend/packages/ux-editor-v3/src/components/UnsupportedVersionMessage/index.ts b/frontend/packages/ux-editor-v3/src/components/UnsupportedVersionMessage/index.ts
new file mode 100644
index 00000000000..e7ac9d60b39
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/UnsupportedVersionMessage/index.ts
@@ -0,0 +1 @@
+export { UnsupportedVersionMessage } from './UnsupportedVersionMessage';
diff --git a/frontend/packages/ux-editor-v3/src/components/config/ConditionalRenderingComponent.module.css b/frontend/packages/ux-editor-v3/src/components/config/ConditionalRenderingComponent.module.css
new file mode 100644
index 00000000000..8fe2b1c480b
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/ConditionalRenderingComponent.module.css
@@ -0,0 +1,165 @@
+:root {
+ --primary-color: #008fd6;
+ --primary-color-700: #022f51;
+ --success-color: #17c96b;
+ --danger-color: #e23b53;
+ --text-color: #000;
+ --paper-color: #fff;
+ --disabled-color: #e9ecef;
+ --overlay-gradient: rgba(30, 174, 247, 0.3);
+}
+.modalBody {
+ max-width: 800px;
+ width: 100%;
+ background: var(--paper-color);
+ margin-top: 30px;
+}
+
+.modalHeader {
+ min-height: 80px;
+ background: var(--primary-color-700);
+ padding: 12px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ color: var(--paper-color);
+}
+
+.modalHeaderTitle {
+ font-size: 1.5rem;
+ font-weight: normal;
+}
+
+.configConditionalIcon {
+ font-size: 2.25em;
+}
+
+.modalBodyContent {
+ padding: 30px;
+}
+
+.formGroup {
+ display: flex;
+ flex-direction: column;
+}
+
+.label {
+ display: block;
+ margin-bottom: 8px;
+}
+
+.customSelect {
+ padding: 8px;
+ margin-bottom: 8px;
+ border: 2px solid var(--primary-color);
+}
+
+.configureInputParamsContainer {
+ display: grid;
+ grid-template-columns: 1fr 10fr;
+ gap: 8px;
+}
+
+.chooseComponentContainer {
+ display: flex;
+ align-items: center;
+ gap: 8;
+}
+
+.selectActionContainer {
+ display: flex;
+ flex-direction: column;
+}
+
+.inputType {
+ font-size: 16px;
+ padding: 8px;
+ max-width: 80px;
+ color: var(--text-color);
+ letter-spacing: 0.3px;
+ border-radius: 0;
+ transition: none;
+ background: var(--disabled-color);
+ border: 2px solid var(--primary-color);
+}
+
+.exitIcon {
+ font-size: 2.25em;
+}
+
+.addFieldButton {
+ font-weight: 'normal';
+ border: none;
+ cursor: pointer;
+ font-size: 1.1rem;
+ border-radius: 0;
+ max-width: 240px;
+ margin-top: 20px;
+ margin-bottom: 12px;
+ position: relative;
+ display: inline-block;
+ padding: 6px 24px 4px 24px;
+ color: var(--text-color);
+ background: var(--primary-color);
+}
+
+.saveButton {
+ color: #000;
+ font-size: 1rem;
+ padding: 8px 30px;
+ background-color: var(--success-color);
+ border: 2px solid var(--success-color);
+}
+
+.cancelButton {
+ font-size: 1rem;
+ padding: 8px 30px;
+ color: var(--text-color);
+ border: 2px solid var(--text-color);
+}
+
+.dangerButton {
+ font-size: 1rem;
+ padding: 8px 30px;
+ color: var(--paper-color);
+ background-color: var(--danger-color);
+ border: 2px solid var(--danger-color);
+}
+
+.deleteFieldButton {
+ border: 0;
+ font-size: 1rem;
+ width: min-content;
+ background: transparent;
+ color: var(--danger-color);
+}
+
+button:hover {
+ cursor: pointer;
+}
+
+.buttonsContainer {
+ display: flex;
+ gap: 12px;
+ margin-top: 8px;
+}
+
+.subTitle {
+ font-size: 18px;
+}
+
+.reactModalOverlay {
+ display: flex;
+ justify-content: center;
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ background-color: var(--overlay-gradient);
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ overflow-y: auto;
+ z-index: 1000;
+}
diff --git a/frontend/packages/ux-editor-v3/src/components/config/ConditionalRenderingComponent.tsx b/frontend/packages/ux-editor-v3/src/components/config/ConditionalRenderingComponent.tsx
new file mode 100644
index 00000000000..31dfd01a7f0
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/ConditionalRenderingComponent.tsx
@@ -0,0 +1,441 @@
+import React from 'react';
+import { v1 as uuidv1 } from 'uuid';
+import Modal from 'react-modal';
+import { getComponentTitleByComponentType } from '../../utils/language';
+import { SelectDataModelComponent } from './SelectDataModelComponent';
+import type {
+ IFormDesignerComponents,
+ IFormDesignerContainers,
+ IFormLayoutOrder,
+ IRuleModelFieldElement,
+} from '../../types/global';
+import classes from './ConditionalRenderingComponent.module.css';
+import { withTranslation } from 'react-i18next';
+import { BASE_CONTAINER_ID } from 'app-shared/constants';
+import type {
+ ConditionalRenderingConnection,
+ ConditionalRenderingConnections,
+} from 'app-shared/types/RuleConfig';
+import type i18next from 'i18next';
+import type { FormComponent } from '../../types/FormComponent';
+import { Buldings2Icon, XMarkOctagonFillIcon } from '@navikt/aksel-icons';
+import type { FormContainer } from '../../types/FormContainer';
+
+export interface IConditionalRenderingComponentProps {
+ connectionId?: string;
+ cancelEdit: () => void;
+ saveEdit: (id: string, connection: ConditionalRenderingConnection) => void;
+ ruleModelElements: IRuleModelFieldElement[];
+ conditionalRendering: ConditionalRenderingConnections;
+ formLayoutComponents: IFormDesignerComponents;
+ deleteConnection?: (connectionId: string) => void;
+ formLayoutContainers: IFormDesignerContainers;
+ order: IFormLayoutOrder;
+ t: typeof i18next.t;
+}
+
+interface IConditionalRenderingComponentState {
+ selectedFunctionNr: number | null;
+ connectionId: string | null;
+ selectableActions: string[];
+ conditionalRendering: ConditionalRenderingConnection;
+}
+
+class ConditionalRendering extends React.Component<
+ IConditionalRenderingComponentProps,
+ IConditionalRenderingComponentState
+> {
+ constructor(props: IConditionalRenderingComponentProps) {
+ super(props);
+ const id = uuidv1();
+ this.state = {
+ selectedFunctionNr: null,
+ connectionId: null,
+ selectableActions: ['Show', 'Hide'],
+ conditionalRendering: {
+ selectedFunction: '',
+ inputParams: {},
+ selectedAction: '',
+ selectedFields: {
+ [id]: '',
+ },
+ },
+ };
+ }
+
+ /**
+ * Methods that maps a connection to the correct props if one opens an existing rule
+ * and creates a new connection id if one is trying to create a new connection
+ */
+ public componentDidMount() {
+ if (this.props.connectionId) {
+ for (let i = 0; this.props.ruleModelElements.length - 1; i++) {
+ // eslint-disable-next-line max-len
+ if (
+ this.props.ruleModelElements[i].name ===
+ this.props.conditionalRendering[this.props.connectionId].selectedFunction
+ ) {
+ this.setState({
+ selectedFunctionNr: i,
+ });
+ break;
+ }
+ }
+ this.setState({
+ connectionId: this.props.connectionId,
+ conditionalRendering: {
+ ...this.props.conditionalRendering[this.props.connectionId],
+ },
+ });
+ } else {
+ this.setState({ connectionId: uuidv1() });
+ }
+ }
+
+ /**
+ * Methods that handles the saving of a conditional rendering rule
+ */
+ public handleSaveEdit = (): void => {
+ this.props.saveEdit(this.state.connectionId, this.state.conditionalRendering);
+ };
+
+ /**
+ * Methods that updates which function is selected and is to be used when running the conditional rendering rule
+ */
+ public handleSelectedMethodChange = (e: any): void => {
+ const nr = e.target.selectedIndex - 1 < 0 ? null : e.target.selectedIndex - 1;
+ const value = e.target.value;
+ this.setState({
+ selectedFunctionNr: nr,
+ conditionalRendering: {
+ ...this.state.conditionalRendering,
+ selectedFunction: value,
+ },
+ });
+ };
+
+ /**
+ * Methods that updates which action is selected and is to be used when running the conditional rendering rule
+ */
+ public handleActionChange = (e: any): void => {
+ const value = e.target.value;
+ this.setState({
+ conditionalRendering: {
+ ...this.state.conditionalRendering,
+ selectedAction: value,
+ },
+ });
+ };
+
+ /**
+ * Methods that updates the input param connections to the datamodel
+ */
+ public handleParamDataChange = (paramName: any, value: any): void => {
+ this.setState({
+ conditionalRendering: {
+ ...this.state.conditionalRendering,
+ inputParams: {
+ ...this.state.conditionalRendering.inputParams,
+ [paramName]: value,
+ },
+ },
+ });
+ };
+
+ /**
+ * Methods that updates which layout components that should be affected when the conditional rendering rule runs
+ */
+ public handleFieldMappingChange = (id: any, e: any) => {
+ const value = e.target.value;
+ this.setState({
+ ...this.state,
+ conditionalRendering: {
+ ...this.state.conditionalRendering,
+ selectedFields: {
+ ...this.state.conditionalRendering.selectedFields,
+ [id]: value,
+ },
+ },
+ });
+ };
+
+ /**
+ * Methods that removes a layout component from the pool of layout
+ * compoenents that will be affected by the conditional rendering rule
+ */
+ public removeFieldMapping = (removeId: any) => {
+ this.setState({
+ ...this.state,
+ conditionalRendering: {
+ ...this.state.conditionalRendering,
+ selectedFields: Object.keys(this.state.conditionalRendering.selectedFields)
+ .filter((id: any) => id !== removeId)
+ .reduce((newSelectedFields, item) => {
+ return {
+ ...newSelectedFields,
+ [item]: this.state.conditionalRendering.selectedFields[item],
+ };
+ }, {}),
+ },
+ });
+ };
+
+ /**
+ * Methods that adds a new layout component to the GUI that will later be put in the pool of layout
+ * components that will be affected by the conditional rendering rule.
+ * On init this field is empty and not mapped to a layout compoenent
+ */
+ public addNewField = () => {
+ const newId = uuidv1();
+ this.setState({
+ ...this.state,
+ conditionalRendering: {
+ ...this.state.conditionalRendering,
+ selectedFields: {
+ ...this.state.conditionalRendering.selectedFields,
+ [newId]: '',
+ },
+ },
+ });
+ };
+
+ public handleDeleteConnection = () => {
+ this.props.deleteConnection(this.props.connectionId);
+ };
+
+ public renderConditionalRenderingTargetComponentOption = (id: string): JSX.Element => {
+ const component: FormComponent = this.props.formLayoutComponents[id];
+ const labelText = getComponentTitleByComponentType(component.type, this.props.t);
+ return (
+
+ );
+ };
+
+ public renderConditionalRenderingTargetContainerOptions = (
+ id: string,
+ baseContainer?: boolean,
+ ): JSX.Element[] => {
+ const options: JSX.Element[] = [];
+ if (!this.props.order[id]) {
+ return options;
+ }
+ if (!baseContainer) {
+ const container: FormContainer = this.props.formLayoutContainers[id];
+ const name = getComponentTitleByComponentType(container.type, this.props.t);
+ options.push(
+ ,
+ );
+ }
+ this.props.order[id].forEach((key) => {
+ if (this.props.formLayoutComponents[key]) {
+ const option = this.renderConditionalRenderingTargetComponentOption(key);
+ options.push(option);
+ } else {
+ // A container can have components and sub-containers
+ const containerOptions = this.renderConditionalRenderingTargetContainerOptions(key);
+ containerOptions.forEach((option) => {
+ options.push(option);
+ });
+ }
+ });
+ return options;
+ };
+
+ public renderConditionalRenderingTargetOptions = (): JSX.Element[] => {
+ const options: JSX.Element[] = [];
+ Object.keys(this.props.order).forEach((key) => {
+ const containerKey = Object.keys(this.props.order)[0];
+ const isBaseContainer = containerKey === BASE_CONTAINER_ID;
+ const containerOptions = this.renderConditionalRenderingTargetContainerOptions(
+ key,
+ isBaseContainer,
+ );
+ containerOptions.forEach((option) => {
+ options.push(option);
+ });
+ });
+ return options;
+ };
+
+ public render(): JSX.Element {
+ const selectedMethod = this.state.conditionalRendering.selectedFunction;
+ const selectedMethodNr = this.state.selectedFunctionNr;
+ return (
+ {}}
+ className={classes.modalBody}
+ ariaHideApp={false}
+ overlayClassName={classes.reactModalOverlay}
+ >
+
+
+
+ {this.props.t('ux_editor.modal_configure_conditional_rendering_header')}
+
+
+
+
+
+
+
+
+ {this.state.conditionalRendering.selectedFunction ? (
+ <>
+
+
+ {this.props.t(
+ 'ux_editor.modal_configure_conditional_rendering_configure_input_header',
+ )}
+
+ {Object.keys(this.props.ruleModelElements[selectedMethodNr].inputs).map(
+ (key: any) => {
+ const paramName = key;
+ return (
+ <>
+
+
+ >
+ );
+ },
+ )}
+
+
+
+ {this.props.t(
+ 'ux_editor.modal_configure_conditional_rendering_configure_output_header',
+ )}
+
+
+
+
+
+
+ {this.props.t(
+ 'ux_editor.modal_configure_conditional_rendering_configure_output_field_helper',
+ )}
+
+ {Object.keys(this.state.conditionalRendering.selectedFields).map((key: any) => {
+ return (
+
+
+
+
+
+ );
+ })}
+
+
+ >
+ ) : null}
+
+ {this.state.conditionalRendering.selectedFunction ? (
+
+ ) : null}
+ {this.props.connectionId ? (
+
+ ) : null}
+
+
+
+
+ );
+ }
+}
+
+export const ConditionalRenderingComponent = withTranslation()(ConditionalRendering);
diff --git a/frontend/packages/ux-editor-v3/src/components/config/EditFormComponent.module.css b/frontend/packages/ux-editor-v3/src/components/config/EditFormComponent.module.css
new file mode 100644
index 00000000000..a58cd493f9a
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/EditFormComponent.module.css
@@ -0,0 +1,13 @@
+.root {
+ --fieldset-gap: var(--fds-spacing-2);
+}
+
+.gridItem {
+ margin-top: 18px;
+}
+
+.root {
+ display: flex;
+ flex-direction: column;
+ gap: var(--fieldset-gap);
+}
diff --git a/frontend/packages/ux-editor-v3/src/components/config/EditFormComponent.test.tsx b/frontend/packages/ux-editor-v3/src/components/config/EditFormComponent.test.tsx
new file mode 100644
index 00000000000..969a11680a1
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/EditFormComponent.test.tsx
@@ -0,0 +1,270 @@
+import React from 'react';
+import { EditFormComponent } from './EditFormComponent';
+import { act, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import type { FormComponent } from '../../types/FormComponent';
+import { renderHookWithMockStore, renderWithMockStore } from '../../testing/mocks';
+import { useLayoutSchemaQuery } from '../../hooks/queries/useLayoutSchemaQuery';
+import { mockUseTranslation } from '../../../../../testing/mocks/i18nMock';
+import { ComponentType } from 'app-shared/types/ComponentType';
+import { useDatamodelMetadataQuery } from '../../hooks/queries/useDatamodelMetadataQuery';
+import type { DatamodelMetadataResponse } from 'app-shared/types/api';
+
+const user = userEvent.setup();
+
+// Test data:
+const srcValueLabel = 'Source';
+const texts = {
+ 'general.label': '',
+ 'general.value': '',
+ 'ux_editor.modal_header_type_h2': 'H2',
+ 'ux_editor.modal_header_type_h3': 'H3',
+ 'ux_editor.modal_header_type_h4': 'H4',
+ 'ux_editor.modal_properties_image_src_value_label': srcValueLabel,
+ 'ux_editor.modal_properties_image_placement_label': 'Placement',
+ 'ux_editor.modal_properties_image_alt_text_label': 'Alt text',
+ 'ux_editor.modal_properties_image_width_label': 'Width',
+};
+
+// Mocks:
+jest.mock('react-i18next', () => ({ useTranslation: () => mockUseTranslation(texts) }));
+const buttonSpecificContentId = 'button-specific-content';
+jest.mock('./componentSpecificContent/Button/ButtonComponent', () => ({
+ ButtonComponent: () => ,
+}));
+const imageSpecificContentId = 'image-specific-content';
+jest.mock('./componentSpecificContent/Image/ImageComponent', () => ({
+ ImageComponent: () => ,
+}));
+
+const getDatamodelMetadata = () =>
+ Promise.resolve({
+ elements: {
+ testModel: {
+ id: 'testModel',
+ type: 'ComplexType',
+ dataBindingName: 'testModel',
+ displayString: 'testModel',
+ isReadOnly: false,
+ isTagContent: false,
+ jsonSchemaPointer: '#/definitions/testModel',
+ maxOccurs: 1,
+ minOccurs: 1,
+ name: 'testModel',
+ parentElement: null,
+ restrictions: [],
+ texts: [],
+ xmlSchemaXPath: '/testModel',
+ xPath: '/testModel',
+ },
+ 'testModel.field1': {
+ id: 'testModel.field1',
+ type: 'SimpleType',
+ dataBindingName: 'testModel.field1',
+ displayString: 'testModel.field1',
+ isReadOnly: false,
+ isTagContent: false,
+ jsonSchemaPointer: '#/definitions/testModel/properteis/field1',
+ maxOccurs: 1,
+ minOccurs: 1,
+ name: 'testModel/field1',
+ parentElement: null,
+ restrictions: [],
+ texts: [],
+ xmlSchemaXPath: '/testModel/field1',
+ xPath: '/testModel/field1',
+ },
+ },
+ });
+
+describe('EditFormComponent', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('should return input specific content when type input', async () => {
+ await render({
+ componentProps: {
+ type: ComponentType.Input,
+ },
+ });
+
+ const labels = {
+ 'ux_editor.modal_properties_component_change_id': 'textbox',
+ 'ux_editor.modal_properties_data_model_helper': 'combobox',
+ 'ux_editor.modal_configure_read_only': 'checkbox',
+ };
+
+ const linkIcon = screen.getByText(/ux_editor.modal_properties_data_model_link/i);
+ await act(() => user.click(linkIcon));
+
+ Object.keys(labels).map(async (label) =>
+ expect(await screen.findByRole(labels[label], { name: label })),
+ );
+ expect(screen.getByRole('combobox'));
+ expect(screen.getByLabelText('Autocomplete (WCAG)'));
+ });
+
+ test('should return header specific content when type header', async () => {
+ await render({
+ componentProps: {
+ type: ComponentType.Header,
+ },
+ });
+
+ expect(screen.getByLabelText('ux_editor.modal_properties_component_change_id'));
+ await waitFor(() =>
+ expect(screen.getByRole('combobox', { name: 'ux_editor.modal_header_type_helper' })),
+ );
+ });
+
+ test('should return file uploader specific content when type file uploader', async () => {
+ await render({
+ componentProps: {
+ type: ComponentType.FileUpload,
+ },
+ });
+
+ const labels = [
+ 'ux_editor.modal_properties_component_change_id',
+ 'ux_editor.modal_properties_file_upload_simple',
+ 'ux_editor.modal_properties_file_upload_list',
+ 'ux_editor.modal_properties_valid_file_endings_all',
+ 'ux_editor.modal_properties_valid_file_endings_custom',
+ 'ux_editor.modal_properties_minimum_files',
+ 'ux_editor.modal_properties_maximum_files',
+ 'ux_editor.modal_properties_maximum_file_size (ux_editor.modal_properties_maximum_file_size_helper)',
+ ];
+
+ labels.map((label) => expect(screen.getByLabelText(label)));
+ });
+
+ test('should call handleComponentUpdate with max number of attachments to 1 when clearing max number of attachments', async () => {
+ const handleUpdate = jest.fn();
+ const { allComponentProps } = await render({
+ componentProps: {
+ maxNumberOfAttachments: 3,
+ type: ComponentType.FileUpload,
+ },
+ handleComponentUpdate: handleUpdate,
+ });
+
+ const maxFilesInput = screen.getByLabelText('ux_editor.modal_properties_maximum_files');
+
+ await act(() => user.clear(maxFilesInput));
+ expect(handleUpdate).toHaveBeenCalledWith({
+ ...allComponentProps,
+ maxNumberOfAttachments: 1,
+ });
+ });
+
+ test('should call handleComponentUpdate with required: false when min number of attachments is set to 0', async () => {
+ const handleUpdate = jest.fn();
+ const { allComponentProps } = await render({
+ componentProps: {
+ required: true,
+ minNumberOfAttachments: 1,
+ type: ComponentType.FileUpload,
+ },
+ handleComponentUpdate: handleUpdate,
+ });
+
+ const minFilesInput = screen.getByLabelText('ux_editor.modal_properties_minimum_files');
+
+ await act(() => user.clear(minFilesInput));
+ expect(handleUpdate).toHaveBeenCalledWith({
+ ...allComponentProps,
+ required: false,
+ minNumberOfAttachments: 0,
+ });
+ });
+
+ test('should return button specific content when type button', async () => {
+ await render({
+ componentProps: {
+ type: ComponentType.Button,
+ },
+ });
+ expect(await screen.findByTestId(buttonSpecificContentId)).toBeInTheDocument();
+ });
+
+ test('should render Image component when component type is Image', async () => {
+ await render({
+ componentProps: {
+ type: ComponentType.Image,
+ },
+ });
+ expect(await screen.findByTestId(imageSpecificContentId)).toBeInTheDocument();
+ });
+
+ it('should not render Image component when component type is not Image', async () => {
+ await render({
+ componentProps: {
+ type: ComponentType.Button,
+ },
+ });
+ expect(screen.queryByLabelText(srcValueLabel)).not.toBeInTheDocument();
+ });
+
+ it('should notify users when the component is unrecognized and cannot be configured in Studio', async () => {
+ await render({
+ componentProps: {
+ // Cast the type to avoid TypeScript error due to components that does not exists within ComponentType.
+ type: 'UnknownComponent' as unknown as any,
+ },
+ });
+ expect(screen.getByText(/ux_editor.edit_component.unknown_component/));
+ });
+});
+
+const waitForData = async () => {
+ const layoutSchemaResult = renderHookWithMockStore()(() => useLayoutSchemaQuery())
+ .renderHookResult.result;
+ await waitFor(() => expect(layoutSchemaResult.current[0].isSuccess).toBe(true));
+ const dataModelMetadataResult = renderHookWithMockStore(
+ {},
+ { getDatamodelMetadata },
+ )(() => useDatamodelMetadataQuery('test-org', 'test-app')).renderHookResult.result;
+ await waitFor(() => expect(dataModelMetadataResult.current.isSuccess).toBe(true));
+ await waitFor(() => expect(layoutSchemaResult.current[0].isSuccess).toBe(true));
+};
+
+const render = async ({
+ componentProps = {},
+ handleComponentUpdate = jest.fn(),
+ isProd = true,
+}: {
+ componentProps?: Partial;
+ handleComponentUpdate?: (component: FormComponent) => {
+ allComponentProps: FormComponent;
+ };
+ isProd?: boolean;
+}) => {
+ const allComponentProps: FormComponent = {
+ dataModelBindings: {},
+ readOnly: false,
+ required: false,
+ textResourceBindings: {
+ title: 'title',
+ },
+ type: ComponentType.Input,
+ id: 'test',
+ itemType: 'COMPONENT',
+ ...componentProps,
+ } as FormComponent;
+
+ await waitForData();
+
+ renderWithMockStore(
+ {},
+ { getDatamodelMetadata },
+ )(
+ ,
+ );
+
+ return { allComponentProps };
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/EditFormComponent.tsx b/frontend/packages/ux-editor-v3/src/components/config/EditFormComponent.tsx
new file mode 100644
index 00000000000..8dd32ed7128
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/EditFormComponent.tsx
@@ -0,0 +1,120 @@
+import React from 'react';
+import type { EditSettings, IGenericEditComponent } from './componentConfig';
+import { configComponents } from './componentConfig';
+import { componentSpecificEditConfig } from './componentConfig';
+import { ComponentSpecificContent } from './componentSpecificContent';
+import { Switch, Fieldset, Heading } from '@digdir/design-system-react';
+import classes from './EditFormComponent.module.css';
+import type { FormComponent } from '../../types/FormComponent';
+import { selectedLayoutNameSelector } from '../../selectors/formLayoutSelectors';
+import { useComponentSchemaQuery } from '../../hooks/queries/useComponentSchemaQuery';
+import { StudioSpinner } from '@studio/components';
+import { FormComponentConfig } from './FormComponentConfig';
+import { EditComponentId } from './editModal/EditComponentId';
+import { useLayoutSchemaQuery } from '../../hooks/queries/useLayoutSchemaQuery';
+import { useSelector } from 'react-redux';
+import { getComponentTitleByComponentType } from '../../utils/language';
+import { useTranslation } from 'react-i18next';
+import {
+ addFeatureFlagToLocalStorage,
+ removeFeatureFlagFromLocalStorage,
+ shouldDisplayFeature,
+} from 'app-shared/utils/featureToggleUtils';
+import { FormField } from 'app-shared/components/FormField';
+import { formItemConfigs } from '../../data/formItemConfig';
+import { UnknownComponentAlert } from '../UnknownComponentAlert';
+
+export interface IEditFormComponentProps {
+ editFormId: string;
+ component: FormComponent;
+ handleComponentUpdate: (component: FormComponent) => void;
+}
+
+export const EditFormComponent = ({
+ editFormId,
+ component,
+ handleComponentUpdate,
+}: IEditFormComponentProps) => {
+ const selectedLayout = useSelector(selectedLayoutNameSelector);
+ const { t } = useTranslation();
+ const [showComponentConfigBeta, setShowComponentConfigBeta] = React.useState(
+ shouldDisplayFeature('componentConfigBeta'),
+ );
+
+ useLayoutSchemaQuery(); // Ensure we load the layout schemas so that component schemas can be loaded
+ const { data: schema, isPending } = useComponentSchemaQuery(component.type);
+
+ const renderFromComponentSpecificDefinition = (configDef: EditSettings[]) => {
+ if (!configDef) return null;
+ return configDef.map((configType) => {
+ const Tag = configComponents[configType];
+ if (!Tag) return null;
+ return React.createElement(Tag, {
+ key: configType,
+ editFormId,
+ handleComponentChange: handleComponentUpdate,
+ component,
+ });
+ });
+ };
+
+ const getConfigDefinitionForComponent = (): EditSettings[] => {
+ return componentSpecificEditConfig[component.type];
+ };
+
+ const toggleShowBetaFunc = (event: React.ChangeEvent) => {
+ setShowComponentConfigBeta(event.target.checked);
+ // Ensure choice of feature toggling is persisted in local storage
+ if (event.target.checked) {
+ addFeatureFlagToLocalStorage('componentConfigBeta');
+ } else {
+ removeFeatureFlagFromLocalStorage('componentConfigBeta');
+ }
+ };
+
+ const isUnknownInternalComponent: boolean = !formItemConfigs[component.type];
+ if (isUnknownInternalComponent) {
+ return ;
+ }
+
+ return (
+
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/EditFormContainer.module.css b/frontend/packages/ux-editor-v3/src/components/config/EditFormContainer.module.css
new file mode 100644
index 00000000000..8cb433cd115
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/EditFormContainer.module.css
@@ -0,0 +1,5 @@
+.fieldset > div {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
diff --git a/frontend/packages/ux-editor-v3/src/components/config/EditFormContainer.test.tsx b/frontend/packages/ux-editor-v3/src/components/config/EditFormContainer.test.tsx
new file mode 100644
index 00000000000..bd3e60c48ff
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/EditFormContainer.test.tsx
@@ -0,0 +1,121 @@
+import React from 'react';
+import { act, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import type { IEditFormContainerProps } from './EditFormContainer';
+import { EditFormContainer } from './EditFormContainer';
+import { useFormLayoutsQuery } from '../../hooks/queries/useFormLayoutsQuery';
+import { useFormLayoutSettingsQuery } from '../../hooks/queries/useFormLayoutSettingsQuery';
+import {
+ formLayoutSettingsMock,
+ renderHookWithMockStore,
+ renderWithMockStore,
+} from '../../testing/mocks';
+import { useLayoutSchemaQuery } from '../../hooks/queries/useLayoutSchemaQuery';
+import { container1IdMock, externalLayoutsMock, layoutMock } from '../../testing/layoutMock';
+import { textMock } from '../../../../../testing/mocks/i18nMock';
+import type { FormLayoutsResponse } from 'app-shared/types/api';
+import type { ILayoutSettings } from 'app-shared/types/global';
+
+const user = userEvent.setup();
+
+// Test data:
+const org = 'org';
+const app = 'app';
+const selectedLayoutSet = 'test-layout-set';
+
+const handleContainerUpdateMock = jest.fn();
+
+describe('EditFormContainer', () => {
+ afterEach(jest.clearAllMocks);
+
+ it('should render the component', async () => {
+ await render();
+
+ expect(
+ screen.getByText(textMock('ux_editor.modal_properties_group_change_id')),
+ ).toBeInTheDocument();
+ });
+
+ it('should update form when editing field', async () => {
+ await render();
+
+ const containerIdInput = screen.getByLabelText(
+ textMock('ux_editor.modal_properties_group_change_id'),
+ );
+ await act(() => user.type(containerIdInput, 'test'));
+ expect(handleContainerUpdateMock).toHaveBeenCalledTimes(4);
+ });
+
+ it('should display an error when containerId is invalid', async () => {
+ await render();
+
+ const containerIdInput = screen.getByLabelText(
+ textMock('ux_editor.modal_properties_group_change_id'),
+ );
+ await act(() => user.type(containerIdInput, 'test@'));
+ expect(
+ screen.getByText(textMock('ux_editor.modal_properties_group_id_not_valid')),
+ ).toBeInTheDocument();
+ expect(handleContainerUpdateMock).toHaveBeenCalledTimes(4);
+ });
+
+ test('user should be able to choose which titles to display in table', async () => {
+ await render({
+ container: {
+ ...layoutMock.containers[container1IdMock],
+ id: 'test',
+ maxCount: 2,
+ },
+ });
+
+ const repeatingGroupSwitch = screen.getByLabelText(
+ textMock('ux_editor.modal_properties_group_repeating'),
+ );
+
+ await act(() => user.click(repeatingGroupSwitch));
+
+ expect(
+ screen.getByText(textMock('ux_editor.modal_properties_group_table_headers')),
+ ).toBeInTheDocument();
+
+ const firstCheckbox = screen.getByRole('checkbox', { name: 'Component-1' });
+ await act(() => user.click(firstCheckbox));
+
+ expect(handleContainerUpdateMock).toHaveBeenCalled();
+ });
+});
+
+const waitForData = async () => {
+ const getFormLayouts = jest
+ .fn()
+ .mockImplementation(() => Promise.resolve(externalLayoutsMock));
+ const getFormLayoutSettings = jest
+ .fn()
+ .mockImplementation(() => Promise.resolve(formLayoutSettingsMock));
+ const formLayoutsResult = renderHookWithMockStore(
+ {},
+ { getFormLayouts },
+ )(() => useFormLayoutsQuery(org, app, selectedLayoutSet)).renderHookResult.result;
+ const settingsResult = renderHookWithMockStore(
+ {},
+ { getFormLayoutSettings },
+ )(() => useFormLayoutSettingsQuery(org, app, selectedLayoutSet)).renderHookResult.result;
+ const layoutSchemaResult = renderHookWithMockStore()(() => useLayoutSchemaQuery())
+ .renderHookResult.result;
+ await waitFor(() => expect(formLayoutsResult.current.isSuccess).toBe(true));
+ await waitFor(() => expect(settingsResult.current.isSuccess).toBe(true));
+ await waitFor(() => expect(layoutSchemaResult.current[0].isSuccess).toBe(true));
+};
+
+const render = async (props: Partial = {}) => {
+ const allProps: IEditFormContainerProps = {
+ editFormId: container1IdMock,
+ container: { ...layoutMock.containers[container1IdMock], id: 'test' },
+ handleContainerUpdate: handleContainerUpdateMock,
+ ...props,
+ };
+
+ await waitForData();
+
+ return renderWithMockStore()();
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/EditFormContainer.tsx b/frontend/packages/ux-editor-v3/src/components/config/EditFormContainer.tsx
new file mode 100644
index 00000000000..f10263eb81c
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/EditFormContainer.tsx
@@ -0,0 +1,241 @@
+import React, { useState } from 'react';
+import { useSelector } from 'react-redux';
+import '../../styles/index.css';
+import { EditGroupDataModelBindings } from './group/EditGroupDataModelBindings';
+import { getTextResource } from '../../utils/language';
+import { idExists } from '../../utils/formLayoutUtils';
+import type { DatamodelFieldElement } from 'app-shared/types/DatamodelFieldElement';
+import { Switch, Checkbox, LegacyFieldSet, LegacyTextField } from '@digdir/design-system-react';
+import classes from './EditFormContainer.module.css';
+import { TextResource } from '../TextResource';
+import { useDatamodelMetadataQuery } from '../../hooks/queries/useDatamodelMetadataQuery';
+import { useText } from '../../hooks';
+import { useSelectedFormLayout, useTextResourcesSelector } from '../../hooks';
+import { textResourcesByLanguageSelector } from '../../selectors/textResourceSelectors';
+import { DEFAULT_LANGUAGE } from 'app-shared/constants';
+import type { ITextResource } from 'app-shared/types/global';
+import { selectedLayoutNameSelector } from '../../selectors/formLayoutSelectors';
+import { useFormLayoutsQuery } from '../../hooks/queries/useFormLayoutsQuery';
+import { FormField } from '../FormField';
+import type { FormContainer } from '../../types/FormContainer';
+import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams';
+import { useAppContext } from '../../hooks/useAppContext';
+
+export interface IEditFormContainerProps {
+ editFormId: string;
+ container: FormContainer;
+ handleContainerUpdate: (updatedContainer: FormContainer) => void;
+}
+
+export const EditFormContainer = ({
+ editFormId,
+ container,
+ handleContainerUpdate,
+}: IEditFormContainerProps) => {
+ const t = useText();
+
+ const { org, app } = useStudioUrlParams();
+
+ const { selectedLayoutSet } = useAppContext();
+ const { data: formLayouts } = useFormLayoutsQuery(org, app, selectedLayoutSet);
+ const { data: dataModel } = useDatamodelMetadataQuery(org, app);
+ const { components, containers } = useSelectedFormLayout();
+ const textResources: ITextResource[] = useTextResourcesSelector(
+ textResourcesByLanguageSelector(DEFAULT_LANGUAGE),
+ );
+
+ const [tableHeadersError, setTableHeadersError] = useState(null);
+
+ const selectedLayout = useSelector(selectedLayoutNameSelector);
+ const layoutOrder = formLayouts?.[selectedLayout]?.order || {};
+
+ const items = layoutOrder[editFormId];
+
+ const handleChangeRepeatingGroup = (isRepeating: boolean) => {
+ if (isRepeating) {
+ handleContainerUpdate({
+ ...container,
+ maxCount: 2,
+ dataModelBindings: { group: undefined },
+ });
+ } else {
+ // we are disabling the repeating feature, remove datamodelbinding
+ handleContainerUpdate({
+ ...container,
+ dataModelBindings: { group: undefined },
+ maxCount: undefined,
+ textResourceBindings: undefined,
+ });
+ }
+ };
+
+ const handleMaxOccurChange = (maxOcc: number) => {
+ if (maxOcc < 2) {
+ maxOcc = 2;
+ }
+ handleContainerUpdate({
+ ...container,
+ maxCount: maxOcc,
+ });
+ };
+
+ const handleButtonTextChange = (id: string) => {
+ handleContainerUpdate({
+ ...container,
+ textResourceBindings: {
+ ...container.textResourceBindings,
+ add_button: id,
+ },
+ });
+ };
+
+ const handleTableHeadersChange = (ids: string[]) => {
+ const updatedContainer = { ...container };
+ updatedContainer.tableHeaders = [...ids];
+ if (updatedContainer.tableHeaders?.length === items.length) {
+ // table headers is the same as children. We ignore the table header prop
+ updatedContainer.tableHeaders = undefined;
+ }
+ let errorMessage;
+ if (updatedContainer.tableHeaders?.length === 0) {
+ errorMessage = t('ux_editor.modal_properties_group_table_headers_error');
+ }
+
+ handleContainerUpdate(updatedContainer);
+ setTableHeadersError(errorMessage);
+ };
+
+ const getMaxOccursForGroupFromDataModel = (dataBindingName: string): number => {
+ const element: DatamodelFieldElement = dataModel.find((e: DatamodelFieldElement) => {
+ return e.dataBindingName === dataBindingName;
+ });
+ return element?.maxOccurs;
+ };
+
+ const handleDataModelGroupChange = (dataBindingName: string, key: string) => {
+ const maxOccurs = getMaxOccursForGroupFromDataModel(dataBindingName);
+ handleContainerUpdate({
+ ...container,
+ dataModelBindings: {
+ [key]: dataBindingName,
+ },
+ maxCount: maxOccurs,
+ });
+ };
+
+ const handleIdChange = (id: string) => {
+ handleContainerUpdate({
+ ...container,
+ id,
+ });
+ };
+
+ return (
+
+ {
+ if (value !== container.id && idExists(value, components, containers)) {
+ return 'unique';
+ }
+ }}
+ customValidationMessages={(errorCode: string) => {
+ if (errorCode === 'unique') {
+ return t('ux_editor.modal_properties_group_id_not_unique_error');
+ }
+ if (errorCode === 'pattern') {
+ return t('ux_editor.modal_properties_group_id_not_valid');
+ }
+ }}
+ onChange={handleIdChange}
+ renderField={({ fieldProps }) => (
+ fieldProps.onChange(e.target.value, e)}
+ />
+ )}
+ />
+ 1}
+ onChange={handleChangeRepeatingGroup}
+ renderField={({ fieldProps }) => (
+ fieldProps.onChange(e.target.checked, e)}
+ >
+ {t('ux_editor.modal_properties_group_repeating')}
+
+ )}
+ />
+ {container.maxCount > 1 && (
+ <>
+
+ (
+ fieldProps.onChange(parseInt(e.target.value), e)}
+ />
+ )}
+ />
+
+ {items?.length > 0 && (
+ {
+ const filteredItems = items.filter((id) => !!components[id]);
+ const checkboxes = filteredItems.map((id) => ({
+ id,
+ name: id,
+ checked:
+ container.tableHeaders === undefined || container.tableHeaders.includes(id),
+ }));
+ return (
+
+ {checkboxes.map(({ id, name, checked }) => (
+
+ {getTextResource(
+ components[id]?.textResourceBindings?.title,
+ textResources,
+ ) || id}
+
+ ))}
+
+ );
+ }}
+ />
+ )}
+ >
+ )}
+
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ComplexExpression/ComplexExpression.module.css b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ComplexExpression/ComplexExpression.module.css
new file mode 100644
index 00000000000..86aed1cbce0
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ComplexExpression/ComplexExpression.module.css
@@ -0,0 +1,13 @@
+.root {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: 10px;
+ width: 100%;
+ margin: 30px 0 30px 0;
+}
+
+.root textarea {
+ height: 150px;
+ font-family: monospace;
+}
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ComplexExpression/ComplexExpression.test.tsx b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ComplexExpression/ComplexExpression.test.tsx
new file mode 100644
index 00000000000..6a76de56fca
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ComplexExpression/ComplexExpression.test.tsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import { screen } from '@testing-library/react';
+import {
+ internalParsableComplexExpression,
+ internalUnParsableComplexExpression,
+} from '../../../../../testing/expressionMocks';
+import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext';
+import { renderWithMockStore } from '../../../../../testing/mocks';
+import type { ComplexExpressionProps } from './ComplexExpression';
+import { ComplexExpression } from './ComplexExpression';
+import { stringifyData } from '../../../../../utils/jsonUtils';
+import { textMock } from '../../../../../../../../testing/mocks/i18nMock';
+
+describe('ComplexExpression', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ it('displays textArea with complex expression as value', () => {
+ render({});
+ const complexExpression = screen.getByRole('textbox');
+ expect(complexExpression).toHaveTextContent(
+ stringifyData(internalUnParsableComplexExpression.complexExpression),
+ );
+ });
+ it('displays an editable textArea', () => {
+ render({});
+ const complexExpression = screen.getByRole('textbox');
+ expect(complexExpression).not.toHaveAttribute('disabled');
+ });
+ it('displays an non-editable textArea when expression is preview', () => {
+ render({
+ props: {
+ disabled: true,
+ },
+ });
+ const complexExpression = screen.getByRole('textbox');
+ expect(complexExpression).toHaveAttribute('disabled');
+ });
+ it('displays too complex expression info message if expression can not be interpreted by Studio', () => {
+ render({});
+ const tooComplexExpressionAlert = screen.getByText(
+ textMock('right_menu.expressions_complex_expression_message'),
+ );
+ expect(tooComplexExpressionAlert).toBeInTheDocument();
+ });
+ it('does not display too complex expression info message if expression can be interpreted by Studio', () => {
+ render({
+ props: {
+ expression: internalParsableComplexExpression.complexExpression,
+ isStudioFriendly: true,
+ },
+ });
+ const tooComplexExpressionAlert = screen.queryByText(
+ textMock('right_menu.expressions_complex_expression_message'),
+ );
+ expect(tooComplexExpressionAlert).not.toBeInTheDocument();
+ });
+});
+
+const render = ({
+ props = {},
+ queries = {},
+}: {
+ props?: Partial;
+ queries?: Partial;
+}) => {
+ const defaultProps: ComplexExpressionProps = {
+ expression: internalUnParsableComplexExpression,
+ onChange: jest.fn(),
+ isStudioFriendly: false,
+ };
+ return renderWithMockStore({}, queries)();
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ComplexExpression/ComplexExpression.tsx b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ComplexExpression/ComplexExpression.tsx
new file mode 100644
index 00000000000..7e6b4d49953
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ComplexExpression/ComplexExpression.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import classes from './ComplexExpression.module.css';
+import { Alert, LegacyTextArea } from '@digdir/design-system-react';
+import { useTranslation } from 'react-i18next';
+import type { Expression } from '../../../../../types/Expressions';
+import { stringifyData } from '../../../../../utils/jsonUtils';
+
+export type ComplexExpressionProps = {
+ disabled?: boolean;
+ expression: Expression;
+ onChange?: (expression: string) => void;
+ isStudioFriendly?: boolean;
+};
+
+export const ComplexExpression = ({
+ disabled = false,
+ expression,
+ onChange,
+ isStudioFriendly,
+}: ComplexExpressionProps) => {
+ const { t } = useTranslation();
+ return (
+
+
onChange?.(event.target.value)}
+ value={stringifyData(expression.complexExpression)}
+ />
+ {!isStudioFriendly && {t('right_menu.expressions_complex_expression_message')}}
+
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ComplexExpression/index.ts b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ComplexExpression/index.ts
new file mode 100644
index 00000000000..9e31d99ee03
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ComplexExpression/index.ts
@@ -0,0 +1,2 @@
+export { ComplexExpression } from './ComplexExpression';
+export type { ComplexExpressionProps } from './ComplexExpression';
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionContent.module.css b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionContent.module.css
new file mode 100644
index 00000000000..b948afb223a
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionContent.module.css
@@ -0,0 +1,40 @@
+.expressionContainer {
+ border: 2px solid #e1e1e1;
+ border-radius: 5px;
+ padding: 1rem;
+ background-color: white;
+}
+
+.topBar {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.previewMode {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+}
+
+.expressionDetails div p span {
+ font-style: italic;
+}
+
+.expressionDetails span span {
+ font-weight: bold;
+}
+
+.expressionDetails p {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.subExpression {
+ display: grid;
+ gap: 0.5rem;
+ grid-template-rows: auto;
+ margin: 0.5rem 0;
+}
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionContent.test.tsx b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionContent.test.tsx
new file mode 100644
index 00000000000..ef4f8052a99
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionContent.test.tsx
@@ -0,0 +1,203 @@
+import React from 'react';
+import { act, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import {
+ internalExpressionWithMultipleSubExpressions,
+ parsableExternalExpression,
+} from '../../../../testing/expressionMocks';
+import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext';
+import { renderWithMockStore } from '../../../../testing/mocks';
+import { formDesignerMock } from '../../../../testing/stateMocks';
+import type { IFormLayouts } from '../../../../types/global';
+import { layout1NameMock, layoutMock } from '../../../../testing/layoutMock';
+import type { ExpressionContentProps } from './ExpressionContent';
+import { ExpressionContent } from './ExpressionContent';
+import { textMock } from '../../../../../../../testing/mocks/i18nMock';
+import { createQueryClientMock } from 'app-shared/mocks/queryClientMock';
+import { QueryKey } from 'app-shared/types/QueryKey';
+import { ExpressionPropertyBase } from '../../../../types/Expressions';
+import { FormContext } from '../../../../containers/FormContext';
+import { formContextProviderMock } from '../../../../testing/formContextMocks';
+import type { FormComponent } from '../../../../types/FormComponent';
+import { ComponentType } from 'app-shared/types/ComponentType';
+import type { FormContainer } from '../../../../types/FormContainer';
+
+const org = 'org';
+const app = 'app';
+const layoutSetName = formDesignerMock.layout.selectedLayoutSet;
+const layouts: IFormLayouts = {
+ [layout1NameMock]: layoutMock,
+};
+
+describe('ExpressionContent', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ it('renders an expression in preview when defaultEditMode is false for an existing expression on hidden property', () => {
+ render({});
+
+ const nullText = screen.getByText(textMock('right_menu.expressions_data_source_null'));
+ expect(nullText).toBeInTheDocument();
+ const numberText = screen.getByText(textMock('right_menu.expressions_data_source_number'));
+ expect(numberText).toBeInTheDocument();
+ const numberValueText = screen.getByText(
+ internalExpressionWithMultipleSubExpressions.subExpressions[0].comparableValue as string,
+ );
+ expect(numberValueText).toBeInTheDocument();
+ const booleanText = screen.getByText(textMock('right_menu.expressions_data_source_boolean'));
+ expect(booleanText).toBeInTheDocument();
+ const booleanValueText = screen.getByText(textMock('general.true'));
+ expect(booleanValueText).toBeInTheDocument();
+ const componentText = screen.getByText(
+ textMock('right_menu.expressions_data_source_component'),
+ );
+ expect(componentText).toBeInTheDocument();
+ const componentValueText = screen.getByText(
+ internalExpressionWithMultipleSubExpressions.subExpressions[1].comparableValue as string,
+ );
+ expect(componentValueText).toBeInTheDocument();
+ });
+
+ it('renders an expression in edit mode when defaultEditMode is true for an existing expression with three subexpressions on hidden property', () => {
+ render({
+ props: {
+ defaultEditMode: true,
+ },
+ });
+
+ const propertyPreviewText = screen.getByText(
+ textMock('right_menu.expressions_property_preview_hidden'),
+ );
+ expect(propertyPreviewText).toBeInTheDocument();
+ const functionSelectComponent = screen.queryAllByRole('combobox', {
+ name: textMock('right_menu.expressions_function'),
+ });
+ expect(functionSelectComponent).toHaveLength(3);
+ const dataSourceSelectComponent = screen.queryAllByRole('combobox', {
+ name: textMock('right_menu.expressions_data_source'),
+ });
+ expect(dataSourceSelectComponent).toHaveLength(3);
+ const dataSourceValueSelectComponent = screen.queryAllByRole('combobox', {
+ name: textMock('right_menu.expressions_data_source_value'),
+ });
+ expect(dataSourceValueSelectComponent).toHaveLength(1);
+ const comparableDataSourceSelectComponent = screen.queryAllByRole('combobox', {
+ name: textMock('right_menu.expressions_comparable_data_source'),
+ });
+ expect(comparableDataSourceSelectComponent).toHaveLength(3);
+ const comparableDataSourceValueSelectComponent = screen.queryAllByRole('textbox');
+ expect(comparableDataSourceValueSelectComponent).toHaveLength(2);
+ const saveExpressionButton = screen.getByRole('button', {
+ name: textMock('right_menu.expression_save'),
+ });
+ expect(saveExpressionButton).toBeInTheDocument();
+ });
+
+ it('renders calls onDeleteExpression when expression is deleted from preview mode', async () => {
+ const user = userEvent.setup();
+ const mockOnDeleteExpression = jest.fn();
+ render({
+ props: {
+ onDeleteExpression: mockOnDeleteExpression,
+ },
+ });
+
+ const deleteButton = screen.getByRole('button', {
+ name: textMock('right_menu.expression_delete'),
+ });
+ await act(() => user.click(deleteButton));
+ expect(mockOnDeleteExpression).toHaveBeenCalledTimes(1);
+ expect(mockOnDeleteExpression).toHaveBeenCalledWith(ExpressionPropertyBase.Hidden);
+ });
+
+ it('renders calls onDeleteExpression when expression is deleted from edit mode', async () => {
+ const user = userEvent.setup();
+ const mockOnDeleteExpression = jest.fn();
+ render({
+ props: {
+ defaultEditMode: true,
+ onDeleteExpression: mockOnDeleteExpression,
+ },
+ });
+
+ const deleteButton = screen.getByRole('button', {
+ name: textMock('right_menu.expression_delete'),
+ });
+ await act(() => user.click(deleteButton));
+ expect(mockOnDeleteExpression).toHaveBeenCalledTimes(1);
+ expect(mockOnDeleteExpression).toHaveBeenCalledWith(ExpressionPropertyBase.Hidden);
+ });
+
+ it('1 of 3 subExpressions is deleted when subExpression is deleted', async () => {
+ const user = userEvent.setup();
+ render({
+ props: {
+ defaultEditMode: true,
+ },
+ });
+
+ // Since there are three subexpressions in this expression there will also be three delete-buttons.
+ const deleteButtons = screen.getAllByRole('button', {
+ name: textMock('right_menu.expression_sub_expression_delete'),
+ });
+ await act(() => user.click(deleteButtons[0]));
+ const newDeleteButtons = screen.getAllByRole('button', {
+ name: textMock('right_menu.expression_sub_expression_delete'),
+ });
+ expect(newDeleteButtons).toHaveLength(2);
+ });
+
+ it('Expression in edit mode is saved and changed to preview mode when save button is clicked', async () => {
+ const user = userEvent.setup();
+ render({
+ props: {
+ defaultEditMode: true,
+ },
+ });
+
+ const saveButton = screen.getByRole('button', { name: textMock('right_menu.expression_save') });
+ await act(() => user.click(saveButton));
+ expect(saveButton).not.toBeInTheDocument();
+ });
+});
+
+const componentWithExpression: FormComponent = {
+ id: 'some-id',
+ type: ComponentType.Input,
+ itemType: 'COMPONENT',
+ hidden: parsableExternalExpression,
+};
+
+const render = ({
+ props = {},
+ queries = {},
+ component = componentWithExpression,
+}: {
+ props?: Partial;
+ queries?: Partial;
+ component?: FormComponent | FormContainer;
+}) => {
+ const defaultProps: ExpressionContentProps = {
+ property: ExpressionPropertyBase.Hidden,
+ defaultEditMode: false,
+ onDeleteExpression: jest.fn(),
+ };
+ const queryClient = createQueryClientMock();
+ queryClient.setQueryData([QueryKey.FormLayouts, org, app, layoutSetName], layouts);
+ return renderWithMockStore(
+ {},
+ queries,
+ queryClient,
+ )(
+
+
+ ,
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionContent.tsx b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionContent.tsx
new file mode 100644
index 00000000000..ef252690944
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionContent.tsx
@@ -0,0 +1,76 @@
+import React, { useContext, useState } from 'react';
+import type { Expression, SubExpression, ExpressionProperty } from '../../../../types/Expressions';
+import {
+ convertExternalExpressionToInternal,
+ convertAndAddExpressionToComponent,
+ removeSubExpression,
+ deleteExpressionFromPropertyOnComponent,
+ getExternalExpressionOnComponentProperty,
+} from '../../../../utils/expressionsUtils';
+import type { FormComponent } from '../../../../types/FormComponent';
+import type { FormContainer } from '../../../../types/FormContainer';
+import { FormContext } from '../../../../containers/FormContext';
+import { ExpressionPreview } from './ExpressionPreview';
+import { ExpressionEditMode } from './ExpressionEditMode';
+
+export interface ExpressionContentProps {
+ property: ExpressionProperty;
+ defaultEditMode: boolean;
+ onDeleteExpression: (property: ExpressionProperty) => void;
+}
+
+export const ExpressionContent = ({
+ property,
+ defaultEditMode,
+ onDeleteExpression,
+}: ExpressionContentProps) => {
+ const { formId, form, handleUpdate, handleSave } = useContext(FormContext);
+ const externalExpression = getExternalExpressionOnComponentProperty(form, property);
+ const defaultExpression = externalExpression
+ ? convertExternalExpressionToInternal(property, externalExpression)
+ : { property };
+ const [expression, setExpression] = useState(defaultExpression);
+ const [editMode, setEditMode] = useState(defaultEditMode);
+
+ const updateAndSaveLayout = async (updatedComponent: FormComponent | FormContainer) => {
+ handleUpdate(updatedComponent);
+ await handleSave(formId, updatedComponent);
+ };
+
+ const saveExpression = async (exp: Expression) => {
+ const updatedComponent = convertAndAddExpressionToComponent(form, exp);
+ await updateAndSaveLayout(updatedComponent);
+ };
+
+ const deleteExpression = async (exp: Expression) => {
+ const updatedComponent = deleteExpressionFromPropertyOnComponent(form, exp.property);
+ await updateAndSaveLayout(updatedComponent);
+ onDeleteExpression(exp.property);
+ };
+
+ const deleteSubExpression = async (subExpression: SubExpression) => {
+ const newExpression: Expression = removeSubExpression(expression, subExpression);
+ const updatedComponent = convertAndAddExpressionToComponent(form, newExpression);
+ await updateAndSaveLayout(updatedComponent);
+ setExpression(newExpression);
+ };
+
+ return editMode ? (
+
+ ) : (
+
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/ExpressionEditMode.test.tsx b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/ExpressionEditMode.test.tsx
new file mode 100644
index 00000000000..c4eb65b0e5e
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/ExpressionEditMode.test.tsx
@@ -0,0 +1,274 @@
+import React from 'react';
+import { act, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import {
+ componentId,
+ internalExpressionWithMultipleSubExpressions,
+ internalParsableComplexExpression,
+ internalUnParsableComplexExpression,
+ simpleInternalExpression,
+} from '../../../../../testing/expressionMocks';
+import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext';
+import { renderWithMockStore } from '../../../../../testing/mocks';
+import { formDesignerMock } from '../../../../../testing/stateMocks';
+import type { IFormLayouts } from '../../../../../types/global';
+import { layout1NameMock, layoutMock } from '../../../../../testing/layoutMock';
+import { textMock } from '../../../../../../../../testing/mocks/i18nMock';
+import { createQueryClientMock } from 'app-shared/mocks/queryClientMock';
+import { QueryKey } from 'app-shared/types/QueryKey';
+import {
+ DataSource,
+ ExpressionFunction,
+ ExpressionPropertyBase,
+ Operator,
+} from '../../../../../types/Expressions';
+import { deepCopy } from 'app-shared/pure';
+import type { ExpressionEditModeProps } from './ExpressionEditMode';
+import { ExpressionEditMode } from './ExpressionEditMode';
+
+const org = 'org';
+const app = 'app';
+const layoutSetName = formDesignerMock.layout.selectedLayoutSet;
+const layouts: IFormLayouts = {
+ [layout1NameMock]: layoutMock,
+};
+
+describe('ExpressionEditMode', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ it('renders the expression in edit mode with saveButton when complex expression is not set', () => {
+ render({});
+
+ const propertyPreviewText = screen.getByText(
+ textMock('right_menu.expressions_property_preview_hidden'),
+ );
+ expect(propertyPreviewText).toBeInTheDocument();
+ const functionSelectComponent = screen.getByRole('combobox', {
+ name: textMock('right_menu.expressions_function'),
+ });
+ expect(functionSelectComponent).toBeInTheDocument();
+ const dataSourceSelectComponent = screen.getByRole('combobox', {
+ name: textMock('right_menu.expressions_data_source'),
+ });
+ expect(dataSourceSelectComponent).toBeInTheDocument();
+ const dataSourceValueSelectComponent = screen.getByRole('combobox', {
+ name: textMock('right_menu.expressions_data_source_value'),
+ });
+ expect(dataSourceValueSelectComponent).toBeInTheDocument();
+ const comparableDataSourceSelectComponent = screen.getByRole('combobox', {
+ name: textMock('right_menu.expressions_comparable_data_source'),
+ });
+ expect(comparableDataSourceSelectComponent).toBeInTheDocument();
+ const comparableDataSourceValueSelectComponent = screen.getByRole('textbox');
+ expect(comparableDataSourceValueSelectComponent).toBeInTheDocument();
+ expect(comparableDataSourceValueSelectComponent).toHaveValue(
+ simpleInternalExpression.subExpressions[0].comparableValue as string,
+ );
+ const saveExpressionButton = screen.getByRole('button', {
+ name: textMock('right_menu.expression_save'),
+ });
+ expect(saveExpressionButton).toBeInTheDocument();
+ });
+ it('renders the complex expression in edit mode with save button when complex expression is set', () => {
+ render({
+ props: {
+ expression: internalUnParsableComplexExpression,
+ },
+ });
+
+ const complexExpression = screen.getByRole('textbox');
+ expect(complexExpression).toBeInTheDocument();
+ expect(complexExpression).toHaveValue(internalUnParsableComplexExpression.complexExpression);
+ expect(complexExpression).not.toHaveAttribute('disabled');
+ const saveExpressionButton = screen.getByRole('button', {
+ name: textMock('right_menu.expression_save'),
+ });
+ expect(saveExpressionButton).toBeInTheDocument();
+ });
+ it('SaveExpression button is disabled when there are no function set', () => {
+ render({
+ props: {
+ expression: {
+ property: ExpressionPropertyBase.Hidden,
+ subExpressions: [
+ {
+ dataSource: DataSource.String,
+ },
+ ],
+ },
+ },
+ });
+ const saveExpressionButton = screen.queryByRole('button', {
+ name: textMock('right_menu.expression_save'),
+ });
+ expect(saveExpressionButton).toHaveAttribute('disabled');
+ });
+ it('saveExpression button is disabled when there are no subExpressions', () => {
+ render({
+ props: {
+ expression: {
+ property: ExpressionPropertyBase.Hidden,
+ },
+ },
+ });
+ const saveExpressionButton = screen.queryByRole('button', {
+ name: textMock('right_menu.expression_save'),
+ });
+ expect(saveExpressionButton).toHaveAttribute('disabled');
+ });
+ it('calls saveExpression when saveExpression button is clicked', async () => {
+ const user = userEvent.setup();
+ const mockOnSaveExpression = jest.fn();
+ render({
+ props: {
+ onSaveExpression: mockOnSaveExpression,
+ },
+ });
+ const saveExpressionButton = screen.getByRole('button', {
+ name: textMock('right_menu.expression_save'),
+ });
+ await act(() => user.click(saveExpressionButton));
+ expect(mockOnSaveExpression).toHaveBeenCalledWith(simpleInternalExpression);
+ expect(mockOnSaveExpression).toHaveBeenCalledTimes(1);
+ });
+ it('calls onDeleteExpression when deleteExpression button is clicked', async () => {
+ const user = userEvent.setup();
+ const mockOnDeleteExpression = jest.fn();
+ render({
+ props: {
+ onDeleteExpression: mockOnDeleteExpression,
+ },
+ });
+ const deleteExpressionButton = screen.getByRole('button', {
+ name: textMock('right_menu.expression_delete'),
+ });
+ await act(() => user.click(deleteExpressionButton));
+ expect(mockOnDeleteExpression).toHaveBeenCalledWith(simpleInternalExpression);
+ expect(mockOnDeleteExpression).toHaveBeenCalledTimes(1);
+ });
+ it('calls onSetExpression when subexpression is updated with new function', async () => {
+ const user = userEvent.setup();
+ const mockOnSetExpression = jest.fn();
+ render({
+ props: {
+ onSetExpression: mockOnSetExpression,
+ },
+ });
+ const functionDropDown = screen.getByRole('combobox', {
+ name: textMock('right_menu.expressions_function'),
+ });
+ await act(() => user.click(functionDropDown));
+ const functionOption = screen.getByRole('option', {
+ name: textMock('right_menu.expressions_function_less_than'),
+ });
+ await act(() => user.click(functionOption));
+ const simpleInternalExpressionCopy = deepCopy(simpleInternalExpression);
+ simpleInternalExpressionCopy.subExpressions[0].function = ExpressionFunction.LessThan;
+ expect(mockOnSetExpression).toHaveBeenCalledWith(simpleInternalExpressionCopy);
+ expect(mockOnSetExpression).toHaveBeenCalledTimes(1);
+ });
+ it('calls onSetExpression when subexpression is added', async () => {
+ const user = userEvent.setup();
+ const mockOnSetExpression = jest.fn();
+ render({
+ props: {
+ onSetExpression: mockOnSetExpression,
+ },
+ });
+ const addSubExpressionButton = screen.getByRole('button', {
+ name: textMock('right_menu.expressions_add_sub_expression'),
+ });
+ await act(() => user.click(addSubExpressionButton));
+ const simpleInternalExpressionCopy = deepCopy(simpleInternalExpression);
+ simpleInternalExpressionCopy.subExpressions.push({});
+ simpleInternalExpressionCopy.operator = Operator.And;
+ expect(mockOnSetExpression).toHaveBeenCalledWith(simpleInternalExpressionCopy);
+ expect(mockOnSetExpression).toHaveBeenCalledTimes(1);
+ });
+ it('calls onSetExpression when operator is changed', async () => {
+ const user = userEvent.setup();
+ const mockOnSetExpression = jest.fn();
+ render({
+ props: {
+ expression: internalExpressionWithMultipleSubExpressions,
+ onSetExpression: mockOnSetExpression,
+ },
+ });
+ const andOperatorToggleButton = screen.getByRole('button', {
+ name: textMock('right_menu.expressions_operator_and'),
+ });
+ await act(() => user.click(andOperatorToggleButton));
+ internalExpressionWithMultipleSubExpressions.operator = Operator.And;
+ expect(mockOnSetExpression).toHaveBeenCalledWith(internalExpressionWithMultipleSubExpressions);
+ expect(mockOnSetExpression).toHaveBeenCalledTimes(1);
+ });
+ it('displays disabled free-style-editing-switch if complex expression can not be interpreted by Studio', () => {
+ render({
+ props: {
+ expression: internalUnParsableComplexExpression,
+ },
+ });
+ const enableFreeStyleEditingSwitch = screen.getByRole('checkbox', {
+ name: textMock('right_menu.expression_enable_free_style_editing'),
+ });
+ expect(enableFreeStyleEditingSwitch).toHaveAttribute('readonly');
+ });
+ it('displays toggled on free-style-editing-switch which is not readOnly if complex expression can be interpreted by Studio', () => {
+ render({
+ props: {
+ expression: internalParsableComplexExpression,
+ },
+ });
+ const enableFreeStyleEditingSwitch = screen.getByRole('checkbox', {
+ name: textMock('right_menu.expression_enable_free_style_editing'),
+ });
+ expect(enableFreeStyleEditingSwitch).toBeChecked();
+ });
+ it('displays toggled off free-style-editing-switch if expression is not complex', () => {
+ render({});
+ const enableFreeStyleEditingSwitch = screen.getByRole('checkbox', {
+ name: textMock('right_menu.expression_enable_free_style_editing'),
+ });
+ expect(enableFreeStyleEditingSwitch).not.toBeChecked();
+ });
+ it('toggles off free-style-editing-switch when clicked if complex expression can be interpreted by Studio', async () => {
+ const user = userEvent.setup();
+ render({
+ props: {
+ expression: internalParsableComplexExpression,
+ },
+ });
+ const enableFreeStyleEditingSwitch = screen.getByRole('checkbox', {
+ name: textMock('right_menu.expression_enable_free_style_editing'),
+ });
+ expect(enableFreeStyleEditingSwitch).toBeChecked();
+ await act(() => user.click(enableFreeStyleEditingSwitch));
+ expect(enableFreeStyleEditingSwitch).not.toBeChecked();
+ });
+});
+
+const render = ({
+ props = {},
+ queries = {},
+}: {
+ props?: Partial;
+ queries?: Partial;
+}) => {
+ const defaultProps: ExpressionEditModeProps = {
+ expression: simpleInternalExpression,
+ componentName: componentId,
+ onSetEditMode: jest.fn(),
+ onDeleteExpression: jest.fn(),
+ onDeleteSubExpression: jest.fn(),
+ onSaveExpression: jest.fn(),
+ onSetExpression: jest.fn(),
+ };
+ const queryClient = createQueryClientMock();
+ queryClient.setQueryData([QueryKey.FormLayouts, org, app, layoutSetName], layouts);
+ return renderWithMockStore(
+ {},
+ queries,
+ queryClient,
+ )();
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/ExpressionEditMode.tsx b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/ExpressionEditMode.tsx
new file mode 100644
index 00000000000..9001ffce3c3
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/ExpressionEditMode.tsx
@@ -0,0 +1,159 @@
+import React, { useState } from 'react';
+import type { Expression, SubExpression } from '../../../../../types/Expressions';
+import { expressionInPreviewPropertyTextKeys, Operator } from '../../../../../types/Expressions';
+import {
+ addSubExpressionToExpression,
+ canExpressionBeSaved,
+ complexExpressionIsSet,
+ convertInternalExpressionToExternal,
+ isStudioFriendlyExpression,
+ tryParseExpression,
+ updateComplexExpressionOnExpression,
+ updateOperatorOnExpression,
+ updateSubExpressionOnExpression,
+} from '../../../../../utils/expressionsUtils';
+import { ComplexExpression } from '../ComplexExpression';
+import { SimpleExpression } from './SimpleExpression';
+import { Switch } from '@digdir/design-system-react';
+import { CheckmarkIcon, PlusCircleIcon, TrashIcon } from '@navikt/aksel-icons';
+import { Trans } from 'react-i18next';
+import classes from '../ExpressionContent.module.css';
+import { stringifyData } from '../../../../../utils/jsonUtils';
+import { useText } from '../../../../../hooks';
+import { StudioButton } from '@studio/components';
+
+export interface ExpressionEditModeProps {
+ expression: Expression;
+ componentName: string;
+ onSetEditMode: (editMode: boolean) => void;
+ onDeleteExpression: (expression: Expression) => void;
+ onDeleteSubExpression: (subExpression: SubExpression) => void;
+ onSaveExpression: (expression: Expression) => void;
+ onSetExpression: (expression: Expression) => void;
+}
+
+export const ExpressionEditMode = ({
+ expression,
+ componentName,
+ onSetEditMode,
+ onDeleteExpression,
+ onDeleteSubExpression,
+ onSaveExpression,
+ onSetExpression,
+}: ExpressionEditModeProps) => {
+ const [freeStyleEditing, setFreeStyleEditing] = useState(!!expression.complexExpression);
+ const t = useText();
+
+ const addSubExpression = (expressionOperator: Operator) => {
+ const newExpression: Expression = addSubExpressionToExpression(expression, expressionOperator);
+ onSetExpression(newExpression);
+ };
+
+ const updateOperator = (expressionOperator: Operator) => {
+ const newExpression: Expression = updateOperatorOnExpression(expression, expressionOperator);
+ onSetExpression(newExpression);
+ };
+
+ const updateSubExpression = (index: number, subExpression: SubExpression) => {
+ const newExpression: Expression = updateSubExpressionOnExpression(
+ expression,
+ index,
+ subExpression,
+ );
+ onSetExpression(newExpression);
+ };
+
+ const updateComplexExpression = (newComplexExpression: any) => {
+ const newExpression: Expression = updateComplexExpressionOnExpression(
+ expression,
+ newComplexExpression,
+ );
+ onSetExpression(newExpression);
+ };
+
+ const handleToggleFreeStyleEditing = (event: React.ChangeEvent) => {
+ setFreeStyleEditing(event.target.checked);
+ if (event.target.checked) {
+ const stringRepresentationOfExpression = stringifyData(externalExpression);
+ updateComplexExpression(stringRepresentationOfExpression);
+ } else {
+ updateComplexExpression(undefined);
+ }
+ };
+
+ const allowToSaveExpression = canExpressionBeSaved(expression);
+ const externalExpression = convertInternalExpressionToExternal(expression);
+ const isStudioFriendly = isStudioFriendlyExpression(
+ tryParseExpression(expression, externalExpression).complexExpression,
+ );
+
+ return (
+
+
+ {t('right_menu.expression_enable_free_style_editing')}
+
+
+
+ }}
+ />
+
+
}
+ onClick={() => onDeleteExpression(expression)}
+ variant='tertiary'
+ size='small'
+ />
+
+ {complexExpressionIsSet(expression.complexExpression) ? (
+
+ ) : (
+
+
+ updateSubExpression(index, subExpression)
+ }
+ onUpdateExpressionOperator={(expressionOp: Operator) => updateOperator(expressionOp)}
+ onRemoveSubExpression={(subExp: SubExpression) => onDeleteSubExpression(subExp)}
+ />
+ addSubExpression(expression.operator || Operator.And)}
+ icon={}
+ >
+ {t('right_menu.expressions_add_sub_expression')}
+
+
+ )}
+
}
+ onClick={() => {
+ onSetEditMode(false);
+ onSaveExpression(expression);
+ }}
+ variant='primary'
+ size='small'
+ disabled={!allowToSaveExpression}
+ >
+ {t('right_menu.expression_save')}
+
+
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/SimpleExpression/DataSourceValue.test.tsx b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/SimpleExpression/DataSourceValue.test.tsx
new file mode 100644
index 00000000000..b4c644e03f7
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/SimpleExpression/DataSourceValue.test.tsx
@@ -0,0 +1,93 @@
+import React from 'react';
+import { screen } from '@testing-library/react';
+import type { DataSourceValueProps } from './DataSourceValue';
+import { DataSourceValue } from './DataSourceValue';
+import { DataSource } from '../../../../../../types/Expressions';
+import { subExpression0 } from '../../../../../../testing/expressionMocks';
+import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext';
+import { renderWithMockStore } from '../../../../../../testing/mocks';
+import { textMock } from '../../../../../../../../../testing/mocks/i18nMock';
+
+describe('DataSourceValue', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it.each([
+ DataSource.ApplicationSettings,
+ DataSource.Component,
+ DataSource.DataModel,
+ DataSource.InstanceContext,
+ ])('should render a Select component when currentDataSource is %s', (dataSource) => {
+ render({
+ props: {
+ currentDataSource: dataSource,
+ },
+ });
+
+ const selectElement = screen.getByRole('combobox');
+ expect(selectElement).toBeInTheDocument();
+ });
+ it('should render a TextField component when currentDataSource is DataSource.String', () => {
+ render({
+ props: {
+ currentDataSource: DataSource.String,
+ },
+ });
+
+ const textFieldElement = screen.getByRole('textbox');
+ expect(textFieldElement).toBeInTheDocument();
+ });
+ it('should render a TextField component that have inputmode=numeric attribute when currentDataSource is DataSource.Number', () => {
+ render({
+ props: {
+ currentDataSource: DataSource.Number,
+ },
+ });
+
+ const textFieldElement = screen.getByRole('textbox');
+ expect(textFieldElement).toHaveAttribute('inputmode', 'numeric');
+ expect(textFieldElement).toBeInTheDocument();
+ });
+ it('should render a ToggleButtonGroup component with true and false buttons when currentDataSource is DataSource.Boolean', () => {
+ render({
+ props: {
+ currentDataSource: DataSource.Boolean,
+ },
+ });
+
+ const trueButton = screen.getByRole('button', { name: textMock('general.true') });
+ const falseButton = screen.getByRole('button', { name: textMock('general.false') });
+ expect(trueButton).toBeInTheDocument();
+ expect(falseButton).toBeInTheDocument();
+ });
+ it('should not render select, textfield or button components when currentDataSource is DataSource.Null', () => {
+ render({
+ props: {
+ currentDataSource: DataSource.Null,
+ },
+ });
+ const selectElement = screen.queryByRole('combobox');
+ expect(selectElement).not.toBeInTheDocument();
+ const textFieldElement = screen.queryByRole('textbox');
+ expect(textFieldElement).not.toBeInTheDocument();
+ const buttonElement = screen.queryByRole('button');
+ expect(buttonElement).not.toBeInTheDocument();
+ });
+});
+
+const render = ({
+ props = {},
+ queries = {},
+}: {
+ props?: Partial;
+ queries?: Partial;
+}) => {
+ const defaultProps: DataSourceValueProps = {
+ subExpression: subExpression0,
+ currentDataSource: DataSource.Component,
+ specifyDataSourceValue: jest.fn(),
+ isComparableValue: false,
+ };
+ return renderWithMockStore({}, queries)();
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/SimpleExpression/DataSourceValue.tsx b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/SimpleExpression/DataSourceValue.tsx
new file mode 100644
index 00000000000..778556e6488
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/SimpleExpression/DataSourceValue.tsx
@@ -0,0 +1,126 @@
+import React from 'react';
+import type { LegacySingleSelectOption } from '@digdir/design-system-react';
+import {
+ LegacySelect,
+ LegacyToggleButtonGroup,
+ LegacyTextField,
+} from '@digdir/design-system-react';
+import type { SubExpression } from '../../../../../../types/Expressions';
+import { DataSource } from '../../../../../../types/Expressions';
+import type { DatamodelFieldElement } from 'app-shared/types/DatamodelFieldElement';
+import { useDatamodelMetadataQuery } from '../../../../../../hooks/queries/useDatamodelMetadataQuery';
+import { useFormLayoutsQuery } from '../../../../../../hooks/queries/useFormLayoutsQuery';
+import {
+ getComponentIds,
+ getDataModelElementNames,
+} from '../../../../../../utils/expressionsUtils';
+import { useText } from '../../../../../../hooks';
+import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams';
+import { useAppContext } from '../../../../../../hooks/useAppContext';
+
+export interface DataSourceValueProps {
+ subExpression: SubExpression;
+ currentDataSource: DataSource;
+ specifyDataSourceValue: (dataSourceValue: string, isComparable: boolean) => void;
+ isComparableValue: boolean;
+}
+
+export const DataSourceValue = ({
+ subExpression,
+ currentDataSource,
+ specifyDataSourceValue,
+ isComparableValue,
+}: DataSourceValueProps) => {
+ const { org, app } = useStudioUrlParams();
+ const { selectedLayoutSet } = useAppContext();
+ // TODO: Show spinner when isLoading
+ const datamodelQuery = useDatamodelMetadataQuery(org, app);
+ const { data: formLayoutsData } = useFormLayoutsQuery(org, app, selectedLayoutSet);
+ const t = useText();
+
+ const dataModelElementsData = datamodelQuery?.data ?? [];
+ const currentValue = isComparableValue ? subExpression.comparableValue : subExpression.value;
+ const selectedValueForDisplayIfBoolean = currentValue ? 'true' : 'false';
+
+ const getCorrespondingDataSourceValues = (dataSource: DataSource): LegacySingleSelectOption[] => {
+ switch (dataSource) {
+ case DataSource.Component:
+ return getComponentIds(formLayoutsData);
+ case DataSource.DataModel:
+ return getDataModelElementNames(dataModelElementsData as DatamodelFieldElement[]);
+ case DataSource.InstanceContext:
+ return ['instanceOwnerPartyId', 'instanceId', 'appId'].map((dsv: string) => ({
+ label: dsv,
+ value: dsv,
+ }));
+ case DataSource.ApplicationSettings:
+ // TODO: Should convert appmetadatasagas to react-query before implementing this. Issue #10856
+ return [{ label: 'Not implemented yet', value: 'NotImplementedYet' }];
+ default:
+ return [];
+ }
+ };
+
+ switch (currentDataSource) {
+ case DataSource.Component:
+ case DataSource.DataModel:
+ case DataSource.InstanceContext:
+ case DataSource.ApplicationSettings:
+ return (
+
+ specifyDataSourceValue(dataSourceValue, isComparableValue)
+ }
+ options={[
+ { label: t('right_menu.expressions_data_source_select'), value: 'default' },
+ ].concat(getCorrespondingDataSourceValues(currentDataSource))}
+ value={(currentValue as string) || 'default'}
+ />
+ );
+ case DataSource.String:
+ return (
+ specifyDataSourceValue(e.target.value, isComparableValue)}
+ value={currentValue as string}
+ />
+ );
+ case DataSource.Number:
+ return (
+ specifyDataSourceValue(e.target.value, isComparableValue)}
+ value={currentValue as string}
+ />
+ );
+ case DataSource.Boolean:
+ return (
+ specifyDataSourceValue(value, isComparableValue)}
+ selectedValue={selectedValueForDisplayIfBoolean}
+ />
+ );
+ default:
+ return null;
+ }
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/SimpleExpression/SimpleExpression.test.tsx b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/SimpleExpression/SimpleExpression.test.tsx
new file mode 100644
index 00000000000..c534a538b5d
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/SimpleExpression/SimpleExpression.test.tsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import { screen } from '@testing-library/react';
+import { internalExpressionWithMultipleSubExpressions } from '../../../../../../testing/expressionMocks';
+import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext';
+import { renderWithMockStore } from '../../../../../../testing/mocks';
+import { formDesignerMock } from '../../../../../../testing/stateMocks';
+import type { SimpleExpressionProps } from './SimpleExpression';
+import { SimpleExpression } from './SimpleExpression';
+import { textMock } from '../../../../../../../../../testing/mocks/i18nMock';
+import { createQueryClientMock } from 'app-shared/mocks/queryClientMock';
+import { QueryKey } from 'app-shared/types/QueryKey';
+import type { IFormLayouts } from '../../../../../../types/global';
+import { layout1NameMock, layoutMock } from '../../../../../../testing/layoutMock';
+
+const org = 'org';
+const app = 'app';
+const layoutSetName = formDesignerMock.layout.selectedLayoutSet;
+const layouts: IFormLayouts = {
+ [layout1NameMock]: layoutMock,
+};
+
+describe('SimpleExpression', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('displays two data source selector components from subExpressionContent when there are two subExpressions in the expression', () => {
+ render({});
+ const subExpressionDataSourceSelectors = screen.queryAllByRole('combobox', {
+ name: textMock('right_menu.expressions_data_source'),
+ });
+ expect(subExpressionDataSourceSelectors).toHaveLength(2);
+ });
+ it('displays one addSubExpressionButton and one toggleButtonGroup with OR operator pressed', () => {
+ render({});
+ const operatorToggleGroupOr = screen.getByRole('button', {
+ name: textMock('right_menu.expressions_operator_or'),
+ });
+ const operatorToggleGroupAnd = screen.getByRole('button', {
+ name: textMock('right_menu.expressions_operator_and'),
+ });
+ expect(operatorToggleGroupOr).toBeInTheDocument();
+ expect(operatorToggleGroupOr).toHaveAttribute('aria-pressed', 'true');
+ expect(operatorToggleGroupAnd).toBeInTheDocument();
+ expect(operatorToggleGroupAnd).toHaveAttribute('aria-pressed', 'false');
+ });
+});
+
+const render = ({
+ props = {},
+ queries = {},
+}: {
+ props?: Partial;
+ queries?: Partial;
+}) => {
+ const defaultProps: SimpleExpressionProps = {
+ expression: internalExpressionWithMultipleSubExpressions,
+ onRemoveSubExpression: jest.fn(),
+ onUpdateExpressionOperator: jest.fn(),
+ onUpdateSubExpression: jest.fn(),
+ };
+ const queryClient = createQueryClientMock();
+ queryClient.setQueryData([QueryKey.FormLayouts, org, app, layoutSetName], layouts);
+ return renderWithMockStore(
+ {},
+ queries,
+ queryClient,
+ )();
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/SimpleExpression/SimpleExpression.tsx b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/SimpleExpression/SimpleExpression.tsx
new file mode 100644
index 00000000000..7148b86eb96
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/SimpleExpression/SimpleExpression.tsx
@@ -0,0 +1,49 @@
+import React from 'react';
+import type { Expression, SubExpression } from '../../../../../../types/Expressions';
+import { Operator } from '../../../../../../types/Expressions';
+import { SubExpressionContent } from './SubExpressionContent';
+import { LegacyToggleButtonGroup } from '@digdir/design-system-react';
+import { useText } from '../../../../../../hooks';
+import { Divider } from 'app-shared/primitives';
+
+export type SimpleExpressionProps = {
+ expression: Expression;
+ onUpdateExpressionOperator: (expressionOperator: Operator) => void;
+ onUpdateSubExpression: (index: number, subExpression: SubExpression) => void;
+ onRemoveSubExpression: (subExpression: SubExpression) => void;
+};
+
+export const SimpleExpression = ({
+ expression,
+ onUpdateExpressionOperator,
+ onUpdateSubExpression,
+ onRemoveSubExpression,
+}: SimpleExpressionProps) => {
+ const t = useText();
+ return (
+ <>
+ {expression.subExpressions?.map((subExp: SubExpression, index: number) => (
+
+
+
+ onUpdateSubExpression(index, subExpression)
+ }
+ onRemoveSubExpression={() => onRemoveSubExpression(subExp)}
+ />
+ {index !== expression.subExpressions.length - 1 && (
+ onUpdateExpressionOperator(value as Operator)}
+ selectedValue={expression.operator || Operator.And}
+ />
+ )}
+
+ ))}
+ >
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/SimpleExpression/SubExpressionContent.module.css b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/SimpleExpression/SubExpressionContent.module.css
new file mode 100644
index 00000000000..80070e79369
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/SimpleExpression/SubExpressionContent.module.css
@@ -0,0 +1,19 @@
+.subExpressionTop {
+ display: flex;
+ justify-content: space-between;
+}
+
+.subExpression {
+ display: flex;
+ flex-direction: column;
+ background-color: #e9eaec;
+ padding: 1rem;
+ gap: 0.5rem;
+ border: 1px solid #d1d1d1;
+ border-radius: 5px;
+}
+
+.expressionFunction {
+ text-align: center;
+ font-weight: bold;
+}
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/SimpleExpression/SubExpressionContent.test.tsx b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/SimpleExpression/SubExpressionContent.test.tsx
new file mode 100644
index 00000000000..4a97a98b89b
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/SimpleExpression/SubExpressionContent.test.tsx
@@ -0,0 +1,201 @@
+import React from 'react';
+import { act, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import type { SubExpressionContentProps } from './SubExpressionContent';
+import { SubExpressionContent } from './SubExpressionContent';
+import {
+ baseInternalSubExpression,
+ componentId,
+ stringValue,
+ subExpression0,
+} from '../../../../../../testing/expressionMocks';
+import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext';
+import { renderWithMockStore } from '../../../../../../testing/mocks';
+import { formDesignerMock } from '../../../../../../testing/stateMocks';
+import { textMock } from '../../../../../../../../../testing/mocks/i18nMock';
+import { createQueryClientMock } from 'app-shared/mocks/queryClientMock';
+import { QueryKey } from 'app-shared/types/QueryKey';
+import { layout1NameMock, layoutMock } from '../../../../../../testing/layoutMock';
+import type { IFormLayouts } from '../../../../../../types/global';
+import { DataSource } from '../../../../../../types/Expressions';
+
+const user = userEvent.setup();
+const org = 'org';
+const app = 'app';
+const layoutSetName = formDesignerMock.layout.selectedLayoutSet;
+
+const layouts: IFormLayouts = {
+ [layout1NameMock]: layoutMock,
+};
+
+describe('SubExpressionContent', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ it('renders function select when subExpression does not have function set', () => {
+ render({
+ props: {
+ subExpression: {},
+ },
+ });
+
+ const selectElement = screen.getByRole('combobox');
+ const functionSelectTitle = screen.getByText(
+ textMock('right_menu.expressions_function_on_property'),
+ );
+ expect(selectElement).toBeInTheDocument();
+ expect(functionSelectTitle).toBeInTheDocument();
+ });
+ it('displays "default" value in only two select components when subExpression only has property set', () => {
+ render({
+ props: {
+ subExpression: baseInternalSubExpression,
+ },
+ });
+
+ const dataSourceSelectElement = screen.getByRole('combobox', {
+ name: textMock('right_menu.expressions_data_source'),
+ });
+ const comparableDataSourceSelectElement = screen.getByRole('combobox', {
+ name: textMock('right_menu.expressions_comparable_data_source'),
+ });
+ expect(dataSourceSelectElement).toBeInTheDocument();
+ expect(comparableDataSourceSelectElement).toBeInTheDocument();
+ expect(dataSourceSelectElement).toHaveValue(
+ textMock('right_menu.expressions_data_source_select'),
+ );
+ expect(comparableDataSourceSelectElement).toHaveValue(
+ textMock('right_menu.expressions_data_source_select'),
+ );
+ });
+ it('calls onUpdateSubExpression when subExpression had existing value and dataSource is changed to DataSource.DataModel', async () => {
+ const onUpdateSubExpression = jest.fn();
+ render({
+ props: {
+ onUpdateSubExpression: onUpdateSubExpression,
+ subExpression: {
+ ...baseInternalSubExpression,
+ dataSource: DataSource.Component,
+ value: componentId,
+ },
+ },
+ });
+
+ // Find select components
+ const selectDataSourceComponent = screen.getByRole('combobox', {
+ name: textMock('right_menu.expressions_data_source'),
+ });
+ expect(selectDataSourceComponent).toHaveValue(
+ textMock('right_menu.expressions_data_source_component'),
+ );
+ const referenceSelector = screen.getByRole('combobox', {
+ name: textMock('right_menu.expressions_data_source_value'),
+ });
+ expect(referenceSelector).toHaveValue(subExpression0.value as string);
+ // Click component/dataSource dropdown
+ await act(() => user.click(selectDataSourceComponent));
+ await act(() =>
+ user.click(
+ screen.getByRole('option', {
+ name: textMock('right_menu.expressions_data_source_data_model'),
+ }),
+ ),
+ );
+ expect(onUpdateSubExpression).toHaveBeenCalledTimes(1);
+ expect(onUpdateSubExpression).toHaveBeenCalledWith({
+ ...baseInternalSubExpression,
+ dataSource: DataSource.DataModel,
+ value: undefined,
+ });
+ });
+ it('calls onUpdateSubExpression when subExpression had existing value and dataSourceValue is changed to a new string', async () => {
+ const onUpdateSubExpression = jest.fn();
+ render({
+ props: {
+ onUpdateSubExpression: onUpdateSubExpression,
+ subExpression: {
+ ...baseInternalSubExpression,
+ comparableDataSource: DataSource.String,
+ comparableValue: stringValue,
+ },
+ },
+ });
+
+ // Find select components
+ const selectDataSourceComponent = screen.getByRole('combobox', {
+ name: textMock('right_menu.expressions_comparable_data_source'),
+ });
+ expect(selectDataSourceComponent).toHaveValue(
+ textMock('right_menu.expressions_data_source_string'),
+ );
+ const comparableValueInputField = screen.getByRole('textbox', {
+ name: textMock('right_menu.expressions_data_source_comparable_value'),
+ });
+ expect(comparableValueInputField).toHaveValue(subExpression0.comparableValue as string);
+ // Type new value to string comparable data source value
+ await act(() => user.clear(comparableValueInputField));
+
+ expect(onUpdateSubExpression).toHaveBeenCalledTimes(1);
+ expect(onUpdateSubExpression).toHaveBeenCalledWith({
+ ...baseInternalSubExpression,
+ comparableDataSource: DataSource.String,
+ comparableValue: '',
+ });
+ });
+ it('displays dataSource, value, comparableDataSource and comparableValue when all are set on subExpression', async () => {
+ render({});
+
+ const selectDataSourceComponent = screen.getByRole('combobox', {
+ name: textMock('right_menu.expressions_data_source'),
+ });
+ const selectValueComponent = screen.getByRole('combobox', {
+ name: textMock('right_menu.expressions_data_source_value'),
+ });
+ const selectComparableDataSourceComponent = screen.getByRole('combobox', {
+ name: textMock('right_menu.expressions_comparable_data_source'),
+ });
+ const selectComparableValueComponent = screen.getByRole('textbox');
+ expect(selectDataSourceComponent).toHaveValue(
+ textMock('right_menu.expressions_data_source_component'),
+ );
+ expect(selectValueComponent).toHaveValue(subExpression0.value as string);
+ expect(selectComparableDataSourceComponent).toHaveValue(
+ textMock('right_menu.expressions_data_source_string'),
+ );
+ expect(selectComparableValueComponent).toHaveValue(subExpression0.comparableValue as string);
+ });
+ it('removes subExpression from expression object and renders nothing when remove-sub-expression is clicked', async () => {
+ const onRemoveSubExpression = jest.fn();
+ render({
+ props: {
+ onRemoveSubExpression: onRemoveSubExpression,
+ },
+ });
+ const deleteSubExpressionButton = screen.getByTitle(
+ textMock('right_menu.expression_sub_expression_delete'),
+ );
+ await act(() => user.click(deleteSubExpressionButton));
+ expect(onRemoveSubExpression).toHaveBeenCalledTimes(1);
+ });
+});
+
+const render = ({
+ props = {},
+ queries = {},
+}: {
+ props?: Partial;
+ queries?: Partial;
+}) => {
+ const defaultProps: SubExpressionContentProps = {
+ subExpression: subExpression0,
+ onUpdateSubExpression: jest.fn(),
+ onRemoveSubExpression: jest.fn(),
+ };
+ const queryClient = createQueryClientMock();
+ queryClient.setQueryData([QueryKey.FormLayouts, org, app, layoutSetName], layouts);
+ return renderWithMockStore(
+ {},
+ queries,
+ queryClient,
+ )();
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/SimpleExpression/SubExpressionContent.tsx b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/SimpleExpression/SubExpressionContent.tsx
new file mode 100644
index 00000000000..7c5656763d5
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/SimpleExpression/SubExpressionContent.tsx
@@ -0,0 +1,138 @@
+import React from 'react';
+import { Button, LegacySelect } from '@digdir/design-system-react';
+import type { SubExpression } from '../../../../../../types/Expressions';
+import {
+ DataSource,
+ expressionDataSourceTexts,
+ ExpressionFunction,
+ expressionFunctionTexts,
+} from '../../../../../../types/Expressions';
+import { TrashIcon } from '@navikt/aksel-icons';
+import classes from './SubExpressionContent.module.css';
+import { DataSourceValue } from './DataSourceValue';
+import {
+ addDataSourceToSubExpression,
+ addDataSourceValueToSubExpression,
+ addFunctionToSubExpression,
+} from '../../../../../../utils/expressionsUtils';
+import { useText } from '../../../../../../hooks';
+
+export interface SubExpressionContentProps {
+ subExpression: SubExpression;
+ onUpdateSubExpression: (subExpression: SubExpression) => void;
+ onRemoveSubExpression: (subExpression: SubExpression) => void;
+}
+
+export const SubExpressionContent = ({
+ subExpression,
+ onUpdateSubExpression,
+ onRemoveSubExpression,
+}: SubExpressionContentProps) => {
+ const t = useText();
+
+ const allowToSpecifyExpression = Object.values(ExpressionFunction).includes(
+ subExpression.function as ExpressionFunction,
+ );
+
+ const addFunction = (func: string) => {
+ const newSubExpression = addFunctionToSubExpression(subExpression, func);
+ onUpdateSubExpression(newSubExpression);
+ };
+
+ const addDataSource = (dataSource: string, isComparable: boolean) => {
+ const newSubExpression = addDataSourceToSubExpression(subExpression, dataSource, isComparable);
+ onUpdateSubExpression(newSubExpression);
+ };
+
+ const addDataSourceValue = (dataSourceValue: string, isComparable: boolean) => {
+ const newSubExpression = addDataSourceValueToSubExpression(
+ subExpression,
+ dataSourceValue,
+ isComparable,
+ );
+ onUpdateSubExpression(newSubExpression);
+ };
+
+ return (
+ <>
+
+
{t('right_menu.expressions_function_on_property')}
+
}
+ onClick={() => onRemoveSubExpression(subExpression)}
+ variant='tertiary'
+ size='small'
+ />
+
+ addFunction(func)}
+ options={[{ label: t('right_menu.expressions_function_select'), value: 'default' }].concat(
+ Object.values(ExpressionFunction).map((func: string) => ({
+ label: expressionFunctionTexts(t)[func],
+ value: func,
+ })),
+ )}
+ value={subExpression.function || 'default'}
+ />
+ {allowToSpecifyExpression && (
+
+
addDataSource(dataSource, false)}
+ options={[
+ { label: t('right_menu.expressions_data_source_select'), value: 'default' },
+ ].concat(
+ Object.values(DataSource).map((ds: string) => ({
+ label: expressionDataSourceTexts(t)[ds],
+ value: ds,
+ })),
+ )}
+ value={subExpression.dataSource || 'default'}
+ />
+ {subExpression.dataSource && (
+
+ addDataSourceValue(dataSourceValue, false)
+ }
+ isComparableValue={false}
+ />
+ )}
+
+ {expressionFunctionTexts(t)[subExpression.function]}
+
+ addDataSource(compDataSource, true)}
+ options={[
+ { label: t('right_menu.expressions_data_source_select'), value: 'default' },
+ ].concat(
+ Object.values(DataSource).map((cds: string) => ({
+ label: expressionDataSourceTexts(t)[cds],
+ value: cds,
+ })),
+ )}
+ value={subExpression.comparableDataSource || 'default'}
+ />
+ {subExpression.comparableDataSource && (
+
+ addDataSourceValue(dataSourceValue, true)
+ }
+ isComparableValue={true}
+ />
+ )}
+
+ )}
+ >
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/SimpleExpression/index.ts b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/SimpleExpression/index.ts
new file mode 100644
index 00000000000..9b2dd2d1a78
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/SimpleExpression/index.ts
@@ -0,0 +1,2 @@
+export { SimpleExpression } from './SimpleExpression';
+export type { SimpleExpressionProps } from './SimpleExpression';
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/index.ts b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/index.ts
new file mode 100644
index 00000000000..b19dc0901a2
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionEditMode/index.ts
@@ -0,0 +1 @@
+export { ExpressionEditMode } from './ExpressionEditMode';
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionPreview/ExpressionPreview.test.tsx b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionPreview/ExpressionPreview.test.tsx
new file mode 100644
index 00000000000..145e401b8ff
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionPreview/ExpressionPreview.test.tsx
@@ -0,0 +1,88 @@
+import React from 'react';
+import { act, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import {
+ internalUnParsableComplexExpression,
+ simpleInternalExpression,
+} from '../../../../../testing/expressionMocks';
+import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext';
+import { renderWithMockStore } from '../../../../../testing/mocks';
+import { formDesignerMock } from '../../../../../testing/stateMocks';
+import type { IFormLayouts } from '../../../../../types/global';
+import { layout1NameMock, layoutMock } from '../../../../../testing/layoutMock';
+import { textMock } from '../../../../../../../../testing/mocks/i18nMock';
+import { createQueryClientMock } from 'app-shared/mocks/queryClientMock';
+import { QueryKey } from 'app-shared/types/QueryKey';
+import { componentId } from '../../../../../testing/expressionMocks';
+import type { ExpressionPreviewProps } from './ExpressionPreview';
+import { ExpressionPreview } from './ExpressionPreview';
+
+const org = 'org';
+const app = 'app';
+const layoutSetName = formDesignerMock.layout.selectedLayoutSet;
+const layouts: IFormLayouts = {
+ [layout1NameMock]: layoutMock,
+};
+
+describe('ExpressionPreview', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ it('does not show save button when expression is in previewMode', () => {
+ render({});
+
+ const saveExpressionButton = screen.queryByRole('button', { name: textMock('general.save') });
+ expect(saveExpressionButton).not.toBeInTheDocument();
+ });
+ it('renders the complex expression in preview mode when complex expression is set', () => {
+ render({
+ props: {
+ expression: internalUnParsableComplexExpression,
+ },
+ });
+
+ const complexExpression = screen.getByRole('textbox');
+ expect(complexExpression).toBeInTheDocument();
+ expect(complexExpression).toHaveValue(internalUnParsableComplexExpression.complexExpression);
+ expect(complexExpression).toHaveAttribute('disabled');
+ const saveExpressionButton = screen.queryByRole('button', { name: textMock('general.save') });
+ expect(saveExpressionButton).not.toBeInTheDocument();
+ });
+ it('calls onDeleteExpression when deleteExpression button is clicked', async () => {
+ const user = userEvent.setup();
+ const mockOnDeleteExpression = jest.fn();
+ render({
+ props: {
+ onDeleteExpression: mockOnDeleteExpression,
+ },
+ });
+ const deleteExpressionButton = screen.getByRole('button', {
+ name: textMock('right_menu.expression_delete'),
+ });
+ await act(() => user.click(deleteExpressionButton));
+ expect(mockOnDeleteExpression).toHaveBeenCalledWith(simpleInternalExpression);
+ expect(mockOnDeleteExpression).toHaveBeenCalledTimes(1);
+ });
+});
+
+const render = ({
+ props = {},
+ queries = {},
+}: {
+ props?: Partial;
+ queries?: Partial;
+}) => {
+ const defaultProps: ExpressionPreviewProps = {
+ expression: simpleInternalExpression,
+ componentName: componentId,
+ onSetEditMode: jest.fn(),
+ onDeleteExpression: jest.fn(),
+ };
+ const queryClient = createQueryClientMock();
+ queryClient.setQueryData([QueryKey.FormLayouts, org, app, layoutSetName], layouts);
+ return renderWithMockStore(
+ {},
+ queries,
+ queryClient,
+ )();
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionPreview/ExpressionPreview.tsx b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionPreview/ExpressionPreview.tsx
new file mode 100644
index 00000000000..b2d50c0734c
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionPreview/ExpressionPreview.tsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import classes from '../ExpressionContent.module.css';
+import type { Expression } from '../../../../../types/Expressions';
+import { expressionInPreviewPropertyTextKeys } from '../../../../../types/Expressions';
+import { complexExpressionIsSet } from '../../../../../utils/expressionsUtils';
+import { ComplexExpression } from '../ComplexExpression';
+import { SimpleExpressionPreview } from './SimpleExpressionPreview';
+import { StudioButton } from '@studio/components';
+import { PencilIcon, TrashIcon } from '@navikt/aksel-icons';
+import { useText } from '../../../../../hooks';
+import cn from 'classnames';
+import { Trans } from 'react-i18next';
+
+export interface ExpressionPreviewProps {
+ expression: Expression;
+ componentName: string;
+ onSetEditMode: (editMode: boolean) => void;
+ onDeleteExpression: (expression: Expression) => void;
+}
+
+export const ExpressionPreview = ({
+ expression,
+ componentName,
+ onSetEditMode,
+ onDeleteExpression,
+}: ExpressionPreviewProps) => {
+ const t = useText();
+
+ return (
+
+
+
+ }}
+ />
+
+ {complexExpressionIsSet(expression.complexExpression) ? (
+
+ ) : (
+
+ )}
+
+
+ }
+ onClick={() => onDeleteExpression(expression)}
+ variant='tertiary'
+ size='small'
+ />
+ }
+ onClick={() => onSetEditMode(true)}
+ variant='tertiary'
+ size='small'
+ />
+
+
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionPreview/SimpleExpressionPreview.test.tsx b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionPreview/SimpleExpressionPreview.test.tsx
new file mode 100644
index 00000000000..b8e062f44f8
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionPreview/SimpleExpressionPreview.test.tsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import { screen } from '@testing-library/react';
+import { internalExpressionWithMultipleSubExpressions } from '../../../../../testing/expressionMocks';
+import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext';
+import { renderWithMockStore } from '../../../../../testing/mocks';
+import type { SimpleExpressionPreviewProps } from './SimpleExpressionPreview';
+import { SimpleExpressionPreview } from './SimpleExpressionPreview';
+import { textMock } from '../../../../../../../../testing/mocks/i18nMock';
+import { ExpressionFunction, ExpressionPropertyBase } from '../../../../../types/Expressions';
+
+describe('SimpleExpressionPreview', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('displays all values for a subexpression as strings and operator', () => {
+ render({});
+
+ const nullText = screen.getByText(textMock('right_menu.expressions_data_source_null'));
+ expect(nullText).toBeInTheDocument();
+ const numberText = screen.getByText(textMock('right_menu.expressions_data_source_number'));
+ expect(numberText).toBeInTheDocument();
+ const numberValueText = screen.getByText(
+ internalExpressionWithMultipleSubExpressions.subExpressions[0].comparableValue as string,
+ );
+ expect(numberValueText).toBeInTheDocument();
+ const booleanText = screen.getByText(textMock('right_menu.expressions_data_source_boolean'));
+ expect(booleanText).toBeInTheDocument();
+ const booleanValueText = screen.getByText(textMock('general.true'));
+ expect(booleanValueText).toBeInTheDocument();
+ const componentText = screen.getByText(
+ textMock('right_menu.expressions_data_source_component'),
+ );
+ expect(componentText).toBeInTheDocument();
+ const componentValueText = screen.getByText(
+ internalExpressionWithMultipleSubExpressions.subExpressions[1].comparableValue as string,
+ );
+ expect(componentValueText).toBeInTheDocument();
+ const operatorText = screen.getByText(textMock('right_menu.expressions_operator_or'));
+ expect(operatorText).toBeInTheDocument();
+ });
+ it('displays Null as both datasources if nothing more is set for a sub expression than a function', () => {
+ render({
+ props: {
+ expression: {
+ property: ExpressionPropertyBase.Hidden,
+ subExpressions: [
+ {
+ function: ExpressionFunction.Not,
+ },
+ ],
+ },
+ },
+ });
+ const nullText = screen.getAllByText(textMock('right_menu.expressions_data_source_null'));
+ expect(nullText).toHaveLength(2);
+ });
+});
+
+const render = ({
+ props = {},
+ queries = {},
+}: {
+ props?: Partial;
+ queries?: Partial;
+}) => {
+ const defaultProps: SimpleExpressionPreviewProps = {
+ expression: internalExpressionWithMultipleSubExpressions,
+ };
+ return renderWithMockStore({}, queries)();
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionPreview/SimpleExpressionPreview.tsx b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionPreview/SimpleExpressionPreview.tsx
new file mode 100644
index 00000000000..710f9febd2c
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionPreview/SimpleExpressionPreview.tsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import type { Expression, SubExpression } from '../../../../../types/Expressions';
+import {
+ DataSource,
+ expressionDataSourceTexts,
+ expressionFunctionTexts,
+ Operator,
+} from '../../../../../types/Expressions';
+import { ArrowRightIcon } from '@navikt/aksel-icons';
+import { useText } from '../../../../../hooks';
+import { stringifyValueForDisplay } from '../../../../../utils/expressionsUtils';
+
+export type SimpleExpressionPreviewProps = {
+ expression: Expression;
+};
+
+export const SimpleExpressionPreview = ({ expression }: SimpleExpressionPreviewProps) => {
+ const t = useText();
+ return (
+ <>
+ {expression.subExpressions.map((subExp: SubExpression, index: number) => (
+
+
+
+ {expressionDataSourceTexts(t)[subExp.dataSource ?? DataSource.Null]}
+ {stringifyValueForDisplay(t, subExp.value)}
+
+
{expressionFunctionTexts(t)[subExp.function]}
+
+
+ {expressionDataSourceTexts(t)[subExp.comparableDataSource ?? DataSource.Null]}
+ {stringifyValueForDisplay(t, subExp.comparableValue)}
+
+ {index !== expression.subExpressions.length - 1 && (
+
+ {expression.operator === Operator.And
+ ? t('right_menu.expressions_operator_and')
+ : t('right_menu.expressions_operator_or')}
+
+ )}
+
+ ))}
+ >
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionPreview/index.ts b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionPreview/index.ts
new file mode 100644
index 00000000000..e73c46ae135
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/ExpressionPreview/index.ts
@@ -0,0 +1 @@
+export { ExpressionPreview } from './ExpressionPreview';
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/index.ts b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/index.ts
new file mode 100644
index 00000000000..f1cf7f8f3ac
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/ExpressionContent/index.ts
@@ -0,0 +1 @@
+export { ExpressionContent } from './ExpressionContent';
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/Expressions.module.css b/frontend/packages/ux-editor-v3/src/components/config/Expressions/Expressions.module.css
new file mode 100644
index 00000000000..28151a383cd
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/Expressions.module.css
@@ -0,0 +1,9 @@
+.root {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.expressionsAlert {
+ z-index: 1;
+}
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/Expressions.test.tsx b/frontend/packages/ux-editor-v3/src/components/config/Expressions/Expressions.test.tsx
new file mode 100644
index 00000000000..4b8cfd58a3b
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/Expressions.test.tsx
@@ -0,0 +1,237 @@
+import React from 'react';
+import { act, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext';
+import { renderWithMockStore } from '../../../testing/mocks';
+import { formDesignerMock } from '../../../testing/stateMocks';
+import { formContextProviderMock } from '../../../testing/formContextMocks';
+import type { IFormLayouts } from '../../../types/global';
+import { layout1NameMock, layoutMock } from '../../../testing/layoutMock';
+import { textMock } from '../../../../../../testing/mocks/i18nMock';
+import { createQueryClientMock } from 'app-shared/mocks/queryClientMock';
+import { QueryKey } from 'app-shared/types/QueryKey';
+import { Expressions } from './Expressions';
+import { FormContext } from '../../../containers/FormContext';
+import type { FormComponent } from '../../../types/FormComponent';
+import { ComponentType } from 'app-shared/types/ComponentType';
+import { parsableExternalExpression } from '../../../testing/expressionMocks';
+import type { FormContainer } from '../../../types/FormContainer';
+
+const org = 'org';
+const app = 'app';
+const layoutSetName = formDesignerMock.layout.selectedLayoutSet;
+const layouts: IFormLayouts = {
+ [layout1NameMock]: layoutMock,
+};
+const componentWithExpression: FormComponent = {
+ id: 'some-id',
+ type: ComponentType.Input,
+ itemType: 'COMPONENT',
+ hidden: parsableExternalExpression,
+};
+
+describe('Expressions', () => {
+ beforeEach(jest.clearAllMocks);
+
+ it('renders only add new expression button when there are no existing expressions on component', async () => {
+ render({ component: { ...componentWithExpression, hidden: true } });
+ const deleteExpressionButtons = screen.queryByRole('button', {
+ name: textMock('right_menu.expression_delete'),
+ });
+ expect(deleteExpressionButtons).not.toBeInTheDocument();
+ const addExpressionButton = screen.getByRole('button', {
+ name: textMock('right_menu.expressions_add'),
+ });
+ expect(addExpressionButton).toBeInTheDocument();
+ });
+
+ it('renders existing expressions and addExpressionButton when hidden field on the component has an expression', () => {
+ render({});
+
+ const deleteExpressionButton = screen.getByRole('button', {
+ name: textMock('right_menu.expression_delete'),
+ });
+ expect(deleteExpressionButton).toBeInTheDocument();
+ const editExpressionButton = screen.getByRole('button', {
+ name: textMock('right_menu.expression_edit'),
+ });
+ expect(editExpressionButton).toBeInTheDocument();
+ const addExpressionButton = screen.getByRole('button', {
+ name: textMock('right_menu.expressions_add'),
+ });
+ expect(addExpressionButton).toBeInTheDocument();
+ const expressionLimitAlert = screen.queryByText(
+ textMock('right_menu.expressions_expressions_limit_reached_alert'),
+ );
+ expect(expressionLimitAlert).not.toBeInTheDocument();
+ });
+
+ it('renders alert component when there are as many existing expressions as available properties to set expressions on for a regular component', () => {
+ const componentWithMultipleExpressions: FormComponent = {
+ id: 'some-id',
+ type: ComponentType.Input,
+ itemType: 'COMPONENT',
+ hidden: parsableExternalExpression,
+ required: parsableExternalExpression,
+ readOnly: parsableExternalExpression,
+ };
+ render({
+ component: componentWithMultipleExpressions,
+ });
+
+ const expressionLimitAlert = screen.queryByText(
+ textMock('right_menu.expressions_expressions_limit_reached_alert'),
+ );
+ expect(expressionLimitAlert).toBeInTheDocument();
+ });
+
+ it('renders alert component when there are as many existing expressions as available properties to set expressions on for a group component', () => {
+ const groupComponentWithAllBooleanFieldsAsExpressions: FormContainer = {
+ id: 'some-id',
+ itemType: 'CONTAINER',
+ type: ComponentType.Group,
+ hidden: parsableExternalExpression,
+ required: parsableExternalExpression,
+ readOnly: parsableExternalExpression,
+ edit: {
+ addButton: parsableExternalExpression,
+ deleteButton: parsableExternalExpression,
+ saveButton: parsableExternalExpression,
+ saveAndNextButton: parsableExternalExpression,
+ },
+ };
+ render({
+ component: groupComponentWithAllBooleanFieldsAsExpressions,
+ });
+
+ const expressionLimitAlert = screen.getByText(
+ textMock('right_menu.expressions_expressions_limit_reached_alert'),
+ );
+ expect(expressionLimitAlert).toBeInTheDocument();
+ });
+
+ it('adds new expression on read only property when read only menuItem is selected after add expression button is clicked', async () => {
+ const user = userEvent.setup();
+ render({});
+
+ const addExpressionButton = screen.getByRole('button', {
+ name: textMock('right_menu.expressions_add'),
+ });
+ await act(() => user.click(addExpressionButton));
+ const propertyDropDownMenuItem = screen.getByRole('menuitem', {
+ name: textMock('right_menu.expressions_property_read_only'),
+ });
+ await act(() => user.click(propertyDropDownMenuItem));
+
+ const newExpression = screen.getByText(
+ textMock('right_menu.expressions_property_preview_read_only'),
+ );
+ expect(newExpression).toBeInTheDocument();
+ });
+
+ it('expression is no longer in previewMode when edit expression is clicked', async () => {
+ const user = userEvent.setup();
+ render({});
+
+ const expressionInPreview = screen.getByText(
+ textMock('right_menu.expressions_property_preview_hidden'),
+ );
+ expect(expressionInPreview).toBeInTheDocument();
+
+ const editExpressionButton = screen.getByRole('button', {
+ name: textMock('right_menu.expression_edit'),
+ });
+ await act(() => user.click(editExpressionButton));
+
+ expect(expressionInPreview).not.toBeInTheDocument();
+ });
+
+ it('expression is deleted when delete expression button is clicked', async () => {
+ const user = userEvent.setup();
+ render({});
+
+ const deleteExpressionButton = screen.getByRole('button', {
+ name: textMock('right_menu.expression_delete'),
+ });
+ expect(deleteExpressionButton).toBeInTheDocument();
+ await act(() => user.click(deleteExpressionButton));
+
+ expect(deleteExpressionButton).not.toBeInTheDocument();
+ });
+
+ it('Renders successfully when the component is a multipage group', () => {
+ const component: FormContainer = {
+ id: 'some-id',
+ itemType: 'CONTAINER',
+ type: ComponentType.Group,
+ edit: {
+ multiPage: true,
+ },
+ };
+ render({ component });
+ expect(
+ screen.getByText(textMock('right_menu.read_more_about_expressions')),
+ ).toBeInTheDocument();
+ });
+
+ it('renders no existing expressions when component fields are boolean', () => {
+ const componentWithoutExpressions: FormComponent = {
+ id: 'some-id',
+ type: ComponentType.Input,
+ itemType: 'COMPONENT',
+ hidden: true,
+ required: false,
+ readOnly: true,
+ };
+ render({
+ component: componentWithoutExpressions,
+ });
+
+ const createRuleForComponentIdText = screen.getByText(
+ textMock('right_menu.expressions_property_on_component'),
+ );
+ expect(createRuleForComponentIdText).toBeInTheDocument();
+ const createNewExpressionButton = screen.getByRole('button', {
+ name: textMock('right_menu.expressions_add'),
+ });
+ expect(createNewExpressionButton).toBeInTheDocument();
+ });
+
+ it('renders link to docs', () => {
+ render({});
+
+ const linkToExpressionDocs = screen.getByText(
+ textMock('right_menu.read_more_about_expressions'),
+ );
+ expect(linkToExpressionDocs).toBeInTheDocument();
+ });
+});
+
+const render = ({
+ props = {},
+ queries = {},
+ component = componentWithExpression,
+}: {
+ props?: Partial;
+ queries?: Partial;
+ component?: FormComponent | FormContainer;
+}) => {
+ const queryClient = createQueryClientMock();
+ queryClient.setQueryData([QueryKey.FormLayouts, org, app, layoutSetName], layouts);
+ return renderWithMockStore(
+ {},
+ queries,
+ queryClient,
+ )(
+
+
+ ,
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/Expressions.tsx b/frontend/packages/ux-editor-v3/src/components/config/Expressions/Expressions.tsx
new file mode 100644
index 00000000000..786599919db
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/Expressions.tsx
@@ -0,0 +1,95 @@
+import React, { useContext } from 'react';
+import { Alert } from '@digdir/design-system-react';
+import { ExpressionContent } from './ExpressionContent';
+import { useText } from '../../../hooks';
+import type { ExpressionProperty } from '../../../types/Expressions';
+import { getExpressionPropertiesBasedOnComponentType } from '../../../types/Expressions';
+import {
+ addPropertyForExpression,
+ getAllComponentPropertiesThatCanHaveExpressions,
+ getNonOverlappingElementsFromTwoLists,
+ getPropertiesWithExistingExpression,
+} from '../../../utils/expressionsUtils';
+import classes from './Expressions.module.css';
+import type { LayoutItemType } from '../../../types/global';
+import { FormContext } from '../../../containers/FormContext';
+import { altinnDocsUrl } from 'app-shared/ext-urls';
+import { Trans } from 'react-i18next';
+import { NewExpressionButton } from './NewExpressionButton';
+
+export const Expressions = () => {
+ const { form } = useContext(FormContext);
+ const availableProperties = getAllComponentPropertiesThatCanHaveExpressions(form);
+ const propertiesFromComponentWithExpressions = getPropertiesWithExistingExpression(
+ form,
+ availableProperties,
+ );
+ const [propertiesWithExpressions, setPropertiesWithExpressions] = React.useState<
+ ExpressionProperty[]
+ >(propertiesFromComponentWithExpressions.length ? propertiesFromComponentWithExpressions : []);
+ const [newlyAddedProperty, setNewlyAddedProperty] = React.useState(undefined);
+ const t = useText();
+ const expressionProperties = getExpressionPropertiesBasedOnComponentType(
+ form.itemType as LayoutItemType,
+ );
+ const isExpressionLimitReached =
+ propertiesWithExpressions?.length >= expressionProperties?.length;
+
+ const addNewExpression = (property: ExpressionProperty) => {
+ const newProperties = addPropertyForExpression(propertiesWithExpressions, property);
+ setPropertiesWithExpressions(newProperties);
+ setNewlyAddedProperty(newProperties.at(newProperties.length - 1));
+ };
+
+ const handleDeleteExpression = (propertyToDelete: ExpressionProperty) => {
+ const updatedProperties = propertiesWithExpressions.filter(
+ (property) => property !== propertyToDelete,
+ );
+ setPropertiesWithExpressions(updatedProperties);
+ };
+
+ const getAvailableProperties = (): ExpressionProperty[] => {
+ return getNonOverlappingElementsFromTwoLists(expressionProperties, propertiesWithExpressions);
+ };
+
+ return (
+
+
+
+
+ {Object.values(propertiesWithExpressions).map((property: ExpressionProperty) => (
+
+ ))}
+ {isExpressionLimitReached ? (
+
+ {t('right_menu.expressions_expressions_limit_reached_alert')}
+
+ ) : (
+ <>
+ {propertiesWithExpressions.length === 0 && (
+
+ }}
+ />
+
+ )}
+
addNewExpression(property)}
+ />
+ >
+ )}
+
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/NewExpressionButton.module.css b/frontend/packages/ux-editor-v3/src/components/config/Expressions/NewExpressionButton.module.css
new file mode 100644
index 00000000000..b60630a5f39
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/NewExpressionButton.module.css
@@ -0,0 +1,3 @@
+.dropdownMenu {
+ background-color: var(--fds-semantic-background-default);
+}
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/NewExpressionButton.test.tsx b/frontend/packages/ux-editor-v3/src/components/config/Expressions/NewExpressionButton.test.tsx
new file mode 100644
index 00000000000..6591595b981
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/NewExpressionButton.test.tsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import { act, screen } from '@testing-library/react';
+import type { NewExpressionButtonProps } from './NewExpressionButton';
+import { NewExpressionButton } from './NewExpressionButton';
+import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext';
+import { renderWithMockStore } from '../../../testing/mocks';
+import { textMock } from '../../../../../../testing/mocks/i18nMock';
+import userEvent from '@testing-library/user-event';
+import { ExpressionPropertyBase } from '../../../types/Expressions';
+
+const user = userEvent.setup();
+
+describe('NewExpressionButton', () => {
+ afterEach(jest.clearAllMocks);
+
+ it('renders add expression button by default', () => {
+ render({});
+
+ const addButton = screen.getByText(textMock('right_menu.expressions_add'));
+
+ expect(addButton).toBeInTheDocument();
+ });
+
+ it('renders dropdown when button is clicked', async () => {
+ render({});
+
+ const addButton = screen.getByText(textMock('right_menu.expressions_add'));
+ await act(() => user.click(addButton));
+ const dropdown = screen.getByRole('heading', {
+ name: textMock('right_menu.expressions_property'),
+ });
+ expect(dropdown).toBeInTheDocument();
+ });
+
+ it('calls onAddExpression when an option is selected', async () => {
+ const onAddExpressionMock = jest.fn();
+ render({
+ props: {
+ onAddExpression: onAddExpressionMock,
+ },
+ });
+
+ const addButton = screen.getByText(textMock('right_menu.expressions_add'));
+ await act(() => user.click(addButton));
+ const dropdownOption = screen.getByRole('menuitem', {
+ name: textMock('right_menu.expressions_property_read_only'),
+ });
+ await act(() => user.click(dropdownOption));
+
+ expect(onAddExpressionMock).toHaveBeenCalledWith(optionsMock[1]);
+ expect(onAddExpressionMock).toHaveBeenCalledTimes(1);
+ });
+});
+
+const optionsMock = [ExpressionPropertyBase.Required, ExpressionPropertyBase.ReadOnly];
+
+const render = ({
+ props = {},
+ queries = {},
+}: {
+ props?: Partial;
+ queries?: Partial;
+}) => {
+ const defaultProps: NewExpressionButtonProps = {
+ options: optionsMock,
+ onAddExpression: jest.fn(),
+ };
+ return renderWithMockStore({}, queries)();
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/NewExpressionButton.tsx b/frontend/packages/ux-editor-v3/src/components/config/Expressions/NewExpressionButton.tsx
new file mode 100644
index 00000000000..39ba972a321
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/NewExpressionButton.tsx
@@ -0,0 +1,62 @@
+import React, { useRef } from 'react';
+import { DropdownMenu } from '@digdir/design-system-react';
+import { PlusIcon } from '@navikt/aksel-icons';
+import { useText } from '../../../hooks';
+import type { ExpressionProperty } from '../../../types/Expressions';
+import { expressionPropertyTexts } from '../../../types/Expressions';
+import { StudioButton } from '@studio/components';
+
+import classes from './NewExpressionButton.module.css';
+
+export interface NewExpressionButtonProps {
+ options: ExpressionProperty[];
+ onAddExpression: (property: ExpressionProperty) => void;
+}
+
+export const NewExpressionButton = ({ options, onAddExpression }: NewExpressionButtonProps) => {
+ const [showDropdown, setShowDropdown] = React.useState(false);
+ const t = useText();
+ const anchorEl = useRef(null);
+
+ return (
+ <>
+ }
+ onClick={() => setShowDropdown(!showDropdown)}
+ ref={anchorEl}
+ size='small'
+ title={t('right_menu.expressions_add')}
+ variant='secondary'
+ >
+ {t('right_menu.expressions_add')}
+
+ setShowDropdown(false)}
+ open={showDropdown}
+ placement='top'
+ portal
+ size='small'
+ >
+
+ {options.map((o) => (
+ {
+ setShowDropdown(false);
+ onAddExpression(o);
+ }}
+ >
+ {expressionPropertyTexts(t)[o]}
+
+ ))}
+
+
+ >
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/Expressions/index.ts b/frontend/packages/ux-editor-v3/src/components/config/Expressions/index.ts
new file mode 100644
index 00000000000..edb4c958408
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/Expressions/index.ts
@@ -0,0 +1 @@
+export { Expressions } from './Expressions';
diff --git a/frontend/packages/ux-editor-v3/src/components/config/FormComponentConfig.test.tsx b/frontend/packages/ux-editor-v3/src/components/config/FormComponentConfig.test.tsx
new file mode 100644
index 00000000000..d6dc6aa6174
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/FormComponentConfig.test.tsx
@@ -0,0 +1,151 @@
+import React from 'react';
+import type { FormComponentConfigProps } from './FormComponentConfig';
+import { FormComponentConfig } from './FormComponentConfig';
+import { renderWithMockStore } from '../../testing/mocks';
+import { componentMocks } from '../../testing/componentMocks';
+import InputSchema from '../../testing/schemas/json/component/Input.schema.v1.json';
+import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext';
+import { screen, waitFor } from '@testing-library/react';
+import { textMock } from '../../../../../testing/mocks/i18nMock';
+
+describe('FormComponentConfig', () => {
+ it('should render expected components', async () => {
+ render({});
+ expect(
+ screen.getByText(textMock('ux_editor.modal_properties_component_change_id')),
+ ).toBeInTheDocument();
+ ['title', 'description', 'help'].forEach(async (key) => {
+ expect(
+ screen.getByText(textMock(`ux_editor.modal_properties_textResourceBindings_${key}`)),
+ ).toBeInTheDocument();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(textMock('ux_editor.modal_properties_data_model_link')),
+ ).toBeInTheDocument();
+ });
+
+ [
+ 'grid',
+ 'readOnly',
+ 'required',
+ 'hidden',
+ 'renderAsSummary',
+ 'variant',
+ 'autocomplete',
+ 'maxLength',
+ 'triggers',
+ 'labelSettings',
+ 'pageBreak',
+ 'formatting',
+ ].forEach(async (propertyKey) => {
+ expect(
+ await screen.findByText(textMock(`ux_editor.component_properties.${propertyKey}`)),
+ ).toBeInTheDocument();
+ });
+ });
+ });
+
+ it('should render list of unsupported properties', () => {
+ render({
+ props: {
+ hideUnsupported: false,
+ schema: {
+ ...InputSchema,
+ properties: {
+ ...InputSchema.properties,
+ unsupportedProperty: {
+ type: 'array',
+ items: {
+ type: 'object',
+ },
+ },
+ },
+ },
+ },
+ });
+ expect(
+ screen.getByText(textMock('ux_editor.edit_component.unsupported_properties_message')),
+ ).toBeInTheDocument();
+ expect(screen.getByText('unsupportedProperty')).toBeInTheDocument();
+ });
+
+ it('should show children property in list of unsupported properties if it is present', () => {
+ render({
+ props: {
+ hideUnsupported: false,
+ schema: {
+ ...InputSchema,
+ properties: {
+ ...InputSchema.properties,
+ children: {
+ type: 'string',
+ },
+ },
+ },
+ },
+ });
+ expect(
+ screen.getByText(textMock('ux_editor.edit_component.unsupported_properties_message')),
+ ).toBeInTheDocument();
+ expect(screen.getByText('children')).toBeInTheDocument();
+ });
+
+ it('should not render list of unsupported properties if hideUnsupported is true', () => {
+ render({
+ props: {
+ hideUnsupported: true,
+ schema: {
+ ...InputSchema,
+ properties: {
+ ...InputSchema.properties,
+ unsupportedProperty: {
+ type: 'array',
+ items: {
+ type: 'object',
+ },
+ },
+ },
+ },
+ },
+ });
+ expect(
+ screen.queryByText(textMock('ux_editor.edit_component.unsupported_properties_message')),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByText('unsupportedProperty')).not.toBeInTheDocument();
+ });
+
+ it('should not render property if it is null', () => {
+ render({
+ props: {
+ hideUnsupported: true,
+ schema: {
+ ...InputSchema,
+ properties: {
+ ...InputSchema.properties,
+ nullProperty: null,
+ },
+ },
+ },
+ });
+ expect(screen.queryByText('nullProperty')).not.toBeInTheDocument();
+ });
+
+ const render = ({
+ props = {},
+ queries = {},
+ }: {
+ props?: Partial;
+ queries?: Partial;
+ }) => {
+ const { Input: inputComponent } = componentMocks;
+ const defaultProps: FormComponentConfigProps = {
+ schema: InputSchema,
+ editFormId: '',
+ component: inputComponent,
+ handleComponentUpdate: jest.fn(),
+ hideUnsupported: false,
+ };
+ return renderWithMockStore({}, queries)();
+ };
+});
diff --git a/frontend/packages/ux-editor-v3/src/components/config/FormComponentConfig.tsx b/frontend/packages/ux-editor-v3/src/components/config/FormComponentConfig.tsx
new file mode 100644
index 00000000000..f7570be49b2
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/FormComponentConfig.tsx
@@ -0,0 +1,269 @@
+import React from 'react';
+import { EditComponentId } from './editModal/EditComponentId';
+import { Alert, Heading, Paragraph } from '@digdir/design-system-react';
+import type { FormComponent } from '../../types/FormComponent';
+import { selectedLayoutNameSelector } from '../../selectors/formLayoutSelectors';
+import { EditDataModelBindings } from './editModal/EditDataModelBindings';
+import { EditTextResourceBindings } from './editModal/EditTextResourceBindings';
+import { EditBooleanValue } from './editModal/EditBooleanValue';
+import { EditNumberValue } from './editModal/EditNumberValue';
+import { EditOptions } from './editModal/EditOptions';
+import { EditStringValue } from './editModal/EditStringValue';
+import { useSelector } from 'react-redux';
+import { useText } from '../../hooks';
+import { getComponentPropertyLabel } from '../../utils/language';
+import { getUnsupportedPropertyTypes } from '../../utils/component';
+import { EditGrid } from './editModal/EditGrid';
+
+export interface IEditFormComponentProps {
+ editFormId: string;
+ component: FormComponent;
+ handleComponentUpdate: (component: FormComponent) => void;
+}
+
+export interface FormComponentConfigProps extends IEditFormComponentProps {
+ schema: any;
+ hideUnsupported?: boolean;
+}
+export const FormComponentConfig = ({
+ schema,
+ editFormId,
+ component,
+ handleComponentUpdate,
+ hideUnsupported,
+}: FormComponentConfigProps) => {
+ const selectedLayout = useSelector(selectedLayoutNameSelector);
+ const t = useText();
+
+ if (!schema?.properties) return null;
+
+ const {
+ textResourceBindings,
+ dataModelBindings,
+ required,
+ readOnly,
+ id,
+ type,
+ options,
+ optionsId,
+ hasCustomFileEndings,
+ validFileEndings,
+ children,
+ grid,
+ ...rest
+ } = schema.properties;
+
+ // children property is not supported in component config - it should be part of container config.
+ const unsupportedPropertyKeys: string[] = getUnsupportedPropertyTypes(
+ rest,
+ children ? ['children'] : undefined,
+ );
+ return (
+ <>
+ {id && (
+
+ )}
+ {textResourceBindings?.properties && (
+ <>
+
+ {t('general.text')}
+
+
+ >
+ )}
+ {dataModelBindings?.properties && (
+ <>
+
+ {t('top_menu.datamodel')}
+
+ {Object.keys(dataModelBindings?.properties).map((propertyKey: any) => {
+ return (
+
+ );
+ })}
+ >
+ )}
+ {grid && (
+
+
+ {t('ux_editor.component_properties.grid')}
+
+
+
+ )}
+ {!hideUnsupported && (
+
+ {'Andre innstillinger'}
+
+ )}
+ {options && optionsId && (
+
+ )}
+
+ {hasCustomFileEndings && (
+ <>
+ {
+ if (!updatedComponent.hasCustomFileEndings) {
+ handleComponentUpdate({
+ ...updatedComponent,
+ validFileEndings: undefined,
+ });
+ return;
+ }
+ handleComponentUpdate(updatedComponent);
+ }}
+ />
+ {component['hasCustomFileEndings'] && (
+
+ )}
+ >
+ )}
+
+ {readOnly && (
+
+ )}
+ {required && (
+
+ )}
+
+ {Object.keys(rest).map((propertyKey) => {
+ if (!rest[propertyKey]) return null;
+ if (
+ rest[propertyKey].type === 'boolean' ||
+ rest[propertyKey].$ref?.endsWith('layout/expression.schema.v1.json#/definitions/boolean')
+ ) {
+ return (
+
+ );
+ }
+ if (rest[propertyKey].type === 'number' || rest[propertyKey].type === 'integer') {
+ return (
+
+ );
+ }
+ if (rest[propertyKey].type === 'string') {
+ return (
+
+ );
+ }
+ if (rest[propertyKey].type === 'array' && rest[propertyKey].items?.type === 'string') {
+ return (
+
+ );
+ }
+ if (rest[propertyKey].type === 'object' && rest[propertyKey].properties) {
+ return (
+
+
+ {getComponentPropertyLabel(propertyKey, t)}
+
+ {rest[propertyKey]?.description && (
+ {rest[propertyKey].description}
+ )}
+ {
+ handleComponentUpdate({
+ ...component,
+ [propertyKey]: updatedComponent,
+ });
+ }}
+ editFormId={editFormId}
+ hideUnsupported
+ />
+
+ );
+ }
+ return null;
+ })}
+ {/* Show information about unsupported properties if there are any */}
+ {unsupportedPropertyKeys.length > 0 && !hideUnsupported && (
+
+ {t('ux_editor.edit_component.unsupported_properties_message')}
+
+ {unsupportedPropertyKeys.length > 0 &&
+ unsupportedPropertyKeys.map((propertyKey) => (
+ - {propertyKey}
+ ))}
+
+
+ )}
+ >
+ );
+};
diff --git a/frontend/packages/ux-editor-v3/src/components/config/RuleComponent.module.css b/frontend/packages/ux-editor-v3/src/components/config/RuleComponent.module.css
new file mode 100644
index 00000000000..d3b9b52b719
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/RuleComponent.module.css
@@ -0,0 +1,133 @@
+:root {
+ --primary-color: #008fd6;
+ --primary-color-700: #022f51;
+ --success-color: #17c96b;
+ --danger-color: #e23b53;
+ --text-color: #000;
+ --paper-color: #fff;
+ --disabled-color: #e9ecef;
+ --overlay-gradient: rgba(30, 174, 247, 0.3);
+}
+
+.modalHeader {
+ min-height: 80px;
+ background: var(--primary-color-700);
+ padding: 12px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ color: var(--paper-color);
+}
+
+.modalBody {
+ max-width: 800px;
+ width: 100%;
+ background: var(--paper-color);
+ margin-top: 30px;
+}
+
+.modalHeaderTitle {
+ font-size: 1.5rem;
+ font-weight: normal;
+}
+
+.configRulesIcon {
+ font-size: 2.25em;
+}
+
+.modalBodyContent {
+ padding: 30px;
+}
+
+.reactModalOverlay {
+ display: flex;
+ justify-content: center;
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ background-color: var(--overlay-gradient);
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ overflow-y: auto;
+ z-index: 1000;
+}
+
+.subTitle {
+ font-size: 18px;
+}
+
+.formGroup {
+ display: flex;
+ flex-direction: column;
+}
+
+.label {
+ display: block;
+ margin-bottom: 8px;
+ margin-top: 25px;
+}
+
+.customSelect {
+ padding: 8px;
+ margin-bottom: 8px;
+ border: 2px solid var(--primary-color);
+ width: 100%;
+}
+
+.selectActionContainer {
+ display: flex;
+ flex-direction: column;
+}
+
+.configureInputParamsContainer {
+ display: grid;
+ grid-template-columns: 1fr 10fr;
+ gap: 8px;
+}
+
+.inputType {
+ font-size: 16px;
+ padding: 8px;
+ max-width: 80px;
+ color: var(--text-color);
+ letter-spacing: 0.3px;
+ border-radius: 0;
+ transition: none;
+ background: var(--disabled-color);
+ border: 2px solid var(--primary-color);
+}
+
+.buttonsContainer {
+ display: flex;
+ gap: 12px;
+ margin-top: 8px;
+}
+
+.saveButton {
+ color: #000;
+ font-size: 1rem;
+ padding: 8px 30px;
+ background-color: var(--success-color);
+ border: 2px solid var(--success-color);
+ margin-top: 25px;
+}
+
+.dangerButton {
+ font-size: 1rem;
+ padding: 8px 30px;
+ color: var(--paper-color);
+ background-color: var(--danger-color);
+ border: 2px solid var(--danger-color);
+ margin-top: 25px;
+}
+
+.cancelButton {
+ font-size: 1rem;
+ padding: 8px 30px;
+ color: var(--text-color);
+ border: 2px solid var(--text-color);
+ margin-top: 25px;
+}
diff --git a/frontend/packages/ux-editor-v3/src/components/config/RuleComponent.tsx b/frontend/packages/ux-editor-v3/src/components/config/RuleComponent.tsx
new file mode 100644
index 00000000000..e4d540cc39d
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/RuleComponent.tsx
@@ -0,0 +1,246 @@
+import React from 'react';
+import { v1 as uuidv1 } from 'uuid';
+import { SelectDataModelComponent } from './SelectDataModelComponent';
+import type { IRuleModelFieldElement } from '../../types/global';
+import { withTranslation } from 'react-i18next';
+import classes from './RuleComponent.module.css';
+import Modal from 'react-modal';
+import type { RuleConnection, RuleConnections } from 'app-shared/types/RuleConfig';
+import type i18next from 'i18next';
+import { Buldings2Icon } from '@navikt/aksel-icons';
+
+export interface IRuleComponentProps {
+ connectionId?: string;
+ cancelEdit: () => void;
+ saveEdit: (id: string, connection: RuleConnection) => void;
+ ruleModelElements: IRuleModelFieldElement[];
+ ruleConnection: RuleConnections;
+ deleteConnection?: (connectionId: string) => void;
+ t: typeof i18next.t;
+}
+
+interface IRuleComponentState {
+ selectedFunctionNr: number | null;
+ connectionId: string | null;
+ ruleConnection: RuleConnection;
+}
+
+class Rule extends React.Component {
+ constructor(props: IRuleComponentProps) {
+ super(props);
+ this.state = {
+ selectedFunctionNr: null,
+ connectionId: null,
+ ruleConnection: {
+ selectedFunction: '',
+ inputParams: {},
+ outParams: {},
+ },
+ };
+ }
+
+ public componentDidMount() {
+ if (this.props.connectionId) {
+ for (let i = 0; this.props.ruleModelElements.length - 1; i++) {
+ // eslint-disable-next-line max-len
+ if (
+ this.props.ruleModelElements[i].name ===
+ this.props.ruleConnection[this.props.connectionId].selectedFunction
+ ) {
+ this.setState({
+ selectedFunctionNr: i,
+ });
+ break;
+ }
+ }
+ this.setState({
+ connectionId: this.props.connectionId,
+ ruleConnection: {
+ ...this.props.ruleConnection[this.props.connectionId],
+ },
+ });
+ } else {
+ this.setState({ connectionId: uuidv1() });
+ }
+ }
+
+ public handleSaveEdit = (): void => {
+ this.props.saveEdit(this.state.connectionId, this.state.ruleConnection);
+ };
+
+ public handleSelectedMethodChange = (e: any): void => {
+ const nr = e.target.selectedIndex - 1 < 0 ? null : e.target.selectedIndex - 1;
+
+ const value = e.target.value;
+ this.setState({
+ selectedFunctionNr: nr,
+ ruleConnection: {
+ ...this.state.ruleConnection,
+ selectedFunction: value,
+ },
+ });
+ };
+
+ public handleParamDataChange = (paramName: any, value: any): void => {
+ this.setState({
+ ruleConnection: {
+ ...this.state.ruleConnection,
+ inputParams: {
+ ...this.state.ruleConnection.inputParams,
+ [paramName]: value,
+ },
+ },
+ });
+ };
+
+ public handleOutParamDataChange = (paramName: any, value: any): void => {
+ this.setState({
+ ruleConnection: {
+ ...this.state.ruleConnection,
+ outParams: {
+ ...this.state.ruleConnection.outParams,
+ [paramName]: value,
+ },
+ },
+ });
+ };
+
+ public handleDeleteConnection = () => {
+ this.props.deleteConnection(this.props.connectionId);
+ };
+
+ public render(): JSX.Element {
+ const selectedMethod = this.state.ruleConnection.selectedFunction;
+ const selectedMethodNr = this.state.selectedFunctionNr;
+ return (
+ {
+ this.props.cancelEdit;
+ }}
+ className={classes.modalBody}
+ ariaHideApp={false}
+ overlayClassName={classes.reactModalOverlay}
+ >
+
+
+
+ {this.props.t('ux_editor.modal_configure_rules_header')}
+
+
+
+
+
+
+
+ {this.state.ruleConnection.selectedFunction ? (
+ <>
+
+
+ {this.props.t('ux_editor.modal_configure_rules_configure_input_header')}
+
+ {Object.keys(this.props.ruleModelElements[selectedMethodNr].inputs).map(
+ (paramName: string) => {
+ return (
+
+
+
+
+ );
+ },
+ )}
+
+
+
+ {this.props.t('ux_editor.modal_configure_rules_configure_output_header')}
+
+
+
+
+ >
+ ) : null}
+
+ {this.state.ruleConnection.selectedFunction ? (
+
+ ) : null}
+ {this.props.connectionId ? (
+
+ ) : null}
+
+
+
+
+ );
+ }
+}
+
+export const RuleComponent = withTranslation()(Rule);
diff --git a/frontend/packages/ux-editor-v3/src/components/config/SelectDataModelComponent.test.tsx b/frontend/packages/ux-editor-v3/src/components/config/SelectDataModelComponent.test.tsx
new file mode 100644
index 00000000000..75716b88bb9
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/SelectDataModelComponent.test.tsx
@@ -0,0 +1,124 @@
+import React from 'react';
+import { act, screen, waitFor } from '@testing-library/react';
+
+import { renderWithMockStore, renderHookWithMockStore } from '../../testing/mocks';
+import { appDataMock, textResourcesMock } from '../../testing/stateMocks';
+import type { IAppDataState } from '../../features/appData/appDataReducers';
+import { SelectDataModelComponent } from './SelectDataModelComponent';
+import { textMock } from '../../../../../testing/mocks/i18nMock';
+import { useDatamodelMetadataQuery } from '../../hooks/queries/useDatamodelMetadataQuery';
+import userEvent from '@testing-library/user-event';
+import type { DatamodelMetadataResponse } from 'app-shared/types/api';
+
+const getDatamodelMetadata = () =>
+ Promise.resolve({
+ elements: {
+ testModel: {
+ id: 'testModel',
+ type: 'ComplexType',
+ dataBindingName: 'testModel',
+ displayString: 'testModel',
+ isReadOnly: false,
+ isTagContent: false,
+ jsonSchemaPointer: '#/definitions/testModel',
+ maxOccurs: 1,
+ minOccurs: 1,
+ name: 'testModel',
+ parentElement: null,
+ restrictions: [],
+ texts: [],
+ xmlSchemaXPath: '/testModel',
+ xPath: '/testModel',
+ },
+ 'testModel.field1': {
+ id: 'testModel.field1',
+ type: 'SimpleType',
+ dataBindingName: 'testModel.field1',
+ displayString: 'testModel.field1',
+ isReadOnly: false,
+ isTagContent: false,
+ jsonSchemaPointer: '#/definitions/testModel/properteis/field1',
+ maxOccurs: 1,
+ minOccurs: 1,
+ name: 'testModel/field1',
+ parentElement: null,
+ restrictions: [],
+ texts: [],
+ xmlSchemaXPath: '/testModel/field1',
+ xPath: '/testModel/field1',
+ },
+ },
+ });
+
+const user = userEvent.setup();
+
+const waitForData = async () => {
+ const datamodelMetadatResult = renderHookWithMockStore(
+ {},
+ {
+ getDatamodelMetadata,
+ },
+ )(() => useDatamodelMetadataQuery('test-org', 'test-app')).renderHookResult.result;
+ await waitFor(() => expect(datamodelMetadatResult.current.isSuccess).toBe(true));
+};
+
+const render = async ({ dataModelBindings = {}, handleComponentChange = jest.fn() } = {}) => {
+ const appData: IAppDataState = {
+ ...appDataMock,
+ textResources: {
+ ...textResourcesMock,
+ },
+ };
+
+ await waitForData();
+
+ renderWithMockStore(
+ { appData },
+ { getDatamodelMetadata },
+ )(
+ ,
+ );
+};
+
+describe('EditDataModelBindings', () => {
+ it('should show select with no selected option by default', async () => {
+ await render();
+ expect(
+ await screen.findByText(textMock('ux_editor.modal_properties_data_model_helper')),
+ ).toBeInTheDocument();
+ expect(screen.getByRole('combobox').getAttribute('value')).toEqual('');
+ });
+
+ it('should show select with provided value', async () => {
+ await render({
+ dataModelBindings: {
+ simpleBinding: 'testModel.field1',
+ },
+ });
+ expect(
+ await screen.findByText(textMock('ux_editor.modal_properties_data_model_helper')),
+ ).toBeInTheDocument();
+ expect(await screen.findByText('testModel.field1')).toBeInTheDocument();
+ });
+
+ it('should call onChange when a new option is selected', async () => {
+ const handleComponentChange = jest.fn();
+ await render({
+ dataModelBindings: {
+ simpleBinding: 'testModel.field1',
+ },
+ handleComponentChange,
+ });
+ const selectElement = screen.getByRole('combobox');
+ await act(async () => {
+ await user.click(selectElement);
+ await user.click(screen.getByText('testModel.field1'));
+ });
+ await waitFor(() => {});
+ expect(handleComponentChange).toHaveBeenCalledWith('testModel.field1');
+ });
+});
diff --git a/frontend/packages/ux-editor-v3/src/components/config/SelectDataModelComponent.tsx b/frontend/packages/ux-editor-v3/src/components/config/SelectDataModelComponent.tsx
new file mode 100644
index 00000000000..04b726d7901
--- /dev/null
+++ b/frontend/packages/ux-editor-v3/src/components/config/SelectDataModelComponent.tsx
@@ -0,0 +1,73 @@
+import React, { useEffect } from 'react';
+import { LegacySelect } from '@digdir/design-system-react';
+import { useDatamodelMetadataQuery } from '../../hooks/queries/useDatamodelMetadataQuery';
+import { FormField } from '../FormField';
+import type { Option } from 'packages/text-editor/src/types';
+import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams';
+
+export interface ISelectDataModelProps {
+ inputId?: string;
+ selectedElement: string;
+ label: string;
+ onDataModelChange: (dataModelField: string) => void;
+ noOptionsMessage?: string;
+ hideRestrictions?: boolean;
+ selectGroup?: boolean;
+ componentType?: string;
+ propertyPath?: string;
+ helpText?: string;
+}
+
+export const SelectDataModelComponent = ({
+ inputId,
+ selectedElement,
+ label,
+ onDataModelChange,
+ noOptionsMessage,
+ selectGroup,
+ componentType,
+ helpText,
+ propertyPath,
+}: ISelectDataModelProps) => {
+ const { org, app } = useStudioUrlParams();
+ const { data } = useDatamodelMetadataQuery(org, app);
+ const [dataModelElementNames, setDataModelElementNames] = React.useState