diff --git a/backend/src/Designer/wwwroot/designer/img/Altinn-studio-3-blue.svg b/backend/src/Designer/wwwroot/designer/img/Altinn-studio-3-blue.svg new file mode 100644 index 00000000000..ac1bb51cf7c --- /dev/null +++ b/backend/src/Designer/wwwroot/designer/img/Altinn-studio-3-blue.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/src/Designer/wwwroot/designer/img/Altinn-studio-code-list-illustration.svg b/backend/src/Designer/wwwroot/designer/img/Altinn-studio-code-list-illustration.svg new file mode 100644 index 00000000000..cda34007dfb --- /dev/null +++ b/backend/src/Designer/wwwroot/designer/img/Altinn-studio-code-list-illustration.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/src/Designer/wwwroot/designer/img/Altinn-studio-images-illustration.svg b/backend/src/Designer/wwwroot/designer/img/Altinn-studio-images-illustration.svg new file mode 100644 index 00000000000..54120aabb8d --- /dev/null +++ b/backend/src/Designer/wwwroot/designer/img/Altinn-studio-images-illustration.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/app-development/enums/RoutePaths.ts b/frontend/app-development/enums/RoutePaths.ts index 270f6ac5fec..2026d5375bc 100644 --- a/frontend/app-development/enums/RoutePaths.ts +++ b/frontend/app-development/enums/RoutePaths.ts @@ -6,4 +6,5 @@ export enum RoutePaths { Deploy = 'deploy', Text = 'text-editor', ProcessEditor = 'process-editor', + ContentLibrary = 'content-library', } diff --git a/frontend/app-development/features/appContentLibrary/AppContentLibrary.test.tsx b/frontend/app-development/features/appContentLibrary/AppContentLibrary.test.tsx new file mode 100644 index 00000000000..b917a7fd5d9 --- /dev/null +++ b/frontend/app-development/features/appContentLibrary/AppContentLibrary.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { AppContentLibrary } from './AppContentLibrary'; +import { textMock } from '@studio/testing/mocks/i18nMock'; + +describe('AppContentLibrary', () => { + it('renders the AppContentLibrary with codeLists and images resources', () => { + renderAppContentLibrary(); + const libraryTitle = screen.getByRole('heading', { + name: textMock('app_content_library.landing_page.title'), + }); + const codeListMenuElement = screen.getByText( + textMock('app_content_library.code_lists.page_name'), + ); + const imagesMenuElement = screen.getByText(textMock('app_content_library.images.page_name')); + expect(libraryTitle).toBeInTheDocument(); + expect(codeListMenuElement).toBeInTheDocument(); + expect(imagesMenuElement).toBeInTheDocument(); + }); +}); + +const renderAppContentLibrary = () => { + render(); +}; diff --git a/frontend/app-development/features/appContentLibrary/AppContentLibrary.tsx b/frontend/app-development/features/appContentLibrary/AppContentLibrary.tsx new file mode 100644 index 00000000000..340355e70e8 --- /dev/null +++ b/frontend/app-development/features/appContentLibrary/AppContentLibrary.tsx @@ -0,0 +1,23 @@ +import { ResourceContentLibraryImpl } from '@studio/content-library'; +import React from 'react'; + +export const AppContentLibrary = (): React.ReactElement => { + const { getContentResourceLibrary } = new ResourceContentLibraryImpl({ + pages: { + codeList: { + props: { + codeLists: [], + onUpdateCodeList: () => {}, + }, + }, + images: { + props: { + images: [], + onUpdateImage: () => {}, + }, + }, + }, + }); + + return
{getContentResourceLibrary()}
; +}; diff --git a/frontend/app-development/features/appContentLibrary/index.ts b/frontend/app-development/features/appContentLibrary/index.ts new file mode 100644 index 00000000000..2ee3d1e1102 --- /dev/null +++ b/frontend/app-development/features/appContentLibrary/index.ts @@ -0,0 +1 @@ +export { AppContentLibrary } from './AppContentLibrary'; diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SelectedSchemaEditor.tsx b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SelectedSchemaEditor.tsx index 7059ac93b0b..ae817cd8fde 100644 --- a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SelectedSchemaEditor.tsx +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SelectedSchemaEditor.tsx @@ -7,7 +7,7 @@ import { SchemaEditorApp } from '@altinn/schema-editor/SchemaEditorApp'; import { useTranslation } from 'react-i18next'; import { AUTOSAVE_DEBOUNCE_INTERVAL_MILLISECONDS } from 'app-shared/constants'; import type { JsonSchema } from 'app-shared/types/JsonSchema'; -import { useOnUnmount } from 'app-shared/hooks/useOnUnmount'; +import { useOnUnmount } from '../hooks/useOnUnmount'; import type { DataModelMetadataJson, DataModelMetadataXsd, diff --git a/frontend/packages/shared/src/hooks/useOnUnmount.test.tsx b/frontend/app-development/features/dataModelling/hooks/useOnUnmount.test.tsx similarity index 100% rename from frontend/packages/shared/src/hooks/useOnUnmount.test.tsx rename to frontend/app-development/features/dataModelling/hooks/useOnUnmount.test.tsx diff --git a/frontend/packages/shared/src/hooks/useOnUnmount.ts b/frontend/app-development/features/dataModelling/hooks/useOnUnmount.ts similarity index 100% rename from frontend/packages/shared/src/hooks/useOnUnmount.ts rename to frontend/app-development/features/dataModelling/hooks/useOnUnmount.ts diff --git a/frontend/app-development/features/overview/components/News/NewsContent/news.nb.json b/frontend/app-development/features/overview/components/News/NewsContent/news.nb.json index 4465b4f8782..63ee640e5f5 100644 --- a/frontend/app-development/features/overview/components/News/NewsContent/news.nb.json +++ b/frontend/app-development/features/overview/components/News/NewsContent/news.nb.json @@ -1,6 +1,11 @@ { "$schema": "news.schema.json", "news": [ + { + "date": "2024-10-25", + "title": "Prosess har blitt voksen!", + "content": "Vi har nå fjernet Beta-merket på Prosess, fordi den er godt testet og i aktiv bruk." + }, { "date": "2024-09-04", "title": "Vi har endret noen begreper i Studio", diff --git a/frontend/app-development/features/processEditor/handlers/OnProcessTaskAddHandler.test.ts b/frontend/app-development/features/processEditor/handlers/OnProcessTaskAddHandler.test.ts index adda2765445..ef16f8bd2d3 100644 --- a/frontend/app-development/features/processEditor/handlers/OnProcessTaskAddHandler.test.ts +++ b/frontend/app-development/features/processEditor/handlers/OnProcessTaskAddHandler.test.ts @@ -118,7 +118,7 @@ describe('OnProcessTaskAddHandler', () => { expect(mutateApplicationPolicyMock).toHaveBeenCalledWith(expectedResponse); }); - it('should add datatype when signing task is added', () => { + it('should add layoutset and datatype when signing task is added', () => { const onProcessTaskAddHandler = createOnProcessTaskHandler(); const taskMetadata: OnProcessTaskEvent = { @@ -128,11 +128,19 @@ describe('OnProcessTaskAddHandler', () => { onProcessTaskAddHandler.handleOnProcessTaskAdd(taskMetadata); + expect(addLayoutSetMock).toHaveBeenCalledWith({ + layoutSetConfig: { + id: 'testElementId', + tasks: ['testElementId'], + }, + layoutSetIdToUpdate: 'testElementId', + taskType: 'signing', + }); + expect(addDataTypeToAppMetadataMock).toHaveBeenCalledWith({ dataTypeId: 'signatureInformation-1234', taskId: 'testElementId', }); - expect(addLayoutSetMock).not.toHaveBeenCalled(); expect(mutateApplicationPolicyMock).not.toHaveBeenCalled(); }); diff --git a/frontend/app-development/features/processEditor/handlers/OnProcessTaskAddHandler.ts b/frontend/app-development/features/processEditor/handlers/OnProcessTaskAddHandler.ts index 6eea89dc282..249018b75f9 100644 --- a/frontend/app-development/features/processEditor/handlers/OnProcessTaskAddHandler.ts +++ b/frontend/app-development/features/processEditor/handlers/OnProcessTaskAddHandler.ts @@ -87,11 +87,12 @@ export class OnProcessTaskAddHandler { } /** - * Adds a dataType to the added signing task + * Adds a dataType and layoutset to the added signing task * @param taskMetadata * @private */ private handleSigningTaskAdd(taskMetadata: OnProcessTaskEvent): void { + this.addLayoutSet(this.createLayoutSetConfig(taskMetadata)); const studioModeler = new StudioModeler(taskMetadata.taskEvent.element as any); const dataTypeId = studioModeler.getDataTypeIdFromBusinessObject( taskMetadata.taskType, diff --git a/frontend/app-development/package.json b/frontend/app-development/package.json index 9c477a877e7..1954ee25f24 100644 --- a/frontend/app-development/package.json +++ b/frontend/app-development/package.json @@ -9,6 +9,7 @@ "not op_mini all" ], "dependencies": { + "@studio/content-library": "workspace:^", "@studio/hooks": "workspace:^", "@studio/icons": "workspace:^", "@studio/pure-functions": "workspace:^", diff --git a/frontend/app-development/router/routes.tsx b/frontend/app-development/router/routes.tsx index f9b6d69ab3b..59cafb65330 100644 --- a/frontend/app-development/router/routes.tsx +++ b/frontend/app-development/router/routes.tsx @@ -14,6 +14,7 @@ import { usePreviewContext } from '../contexts/PreviewContext'; import { useLayoutContext } from '../contexts/LayoutContext'; import { StudioPageSpinner } from '@studio/components'; import { useTranslation } from 'react-i18next'; +import { AppContentLibrary } from 'app-development/features/appContentLibrary'; interface IRouteProps { headerTextKey?: string; @@ -87,4 +88,8 @@ export const routerRoutes: RouterRoute[] = [ path: RoutePaths.ProcessEditor, subapp: ProcessEditor, }, + { + path: RoutePaths.ContentLibrary, + subapp: AppContentLibrary, + }, ]; diff --git a/frontend/app-development/utils/headerMenu/headerMenuUtils.ts b/frontend/app-development/utils/headerMenu/headerMenuUtils.ts index 8a9afe86f8a..840f654f44b 100644 --- a/frontend/app-development/utils/headerMenu/headerMenuUtils.ts +++ b/frontend/app-development/utils/headerMenu/headerMenuUtils.ts @@ -41,7 +41,6 @@ export const topBarMenuItem: HeaderMenuItem[] = [ link: RoutePaths.ProcessEditor, icon: TenancyIcon, repositoryTypes: [RepositoryType.App], - isBeta: true, group: HeaderMenuGroupKey.Tools, }, { diff --git a/frontend/app-development/utils/metadataUtils.ts b/frontend/app-development/utils/metadataUtils.ts index b815f1fcee1..6e31c6ab731 100644 --- a/frontend/app-development/utils/metadataUtils.ts +++ b/frontend/app-development/utils/metadataUtils.ts @@ -3,8 +3,7 @@ import type { DataModelMetadataJson, DataModelMetadataXsd, } from 'app-shared/types/DataModelMetadata'; -import { replaceEnd } from 'app-shared/utils/stringUtils'; -import { ArrayUtils } from '@studio/pure-functions'; +import { ArrayUtils, StringUtils } from '@studio/pure-functions'; import type { MetadataOption } from '../types/MetadataOption'; import type { MetadataOptionsGroup } from '../types/MetadataOptionsGroup'; import { removeSchemaExtension } from 'app-shared/utils/filenameUtils'; @@ -23,7 +22,7 @@ export const filterOutXsdDataIfJsonDataExist = ( ({ fileName }) => !jsonData.find( ({ fileName: jsonFileName }) => - jsonFileName === replaceEnd(fileName, '.xsd', '.schema.json'), + jsonFileName === StringUtils.replaceEnd(fileName, '.xsd', '.schema.json'), ), ); diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index 60b093b0dd2..c630ccaa48d 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -11,6 +11,18 @@ "api_errors.ResourceNotFound": "Fant ikke en fil som appen din prøvde å få tak i.", "api_errors.Unauthorized": "Handlingen du prøver å utføre krever rettigheter du ikke har. Du blir nå logget ut.", "api_errors.UploadedImageNotValid": "Det opplastede bildet er ikke en gyldig filtype", + "app_content_library.code_lists.info_box.description": "En kodeliste er en liste med strukturerte data. Den inneholder definerte alternativer som alle har en unik kode. For eksempel kan du ha en kodeliste med kommunenavn i skjemaet ditt, som brukerne kan velge fra en nedtrekksmeny. Brukerne ser bare navnet, ikke koden.", + "app_content_library.code_lists.info_box.title": "Hva er en kodeliste?", + "app_content_library.code_lists.no_content": "Dette biblioteket har ingen kodelister", + "app_content_library.code_lists.page_name": "Kodelister", + "app_content_library.images.info_box.description": "Bilder kan brukes for å vise frem bilder i skjemaet, Du kan også laste inn bilde til organisasjonens logo, og legge inn det som logobilde inne på innstilligner for appen.", + "app_content_library.images.info_box.title": "Hva kan du bruke bildene til?", + "app_content_library.images.no_content": "Dette biblioteket har ingen bilder", + "app_content_library.images.page_name": "Bilder", + "app_content_library.info_box.title": "En kort beskrivelse om bruk av og hensikt med ressursen i bibliotket.", + "app_content_library.landing_page.description": "Når du utvikler skjemaer, er det nyttig å samle ulike filer og ressurser på ett sted. I biblioteket kan du laste opp ting andre har laget som du har bruk for, eller selv lage det du trenger til de tjenestene du utvikler.", + "app_content_library.landing_page.page_name": "Bibliotek", + "app_content_library.landing_page.title": "Med biblioteket kan du effektivt utvikle mer konsekvente tjenester", "app_create_release.build_version": "Bygg versjon", "app_create_release.check_status": "Sjekker status på appen din...", "app_create_release.loading": "Laster...", @@ -882,7 +894,6 @@ "schema_editor.integer": "Heltall", "schema_editor.invalid_child_error": "Du kan ikke plassere den komponenttypen i denne gruppen.", "schema_editor.invalid_datamodel_name": "Navnet er ugyldig. Du kan bruke tall og store og små bokstaver fra det norske alfabetet, og understrek, punktum og bindestrek.", - "schema_editor.invalid_datamodel_upload_filename": "Filnavnet er ugyldig. Du kan bruke tall og store og små bokstaver fra det norske alfabetet, og understrek, punktum og bindestrek.", "schema_editor.language": "Språk", "schema_editor.language_add_language": "Legg til språk:", "schema_editor.language_confirm_deletion": "Ja, slett språket", @@ -1061,6 +1072,17 @@ "ux_editor.collapsable_text_advanced_components": "Avansert", "ux_editor.collapsable_text_components": "Tekst", "ux_editor.collapsable_text_widgets": "Widgets", + "ux_editor.component_add_item.info_component_selected": "Du har valgt komponenten {{componentName}}", + "ux_editor.component_add_item.info_heading": "Legg til komponent", + "ux_editor.component_add_item.info_no_component_selected": "Velg en komponent for å se informasjon og legge til", + "ux_editor.component_category.advanced": "Avansert", + "ux_editor.component_category.attachment": "Vedlegg", + "ux_editor.component_category.button": "Knapper", + "ux_editor.component_category.container": "Gruppering", + "ux_editor.component_category.form": "Skjema", + "ux_editor.component_category.info": "Informasjon", + "ux_editor.component_category.select": "Flervalg", + "ux_editor.component_category.text": "Tekst", "ux_editor.component_deletion_confirm": "Ja, slett komponenten", "ux_editor.component_deletion_text": "Er du sikker på at du vil slette denne komponenten?", "ux_editor.component_dropdown_set_preselected": "Sett forhåndsvalgt verdi for nedtrekksliste", @@ -1297,6 +1319,9 @@ "ux_editor.component_properties.subform.choose_layout_set_description": " Før du kan bruke komponenten Tabell for underskjema, må du velge hvilket underskjema du skal bruke den med. Deretter kan du velge hvilke egenskaper komponenten skal ha.", "ux_editor.component_properties.subform.choose_layout_set_header": "Velg underskjemaet du vil bruke", "ux_editor.component_properties.subform.choose_layout_set_label": "Velg et underskjema", + "ux_editor.component_properties.subform.create_layout_set_button": "Lag et nytt underskjema", + "ux_editor.component_properties.subform.create_layout_set_description": "Hvis du velger å lage et nytt underskjema, oppretter vi et tomt underskjema for deg. Det må du selv utforme, før du kan sette opp tabellen.", + "ux_editor.component_properties.subform.created_layout_set_name": "Navn på underskjema", "ux_editor.component_properties.subform.go_to_layout_set": "Gå til utforming av underskjemaet", "ux_editor.component_properties.subform.no_layout_sets_acting_as_subform": "Det finnes ingen sidegrupper i løsningen som kan brukes som et underskjema", "ux_editor.component_properties.subform.selected_layout_set_label": "Underskjema", @@ -1383,7 +1408,7 @@ "ux_editor.component_title.PrintButton": "Utskrift", "ux_editor.component_title.RadioButtons": "Radioknapper", "ux_editor.component_title.RepeatingGroup": "Repeterende gruppe", - "ux_editor.component_title.SubForm": "Underskjema", + "ux_editor.component_title.Subform": "Underskjema", "ux_editor.component_title.Summary": "Oppsummering", "ux_editor.component_title.Summary2": "Oppsummering2", "ux_editor.component_title.TextArea": "Stort tekstfelt", diff --git a/frontend/libs/studio-components/src/components/StudioRecommendedNextAction/StudioRecommendedNextAction.module.css b/frontend/libs/studio-components/src/components/StudioRecommendedNextAction/StudioRecommendedNextAction.module.css index d2c491f06f4..71b154d5cb5 100644 --- a/frontend/libs/studio-components/src/components/StudioRecommendedNextAction/StudioRecommendedNextAction.module.css +++ b/frontend/libs/studio-components/src/components/StudioRecommendedNextAction/StudioRecommendedNextAction.module.css @@ -7,5 +7,5 @@ .description { margin-top: var(--fds-spacing-2); - margin-bottom: var(--fds-spacing-4); + margin-bottom: var(--fds-spacing-1); } diff --git a/frontend/libs/studio-components/src/components/StudioRecommendedNextAction/StudioRecommendedNextAction.tsx b/frontend/libs/studio-components/src/components/StudioRecommendedNextAction/StudioRecommendedNextAction.tsx index 12313cb3cfa..5bf6ed75fde 100644 --- a/frontend/libs/studio-components/src/components/StudioRecommendedNextAction/StudioRecommendedNextAction.tsx +++ b/frontend/libs/studio-components/src/components/StudioRecommendedNextAction/StudioRecommendedNextAction.tsx @@ -10,8 +10,8 @@ export type StudioRecommendedNextActionProps = { saveButtonText?: string; onSkip?: React.MouseEventHandler; skipButtonText?: string; - title: string; - description: string; + title?: string; + description?: string; hideSaveButton?: boolean; hideSkipButton?: boolean; children: React.ReactNode; diff --git a/frontend/libs/studio-content-library/README.md b/frontend/libs/studio-content-library/README.md index 8d68945876f..677f73917de 100644 --- a/frontend/libs/studio-content-library/README.md +++ b/frontend/libs/studio-content-library/README.md @@ -26,7 +26,7 @@ const MyInternalContentLibrary = (): React.ReactElement => { pages: { root: { props: { - title: 'Welcome to App Libary for resources', + title: 'Welcome to App Library for resources', children:
My custom component
, }, }, @@ -38,6 +38,6 @@ const MyInternalContentLibrary = (): React.ReactElement => { }, }); - return
{getContentResourceLibrary}
; + return
{getContentResourceLibrary()}
; }; ``` diff --git a/frontend/libs/studio-content-library/mocks/mockPagesConfig.ts b/frontend/libs/studio-content-library/mocks/mockPagesConfig.ts new file mode 100644 index 00000000000..511065c3edf --- /dev/null +++ b/frontend/libs/studio-content-library/mocks/mockPagesConfig.ts @@ -0,0 +1,19 @@ +import type { PagesConfig } from '../src/types/PagesProps'; + +export const mockPagesConfig: PagesConfig = { + codeList: { + props: { + codeLists: [ + { title: 'CodeList1', codeList: {} }, + { title: 'CodeList2', codeList: {} }, + ], + onUpdateCodeList: () => {}, + }, + }, + images: { + props: { + images: [{ title: 'image', imageSrc: 'www.external-image-url.com' }], + onUpdateImage: () => {}, + }, + }, +}; diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/ContentLibrary.module.css b/frontend/libs/studio-content-library/src/ContentLibrary/ContentLibrary.module.css new file mode 100644 index 00000000000..dd78555d772 --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/ContentLibrary.module.css @@ -0,0 +1,27 @@ +.libraryBackground { + display: flex; + background-color: var(--fds-semantic-surface-info-subtle); + --padding-library: var(--fds-spacing-5); + padding: var(--padding-library); + height: calc(100% - 2 * var(--padding-library)); +} + +.libraryContainer { + background-color: var(--fds-semantic-background-default); + border-radius: var(--fds-border_radius-xlarge); + border-bottom: solid 1px var(--fds-semantic-border-neutral-subtle); + overflow: hidden; + min-height: 80%; + max-height: 100%; + width: 100%; +} + +.component { + padding: var(--fds-spacing-10); +} + +.libraryContent { + display: grid; + grid-template-columns: 0.7fr 3.3fr 1fr; + height: 100%; +} diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/ContentLibrary.test.tsx b/frontend/libs/studio-content-library/src/ContentLibrary/ContentLibrary.test.tsx new file mode 100644 index 00000000000..76ed63ecd6c --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/ContentLibrary.test.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { ContentLibrary } from './ContentLibrary'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { mockPagesConfig } from '../../mocks/mockPagesConfig'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { RouterContext } from '../contexts/RouterContext'; +import type { PageName } from '../types/PageName'; + +const navigateMock = jest.fn(); + +describe('ContentLibrary', () => { + it('renders the ContentLibrary with landingPage by default', () => { + renderContentLibrary(); + const libraryHeader = screen.getByRole('heading', { + name: textMock('app_content_library.landing_page.page_name'), + }); + const landingPageTitle = screen.getByRole('heading', { + name: textMock('app_content_library.landing_page.title'), + }); + const infoBox = screen.queryByTitle(textMock('app_content_library.info_box.title')); + expect(libraryHeader).toBeInTheDocument(); + expect(landingPageTitle).toBeInTheDocument(); + expect(infoBox).not.toBeInTheDocument(); + }); + + it('renders the ContentLibrary with codeList content when acting as currentPage', () => { + renderContentLibrary('codeList'); + const codeListTitle = screen.getByRole('heading', { + name: textMock('app_content_library.code_lists.page_name'), + }); + const infoBox = screen.getByTitle(textMock('app_content_library.info_box.title')); + expect(codeListTitle).toBeInTheDocument(); + expect(infoBox).toBeInTheDocument(); + }); + + it('navigates to images content when clicking on images navigation', async () => { + const user = userEvent.setup(); + renderContentLibrary(); + const imagesPageNavigation = screen.getByText(textMock('app_content_library.images.page_name')); + await user.click(imagesPageNavigation); + expect(navigateMock).toHaveBeenCalledTimes(1); + expect(navigateMock).toHaveBeenCalledWith('images'); + }); + + it('renders 404 not found page when pageName without supported implementation is passed', () => { + renderContentLibrary('PageNameWithoutImpl' as PageName); + const notFoundPageTitle = screen.getByRole('heading', { name: '404 Page Not Found' }); + expect(notFoundPageTitle).toBeInTheDocument(); + }); +}); + +const renderContentLibrary = (currentPage: PageName = undefined) => { + render( + + + , + ); +}; diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/ContentLibrary.tsx b/frontend/libs/studio-content-library/src/ContentLibrary/ContentLibrary.tsx new file mode 100644 index 00000000000..04933f99c47 --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/ContentLibrary.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { useRouterContext } from '../contexts/RouterContext'; +import type { PageComponent } from '../utils/router/RouterRouteMapper'; +import { RouterRouteMapperImpl } from '../utils/router/RouterRouteMapper'; +import type { PagePropsMap, PagesConfig } from '../types/PagesProps'; +import classes from './ContentLibrary.module.css'; +import { InfoBox } from './InfoBox'; +import { PagesRouter } from './PagesRouter'; +import { LibraryHeader } from './LibraryHeader'; +import { StudioHeading } from '@studio/components'; +import type { PageName } from '../types/PageName'; + +type ContentLibraryProps = { + pages: PagesConfig; +}; + +export function ContentLibrary({ pages }: ContentLibraryProps): React.ReactElement { + const { currentPage = 'landingPage' } = useRouterContext(); + return ; +} + +type ContentLibraryForPageProps = { + pages: PagesConfig; + currentPage: T; +}; + +function ContentLibraryForPage({ + pages, + currentPage, +}: ContentLibraryForPageProps): React.ReactElement { + const router = new RouterRouteMapperImpl(pages); + + const Component: PageComponent[T]> = + router.configuredRoutes.get(currentPage); + if (!Component) return 404 Page Not Found; // Show the NotFound page from app-dev instead + + const componentPropsAreExternal = currentPage !== 'landingPage'; + + const componentProps: Required[T] = + componentPropsAreExternal && (pages[currentPage].props as Required[T]); + + return ( +
+
+ +
+ +
+ +
+ +
+
+
+ ); +} diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/InfoBox/InfoBox.module.css b/frontend/libs/studio-content-library/src/ContentLibrary/InfoBox/InfoBox.module.css new file mode 100644 index 00000000000..2703923cc80 --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/InfoBox/InfoBox.module.css @@ -0,0 +1,23 @@ +.infoBoxContainer { + border-radius: var(--fds-border_radius-large); + border: 1px solid var(--fds-semantic-border-neutral-subtle); + width: 15vw; + overflow: hidden; + height: fit-content; + margin: var(--fds-spacing-10); +} + +.infoBoxContainer img { + max-width: 100%; + height: auto; + object-fit: fill; +} + +.description { + display: flex; + flex-direction: column; + gap: var(--fds-spacing-6); + padding: var(--fds-spacing-6); + background-color: var(--fds-semantic-surface-first-light); + padding-bottom: var(--fds-spacing-8); +} diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/InfoBox/InfoBox.test.tsx b/frontend/libs/studio-content-library/src/ContentLibrary/InfoBox/InfoBox.test.tsx new file mode 100644 index 00000000000..a597e5ea34a --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/InfoBox/InfoBox.test.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { InfoBox } from './InfoBox'; +import { render, screen } from '@testing-library/react'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import type { PageName } from '../../types/PageName'; +import { infoBoxConfigs } from './infoBoxConfigs'; + +const pageNameMock: PageName = 'codeList'; + +describe('InfoBox', () => { + it('renders the infobox illustration, title and description', () => { + renderInfoBox(); + const infoBoxIllustration = screen.getByRole('img', { + name: textMock(infoBoxConfigs[pageNameMock].titleTextKey), + }); + const infoBoxTitle = screen.getByText(textMock(infoBoxConfigs[pageNameMock].titleTextKey)); + const infoBoxDescription = screen.getByText( + textMock(infoBoxConfigs[pageNameMock].descriptionTextKey), + ); + expect(infoBoxIllustration).toBeInTheDocument(); + expect(infoBoxTitle).toBeInTheDocument(); + expect(infoBoxDescription).toBeInTheDocument(); + }); + + it('renders nothing if receiving a pageName that has no member in infoBoxConfigs', () => { + renderInfoBox('landingPage'); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + }); +}); + +const renderInfoBox = (pageName: PageName = pageNameMock) => { + render(); +}; diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/InfoBox/InfoBox.tsx b/frontend/libs/studio-content-library/src/ContentLibrary/InfoBox/InfoBox.tsx new file mode 100644 index 00000000000..b5099ac13be --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/InfoBox/InfoBox.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import classes from './InfoBox.module.css'; +import { StudioParagraph } from '@studio/components'; +import { useTranslation } from 'react-i18next'; +import type { PageName } from '../../types/PageName'; +import { infoBoxConfigs } from './infoBoxConfigs'; + +type InfoBoxProps = { + pageName: PageName; +}; + +export function InfoBox({ pageName }: InfoBoxProps): React.ReactElement { + const { t } = useTranslation(); + + const infoBoxConfigForPage = infoBoxConfigs[pageName]; + + if (!infoBoxConfigForPage) return null; + + return ( +
+ {t(infoBoxConfigForPage.titleTextKey)} +
+ {t(infoBoxConfigForPage.titleTextKey)} + + {t(infoBoxConfigForPage.descriptionTextKey)} + +
+
+ ); +} diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/InfoBox/index.ts b/frontend/libs/studio-content-library/src/ContentLibrary/InfoBox/index.ts new file mode 100644 index 00000000000..88dab9b2416 --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/InfoBox/index.ts @@ -0,0 +1 @@ +export { InfoBox } from './InfoBox'; diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/InfoBox/infoBoxConfigs.ts b/frontend/libs/studio-content-library/src/ContentLibrary/InfoBox/infoBoxConfigs.ts new file mode 100644 index 00000000000..1e345fb75a1 --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/InfoBox/infoBoxConfigs.ts @@ -0,0 +1,17 @@ +import type { PageName } from '../../types/PageName'; +import type { InfoBoxProps } from '../../types/InfoBoxProps'; + +export type InfoBoxConfigs = Partial<{ [T in PageName]: InfoBoxProps }>; + +export const infoBoxConfigs: InfoBoxConfigs = { + codeList: { + titleTextKey: 'app_content_library.code_lists.info_box.title', + descriptionTextKey: 'app_content_library.code_lists.info_box.description', + illustrationReference: '/designer/img/Altinn-studio-code-list-illustration.svg', + }, + images: { + titleTextKey: 'app_content_library.images.info_box.title', + descriptionTextKey: 'app_content_library.images.info_box.description', + illustrationReference: '/designer/img/Altinn-studio-images-illustration.svg', + }, +}; diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/LibraryHeader/LibraryHeader.module.css b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryHeader/LibraryHeader.module.css new file mode 100644 index 00000000000..7b0751c64e6 --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryHeader/LibraryHeader.module.css @@ -0,0 +1,12 @@ +.libraryHeading { + padding: var(--fds-spacing-4); + border-bottom: solid 1px var(--fds-semantic-border-neutral-subtle); +} + +.libraryLandingPageNavigation { + display: flex; + align-items: center; + gap: var(--fds-spacing-1); + cursor: pointer; + width: fit-content; +} diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/LibraryHeader/LibraryHeader.test.tsx b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryHeader/LibraryHeader.test.tsx new file mode 100644 index 00000000000..85dbbfa2553 --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryHeader/LibraryHeader.test.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { LibraryHeader } from './LibraryHeader'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { RouterContext } from '../../contexts/RouterContext'; + +const navigateMock = jest.fn(); + +describe('LibraryHeader', () => { + it('renders the landingPage header', () => { + renderLibraryHeader(); + const landingPageIcon = screen.getByRole('img'); + const landingPageHeader = screen.getByRole('heading', { + name: textMock('app_content_library.landing_page.page_name'), + }); + expect(landingPageIcon).toBeInTheDocument(); + expect(landingPageHeader).toBeInTheDocument(); + }); + + it('calls navigate from useRouterContext when clicking on the header', async () => { + const user = userEvent.setup(); + renderLibraryHeader(); + const landingPageHeader = screen.getByRole('heading', { + name: textMock('app_content_library.landing_page.page_name'), + }); + await user.click(landingPageHeader); + expect(navigateMock).toHaveBeenCalledTimes(1); + expect(navigateMock).toHaveBeenCalledWith('landingPage'); + }); +}); + +const renderLibraryHeader = () => { + render( + + + , + ); +}; diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/LibraryHeader/LibraryHeader.tsx b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryHeader/LibraryHeader.tsx new file mode 100644 index 00000000000..e59df0f2d9e --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryHeader/LibraryHeader.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import classes from './LibraryHeader.module.css'; +import { pagesRouterConfigs } from '../PagesRouter'; +import type { PageName } from '../../types/PageName'; +import { useRouterContext } from '../../contexts/RouterContext'; +import { StudioHeading } from '@studio/components'; +import { useTranslation } from 'react-i18next'; + +export function LibraryHeader(): React.ReactElement { + const { navigate } = useRouterContext(); + const { t } = useTranslation(); + + const handleNavigation = (pageToNavigateTo: PageName) => { + navigate(pageToNavigateTo); + }; + + return ( +
+
handleNavigation('landingPage')} + > + {pagesRouterConfigs['landingPage'].icon} + + {t(pagesRouterConfigs['landingPage'].pageTitleTextKey)} + +
+
+ ); +} diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/LibraryHeader/index.ts b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryHeader/index.ts new file mode 100644 index 00000000000..e4ea6b0a063 --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryHeader/index.ts @@ -0,0 +1 @@ +export { LibraryHeader } from './LibraryHeader'; diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/PagesRouter/PagesRouter.module.css b/frontend/libs/studio-content-library/src/ContentLibrary/PagesRouter/PagesRouter.module.css new file mode 100644 index 00000000000..6511b0c60f9 --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/PagesRouter/PagesRouter.module.css @@ -0,0 +1,24 @@ +.pagesRouterContainer { + background-color: var(--fds-semantic-surface-action-second-subtle); +} + +.pageIsSelected, +.pageNavigation { + display: flex; + align-items: center; + gap: var(--fds-spacing-2); + border: solid 1px var(--fds-semantic-border-action-second-subtle); + padding: var(--fds-spacing-3); + cursor: pointer; +} + +.pageIsSelected { + border-left: 3px solid var(--semantic-surface-action-default, #0062ba); + background-color: white; +} + +.pageNavigation:hover, +.pageIsSelected:hover { + border-left: 3px solid var(--semantic-surface-action-default, #0062ba); + background-color: white; +} diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/PagesRouter/PagesRouter.test.tsx b/frontend/libs/studio-content-library/src/ContentLibrary/PagesRouter/PagesRouter.test.tsx new file mode 100644 index 00000000000..711ac30d150 --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/PagesRouter/PagesRouter.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 { PagesRouter } from './PagesRouter'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { RouterContext } from '../../contexts/RouterContext'; +import type { PageName } from '../../types/PageName'; + +const navigateMock = jest.fn(); + +describe('PagesRouter', () => { + it('renders the pages as navigation titles', () => { + renderPagesRouter(); + const codeListNavTitle = screen.getByText(textMock('app_content_library.code_lists.page_name')); + const imagesNavTitle = screen.getByText(textMock('app_content_library.images.page_name')); + expect(codeListNavTitle).toBeInTheDocument(); + expect(imagesNavTitle).toBeInTheDocument(); + }); + + it('calls navigate from RouterContext when clicking on a page that is not selected', async () => { + const user = userEvent.setup(); + renderPagesRouter(); + const imagesNavTitle = screen.getByText(textMock('app_content_library.images.page_name')); + await user.click(imagesNavTitle); + expect(navigateMock).toHaveBeenCalledTimes(1); + expect(navigateMock).toHaveBeenCalledWith('images'); + }); + + it('returns null if trying to render an unknown pageName', () => { + const pageName: string = 'unknownPageName'; + renderPagesRouter([pageName as PageName]); + const navTitle = screen.queryByText(pageName); + expect(navTitle).not.toBeInTheDocument(); + }); +}); + +const renderPagesRouter = (pageNames: PageName[] = ['codeList', 'images']) => { + render( + + + , + ); +}; diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/PagesRouter/PagesRouter.tsx b/frontend/libs/studio-content-library/src/ContentLibrary/PagesRouter/PagesRouter.tsx new file mode 100644 index 00000000000..c61185247b9 --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/PagesRouter/PagesRouter.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import classes from './PagesRouter.module.css'; +import { useRouterContext } from '../../contexts/RouterContext'; +import type { PageName } from '../../types/PageName'; +import { pagesRouterConfigs } from './pagesRouterConfigs'; +import { useTranslation } from 'react-i18next'; +import { StudioParagraph } from '@studio/components'; + +type PagesRouterProps = { + pageNames: PageName[]; +}; + +export function PagesRouter({ pageNames }: PagesRouterProps): React.ReactElement { + const { navigate, currentPage } = useRouterContext(); + + const handleNavigation = (pageToNavigateTo: PageName) => { + navigate(pageToNavigateTo); + }; + + return ( +
+ {pageNames.map((pageName) => ( + + ))} +
+ ); +} + +type PageNavigationTileProps = { + currentPage: PageName; + pageName: PageName; + onClick: (newPage: PageName) => void; +}; + +function PageNavigationTile({ + currentPage, + pageName, + onClick, +}: PageNavigationTileProps): React.ReactElement { + const { t } = useTranslation(); + + if (!pagesRouterConfigs[pageName]) return null; + + return ( +
onClick(pageName)} + > + {pagesRouterConfigs[pageName].icon} + {t(pagesRouterConfigs[pageName].pageTitleTextKey)} +
+ ); +} diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/PagesRouter/index.ts b/frontend/libs/studio-content-library/src/ContentLibrary/PagesRouter/index.ts new file mode 100644 index 00000000000..49a7c6f8f2a --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/PagesRouter/index.ts @@ -0,0 +1,2 @@ +export { PagesRouter } from './PagesRouter'; +export { pagesRouterConfigs } from './pagesRouterConfigs'; diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/PagesRouter/pagesRouterConfigs.tsx b/frontend/libs/studio-content-library/src/ContentLibrary/PagesRouter/pagesRouterConfigs.tsx new file mode 100644 index 00000000000..0e323134291 --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/PagesRouter/pagesRouterConfigs.tsx @@ -0,0 +1,29 @@ +import React, { type ReactNode } from 'react'; +import type { PageName } from '../../types/PageName'; +import { BookIcon, CodeListsIcon, ImageIcon } from '@studio/icons'; + +type RouterPageProps = { + icon: ReactNode; + pageName: PageName; + pageTitleTextKey: string; +}; + +type PagesRouterConfigs = { [T in PageName]: RouterPageProps }; + +export const pagesRouterConfigs: PagesRouterConfigs = { + landingPage: { + pageName: 'landingPage', + pageTitleTextKey: 'app_content_library.landing_page.page_name', + icon: , + }, + codeList: { + icon: , + pageName: 'codeList', + pageTitleTextKey: 'app_content_library.code_lists.page_name', + }, + images: { + icon: , + pageName: 'images', + pageTitleTextKey: 'app_content_library.images.page_name', + }, +}; diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/pages/CodeList/CodeList.test.tsx b/frontend/libs/studio-content-library/src/ContentLibrary/pages/CodeList/CodeList.test.tsx new file mode 100644 index 00000000000..533ac28bdbf --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/pages/CodeList/CodeList.test.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import type { CodeListProps } from './CodeList'; +import { CodeList } from './CodeList'; +import userEvent from '@testing-library/user-event'; +import { textMock } from '@studio/testing/mocks/i18nMock'; + +const onUpdateCodeListMock = jest.fn(); +const codeListMock: CodeList = { + title: 'codeList', + codeList: {}, +}; + +describe('CodeList', () => { + it('renders the codeList heading', () => { + renderCodeList(); + const codeListHeading = screen.getByRole('heading', { + name: textMock('app_content_library.code_lists.page_name'), + }); + expect(codeListHeading).toBeInTheDocument(); + }); + + it('renders an alert when no codeLists are passed', () => { + renderCodeList({ codeLists: [], onUpdateCodeList: onUpdateCodeListMock }); + const noCodeListsExistAlert = screen.getByText( + textMock('app_content_library.code_lists.no_content'), + ); + expect(noCodeListsExistAlert).toBeInTheDocument(); + }); + + it('calls onUpdateCodeListMock when clicking the button to update', async () => { + const user = userEvent.setup(); + renderCodeList(); + const updateCodeListButton = screen.getByRole('button', { name: 'Oppdater kodeliste' }); + await user.click(updateCodeListButton); + expect(onUpdateCodeListMock).toHaveBeenCalledTimes(1); + expect(onUpdateCodeListMock).toHaveBeenCalledWith(codeListMock); + }); +}); + +const defaultCodeListProps: CodeListProps = { + codeLists: [codeListMock], + onUpdateCodeList: onUpdateCodeListMock, +}; + +const renderCodeList = (codeListProps: CodeListProps = defaultCodeListProps) => { + render(); +}; diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/pages/CodeList/CodeList.tsx b/frontend/libs/studio-content-library/src/ContentLibrary/pages/CodeList/CodeList.tsx new file mode 100644 index 00000000000..88c6a951f58 --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/pages/CodeList/CodeList.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Alert } from '@digdir/designsystemet-react'; +import { StudioHeading } from '@studio/components'; +import { useTranslation } from 'react-i18next'; + +export type CodeList = { + title: string; + codeList: any; +}; + +export type CodeListProps = { + codeLists: CodeList[]; + onUpdateCodeList: (updatedCodeList: CodeList) => void; +}; +export function CodeList({ codeLists, onUpdateCodeList }: CodeListProps): React.ReactElement { + const { t } = useTranslation(); + + const noExistingCodeLists = codeLists.length === 0; + + return ( +
+ + {t('app_content_library.code_lists.page_name')} + + {noExistingCodeLists ? ( + {t('app_content_library.code_lists.no_content')} + ) : ( + codeLists.map((codeList) => ( +
+ {codeList.title} + +
+ )) + )} +
+ ); +} diff --git a/frontend/libs/studio-content-library/src/pages/CodeList/index.ts b/frontend/libs/studio-content-library/src/ContentLibrary/pages/CodeList/index.ts similarity index 100% rename from frontend/libs/studio-content-library/src/pages/CodeList/index.ts rename to frontend/libs/studio-content-library/src/ContentLibrary/pages/CodeList/index.ts diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/pages/Images/Images.test.tsx b/frontend/libs/studio-content-library/src/ContentLibrary/pages/Images/Images.test.tsx new file mode 100644 index 00000000000..6ca11ad21e5 --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/pages/Images/Images.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import type { Image, ImagesProps } from './Images'; +import { Images } from './Images'; + +const onUpdateImageMock = jest.fn(); +const imageMock: Image = { + title: 'image', + imageSrc: 'www.external-image-url.com', +}; + +describe('Images', () => { + it('renders the images heading', () => { + renderImages(); + const imagesHeading = screen.getByRole('heading', { + name: textMock('app_content_library.images.page_name'), + }); + expect(imagesHeading).toBeInTheDocument(); + }); + + it('renders an alert when no images are passed', () => { + renderImages({ images: [], onUpdateImage: onUpdateImageMock }); + const noImagesExistAlert = screen.getByText(textMock('app_content_library.images.no_content')); + expect(noImagesExistAlert).toBeInTheDocument(); + }); + + it('calls onUpdateImagesMock when clicking the button to update', async () => { + const user = userEvent.setup(); + renderImages(); + const updateImageButton = screen.getByRole('button', { name: 'Oppdater bilde' }); + await user.click(updateImageButton); + expect(onUpdateImageMock).toHaveBeenCalledTimes(1); + expect(onUpdateImageMock).toHaveBeenCalledWith(imageMock); + }); +}); + +const defaultImagesProps: ImagesProps = { + images: [imageMock], + onUpdateImage: onUpdateImageMock, +}; + +const renderImages = (imagesProps: ImagesProps = defaultImagesProps) => { + render(); +}; diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/pages/Images/Images.tsx b/frontend/libs/studio-content-library/src/ContentLibrary/pages/Images/Images.tsx new file mode 100644 index 00000000000..fa812bd9e69 --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/pages/Images/Images.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Alert } from '@digdir/designsystemet-react'; +import { StudioHeading } from '@studio/components'; +import { useTranslation } from 'react-i18next'; + +export type Image = { + title: string; + imageSrc: string; +}; + +export type ImagesProps = { + images: Image[]; + onUpdateImage: (updatedImage: Image) => void; +}; + +export function Images({ images, onUpdateImage }: ImagesProps): React.ReactElement { + const { t } = useTranslation(); + + const noExistingImages = images.length === 0; + + return ( +
+ + {t('app_content_library.images.page_name')} + + {noExistingImages ? ( + {t('app_content_library.images.no_content')} + ) : ( + images.map((image) => ( +
+ {image.title} + {image.title} + +
+ )) + )} +
+ ); +} diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/pages/Images/index.ts b/frontend/libs/studio-content-library/src/ContentLibrary/pages/Images/index.ts new file mode 100644 index 00000000000..e9607291625 --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/pages/Images/index.ts @@ -0,0 +1 @@ +export * from './Images'; diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/pages/LandingPage/LandingPage.module.css b/frontend/libs/studio-content-library/src/ContentLibrary/pages/LandingPage/LandingPage.module.css new file mode 100644 index 00000000000..e3ec4de2709 --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/pages/LandingPage/LandingPage.module.css @@ -0,0 +1,11 @@ +.landingPage { + display: flex; + flex-direction: column; + gap: var(--fds-spacing-5); +} + +.image { + min-width: 10rem; + width: 40%; + max-width: 30rem; +} diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/pages/LandingPage/LandingPage.test.tsx b/frontend/libs/studio-content-library/src/ContentLibrary/pages/LandingPage/LandingPage.test.tsx new file mode 100644 index 00000000000..541fed8163a --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/pages/LandingPage/LandingPage.test.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { LandingPage } from './LandingPage'; +import { textMock } from '@studio/testing/mocks/i18nMock'; + +describe('LandingPage', () => { + afterEach(jest.clearAllMocks); + + it('renders the title, description and image', () => { + renderLandingPage(); + expect( + screen.getByRole('heading', { + name: textMock('app_content_library.landing_page.title'), + level: 1, + }), + ).toBeInTheDocument(); + expect( + screen.getByText(textMock('app_content_library.landing_page.description')), + ).toBeInTheDocument(); + expect(screen.getByRole('presentation')).toBeInTheDocument(); + }); +}); + +const renderLandingPage = () => { + render(); +}; diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/pages/LandingPage/LandingPage.tsx b/frontend/libs/studio-content-library/src/ContentLibrary/pages/LandingPage/LandingPage.tsx new file mode 100644 index 00000000000..319bd26da03 --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/pages/LandingPage/LandingPage.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { StudioHeading, StudioParagraph } from '@studio/components'; +import classes from './LandingPage.module.css'; +import { useTranslation } from 'react-i18next'; + +export function LandingPage(): React.ReactElement { + const { t } = useTranslation(); + return ( +
+ {t('app_content_library.landing_page.title')} + {t('app_content_library.landing_page.description')} + +
+ ); +} diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/pages/LandingPage/index.ts b/frontend/libs/studio-content-library/src/ContentLibrary/pages/LandingPage/index.ts new file mode 100644 index 00000000000..a91a96a687f --- /dev/null +++ b/frontend/libs/studio-content-library/src/ContentLibrary/pages/LandingPage/index.ts @@ -0,0 +1 @@ +export { LandingPage } from './LandingPage'; diff --git a/frontend/libs/studio-content-library/src/config/ContentResourceLibraryImpl.test.tsx b/frontend/libs/studio-content-library/src/config/ContentResourceLibraryImpl.test.tsx new file mode 100644 index 00000000000..131bd3bedb4 --- /dev/null +++ b/frontend/libs/studio-content-library/src/config/ContentResourceLibraryImpl.test.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import type { PagesConfig } from '../types/PagesProps'; +import { ResourceContentLibraryImpl } from './ContentResourceLibraryImpl'; +import { textMock } from '@studio/testing/mocks/i18nMock'; + +describe('ContentResourceLibraryImpl', () => { + it('renders ContentResourceLibraryImpl with given pages', () => { + const pagesConfig: PagesConfig = { + codeList: { + props: { + codeLists: [], + onUpdateCodeList: () => {}, + }, + }, + images: { + props: { + images: [], + onUpdateImage: () => {}, + }, + }, + }; + renderContentResourceLibraryImpl(pagesConfig); + const libraryTitle = screen.getByRole('heading', { + name: textMock('app_content_library.landing_page.title'), + }); + const codeListMenuElement = screen.getByText( + textMock('app_content_library.code_lists.page_name'), + ); + const imagesMenuElement = screen.getByText(textMock('app_content_library.images.page_name')); + expect(libraryTitle).toBeInTheDocument(); + expect(codeListMenuElement).toBeInTheDocument(); + expect(imagesMenuElement).toBeInTheDocument(); + }); + + it('renders ContentResourceLibraryImpl with landingPage when no pages are passed', () => { + renderContentResourceLibraryImpl({}); + const libraryTitle = screen.getByRole('heading', { + name: textMock('app_content_library.landing_page.title'), + }); + const codeListMenuElement = screen.queryByText( + textMock('app_content_library.code_lists.page_name'), + ); + const imagesMenuElement = screen.queryByText(textMock('app_content_library.images.page_name')); + expect(libraryTitle).toBeInTheDocument(); + expect(codeListMenuElement).not.toBeInTheDocument(); + expect(imagesMenuElement).not.toBeInTheDocument(); + }); +}); + +const renderContentResourceLibraryImpl = (pages: PagesConfig) => { + const contentResourceLibraryImpl = new ResourceContentLibraryImpl({ pages }); + const { getContentResourceLibrary } = contentResourceLibraryImpl; + render(
{getContentResourceLibrary()}
); +}; diff --git a/frontend/libs/studio-content-library/src/config/ContentResourceLibraryImpl.tsx b/frontend/libs/studio-content-library/src/config/ContentResourceLibraryImpl.tsx index e99b6519117..e3787ff116f 100644 --- a/frontend/libs/studio-content-library/src/config/ContentResourceLibraryImpl.tsx +++ b/frontend/libs/studio-content-library/src/config/ContentResourceLibraryImpl.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import type { PageConfig } from '../types/PagesProps'; -import { RouterPage } from '../pages/RouterPage'; +import type { PagesConfig } from '../types/PagesProps'; import { RouterContextProvider } from '../contexts/RouterContext'; +import { ContentLibrary } from '../ContentLibrary/ContentLibrary'; export class ResourceContentLibraryImpl { - private readonly pages: PageConfig; + private readonly pages: PagesConfig; - constructor(config: { pages: PageConfig }) { + constructor(config: { pages: PagesConfig }) { this.pages = config.pages; this.getContentResourceLibrary = this.getContentResourceLibrary.bind(this); } @@ -14,7 +14,7 @@ export class ResourceContentLibraryImpl { public getContentResourceLibrary(): React.ReactNode { return ( - + ); } diff --git a/frontend/libs/studio-content-library/src/contexts/RouterContext.test.tsx b/frontend/libs/studio-content-library/src/contexts/RouterContext.test.tsx index 498a0947661..d07b96d22aa 100644 --- a/frontend/libs/studio-content-library/src/contexts/RouterContext.test.tsx +++ b/frontend/libs/studio-content-library/src/contexts/RouterContext.test.tsx @@ -12,7 +12,7 @@ const MockComponent = () => { return (
{currentPage} - +
); }; @@ -52,7 +52,7 @@ describe('RouterContext', () => { const linkButton = screen.getByRole('button', { name: 'Go Home' }); await user.click(linkButton); - expect(navigateMock).toHaveBeenCalledWith('home'); + expect(navigateMock).toHaveBeenCalledWith('landingPage'); }); it('should throw an error when useRouterContext is used outside of a RouterContextProvider', () => { diff --git a/frontend/libs/studio-content-library/src/contexts/RouterContext.tsx b/frontend/libs/studio-content-library/src/contexts/RouterContext.tsx index 409b60c53bf..3f6556ab70f 100644 --- a/frontend/libs/studio-content-library/src/contexts/RouterContext.tsx +++ b/frontend/libs/studio-content-library/src/contexts/RouterContext.tsx @@ -1,9 +1,10 @@ import React, { createContext, useContext } from 'react'; import { useNavigation } from '../hooks/useNavigation'; +import type { PageName } from '../types/PageName'; export type RouterContextProps = { - currentPage: string; - navigate: (page: string) => void; + currentPage: PageName; + navigate: (page: PageName) => void; }; export const RouterContext = createContext(undefined); diff --git a/frontend/libs/studio-content-library/src/hooks/useNavigation.test.ts b/frontend/libs/studio-content-library/src/hooks/useNavigation.test.ts index 4957642aa10..72ae083768e 100644 --- a/frontend/libs/studio-content-library/src/hooks/useNavigation.test.ts +++ b/frontend/libs/studio-content-library/src/hooks/useNavigation.test.ts @@ -9,7 +9,7 @@ interface RouterInstanceMock extends QueryParamsRouter { } const mockRouterInstance: RouterInstanceMock = { - currentRoute: 'root', + currentRoute: 'landingPage', navigate: jest.fn(), }; @@ -20,7 +20,7 @@ jest.mock('../utils/router/QueryParamsRouter', () => ({ })); describe('useNavigation Hook', () => { - const mockCurrentPage: PageName = 'root'; + const mockCurrentPage: PageName = 'landingPage'; beforeEach(() => { mockRouterInstance.currentRoute = mockCurrentPage; diff --git a/frontend/libs/studio-content-library/src/hooks/useNavigation.ts b/frontend/libs/studio-content-library/src/hooks/useNavigation.ts index 0403826bf63..befefef2743 100644 --- a/frontend/libs/studio-content-library/src/hooks/useNavigation.ts +++ b/frontend/libs/studio-content-library/src/hooks/useNavigation.ts @@ -4,7 +4,7 @@ import type { PageName } from '../types/PageName'; type UseNavigationResult = { navigate: (page: PageName) => void; - currentPage: string; + currentPage: PageName; }; export const useNavigation = (): UseNavigationResult => { diff --git a/frontend/libs/studio-content-library/src/pages/CodeList/CodeList.test.tsx b/frontend/libs/studio-content-library/src/pages/CodeList/CodeList.test.tsx deleted file mode 100644 index f695fa651b7..00000000000 --- a/frontend/libs/studio-content-library/src/pages/CodeList/CodeList.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { CodeList } from './CodeList'; -import { useRouterContext } from '../../contexts/RouterContext'; -import userEvent from '@testing-library/user-event'; - -jest.mock('../../contexts/RouterContext', () => ({ - useRouterContext: jest.fn(), -})); - -describe('CodeList Component', () => { - const mockNavigate = jest.fn(); - - beforeEach(() => { - (useRouterContext as jest.Mock).mockReturnValue({ - navigate: mockNavigate, - }); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('renders the title correctly', () => { - render(); - expect(screen.getByText('Test Title')).toBeInTheDocument(); - }); - - it('navigates to root when the button is clicked', async () => { - const user = userEvent.setup(); - render(); - - const button = screen.getByRole('button', { name: /lenke/i }); - await user.click(button); - - expect(mockNavigate).toHaveBeenCalledWith('root'); - }); -}); diff --git a/frontend/libs/studio-content-library/src/pages/CodeList/CodeList.tsx b/frontend/libs/studio-content-library/src/pages/CodeList/CodeList.tsx deleted file mode 100644 index 883d83b2a19..00000000000 --- a/frontend/libs/studio-content-library/src/pages/CodeList/CodeList.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import { useRouterContext } from '../../contexts/RouterContext'; - -export type CodeListProps = { - title: string; -}; -export const CodeList = ({ title }: CodeListProps) => { - const { navigate } = useRouterContext(); - - const handleNavigation = () => { - navigate('root'); - }; - - return ( - <> -

{title}

- - - ); -}; diff --git a/frontend/libs/studio-content-library/src/pages/Root/Root.test.tsx b/frontend/libs/studio-content-library/src/pages/Root/Root.test.tsx deleted file mode 100644 index 47fd46549d0..00000000000 --- a/frontend/libs/studio-content-library/src/pages/Root/Root.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { Root } from './Root'; -import { useRouterContext } from '../../contexts/RouterContext'; -import userEvent from '@testing-library/user-event'; - -jest.mock('../../contexts/RouterContext', () => ({ - useRouterContext: jest.fn(), -})); - -describe('Root Component', () => { - const mockNavigate = jest.fn(); - - beforeEach(() => { - (useRouterContext as jest.Mock).mockReturnValue({ - navigate: mockNavigate, - }); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('renders the title and children', () => { - render( - -
Child Component
-
, - ); - expect(screen.getByRole('heading', { name: 'Welcome', level: 1 })).toBeInTheDocument(); - expect(screen.getByText('Child Component')).toBeInTheDocument(); - }); - - it('navigates to codeList when button is clicked', async () => { - const user = userEvent.setup(); - render( - -
Child Component
-
, - ); - - const button = screen.getByRole('button', { name: /to codelist/i }); - await user.click(button); - - expect(mockNavigate).toHaveBeenCalledWith('codeList'); - }); -}); diff --git a/frontend/libs/studio-content-library/src/pages/Root/Root.tsx b/frontend/libs/studio-content-library/src/pages/Root/Root.tsx deleted file mode 100644 index a7fc49ab914..00000000000 --- a/frontend/libs/studio-content-library/src/pages/Root/Root.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { ReactNode } from 'react'; -import React from 'react'; -import { useRouterContext } from '../../contexts/RouterContext'; - -export type RootPageProps = { - title: string; - children: ReactNode; -}; - -export const Root = ({ children, title }: RootPageProps) => { - const { navigate } = useRouterContext(); - const handleNavigation = () => { - navigate('codeList'); - }; - return ( - <> -

{title}

- {children} - - - ); -}; diff --git a/frontend/libs/studio-content-library/src/pages/Root/index.ts b/frontend/libs/studio-content-library/src/pages/Root/index.ts deleted file mode 100644 index 18ecd38cb9e..00000000000 --- a/frontend/libs/studio-content-library/src/pages/Root/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Root, type RootPageProps } from './Root'; diff --git a/frontend/libs/studio-content-library/src/pages/RouterPage.tsx b/frontend/libs/studio-content-library/src/pages/RouterPage.tsx deleted file mode 100644 index 18994c79c81..00000000000 --- a/frontend/libs/studio-content-library/src/pages/RouterPage.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import { useRouterContext } from '../contexts/RouterContext'; -import { RouterRouteMapperImpl } from '../utils/router/RouterRouteMapper'; -import type { PageConfig } from '../types/PagesProps'; - -type RouterPageProps = { - pages: PageConfig; -}; - -export const RouterPage = ({ pages }: RouterPageProps): React.ReactElement => { - const { currentPage } = useRouterContext(); - const router = new RouterRouteMapperImpl(pages); - - const Component = router.configuredRoutes.get(currentPage); - if (!Component) return

404 Page Not Found

; - - const componentProps = pages[currentPage].props; - return ; -}; diff --git a/frontend/libs/studio-content-library/src/types/InfoBoxProps.ts b/frontend/libs/studio-content-library/src/types/InfoBoxProps.ts new file mode 100644 index 00000000000..5263409ad8c --- /dev/null +++ b/frontend/libs/studio-content-library/src/types/InfoBoxProps.ts @@ -0,0 +1,5 @@ +export type InfoBoxProps = { + titleTextKey: string; + descriptionTextKey: string; + illustrationReference: string; +}; diff --git a/frontend/libs/studio-content-library/src/types/PageName.ts b/frontend/libs/studio-content-library/src/types/PageName.ts index 97ae1a46f97..51c67084372 100644 --- a/frontend/libs/studio-content-library/src/types/PageName.ts +++ b/frontend/libs/studio-content-library/src/types/PageName.ts @@ -1 +1 @@ -export type PageName = 'root' | 'codeList'; +export type PageName = 'landingPage' | 'codeList' | 'images'; diff --git a/frontend/libs/studio-content-library/src/types/PagesProps.ts b/frontend/libs/studio-content-library/src/types/PagesProps.ts index 10eca5baec1..6e7760f5c81 100644 --- a/frontend/libs/studio-content-library/src/types/PagesProps.ts +++ b/frontend/libs/studio-content-library/src/types/PagesProps.ts @@ -1,15 +1,19 @@ -import type { RootPageProps } from '../pages/Root'; -import type { CodeListProps } from '../pages/CodeList'; +import type { CodeListProps } from '../ContentLibrary/pages/CodeList'; +import type { PageName } from './PageName'; +import type { ImagesProps } from '../ContentLibrary/pages/Images'; -type PagePropsMap = { - root: RootPageProps; +export type PagePropsMap = { + landingPage?: {}; codeList?: CodeListProps; + images?: ImagesProps; }; type GlobalPageConfig = { props: T; }; -export type PageConfig = { - [K in keyof PagePropsMap]: GlobalPageConfig; +type AllPagesConfig = { + [K in PageName]: GlobalPageConfig; }; + +export type PagesConfig = Partial; diff --git a/frontend/libs/studio-content-library/src/utils/router/QueryParamsRouter.ts b/frontend/libs/studio-content-library/src/utils/router/QueryParamsRouter.ts index a102a9c84b2..23c21e495da 100644 --- a/frontend/libs/studio-content-library/src/utils/router/QueryParamsRouter.ts +++ b/frontend/libs/studio-content-library/src/utils/router/QueryParamsRouter.ts @@ -22,7 +22,7 @@ export class QueryParamsRouterImpl implements QueryParamsRouter { public get currentRoute(): PageName { const searchParams = new URLSearchParams(window.location.search); - return searchParams.get(pageRouterQueryParamKey) as string as PageName; + return (searchParams.get(pageRouterQueryParamKey) as PageName) ?? 'landingPage'; } public navigate(queryParam: string): void { diff --git a/frontend/libs/studio-content-library/src/utils/router/RouterRouteMapper.test.ts b/frontend/libs/studio-content-library/src/utils/router/RouterRouteMapper.test.ts index c67836cb937..f5d0c9e9752 100644 --- a/frontend/libs/studio-content-library/src/utils/router/RouterRouteMapper.test.ts +++ b/frontend/libs/studio-content-library/src/utils/router/RouterRouteMapper.test.ts @@ -1,49 +1,42 @@ import { RouterRouteMapperImpl } from './RouterRouteMapper'; -import { Root } from '../../pages/Root'; -import { CodeList } from '../../pages/CodeList'; -import type { PageConfig } from '../../types/PagesProps'; - -const mockPageConfig: PageConfig = { - root: { - props: { - title: 'Hello', - children: '

Welcome

', - }, - }, - codeList: { - props: { - title: 'CodeList', - }, - }, -}; +import { LandingPage } from '../../ContentLibrary/pages/LandingPage'; +import { CodeList } from '../../ContentLibrary/pages/CodeList'; +import { mockPagesConfig } from '../../../mocks/mockPagesConfig'; describe('RouterRouteMapperImpl', () => { it('should create configured routes correctly', () => { - const routerMapper = new RouterRouteMapperImpl(mockPageConfig); + const routerMapper = new RouterRouteMapperImpl(mockPagesConfig); const routes = routerMapper.configuredRoutes; - expect(routes.has('root')).toBeTruthy(); + expect(routes.has('landingPage')).toBeTruthy(); expect(routes.has('codeList')).toBeTruthy(); - expect(routes.get('root')).toBe(Root); + expect(routes.get('landingPage')).toBe(LandingPage); expect(routes.get('codeList')).toBe(CodeList); }); + it('should always include landingPage even when noe pages are passed', () => { + const routerMapper = new RouterRouteMapperImpl({}); + const routes = routerMapper.configuredRoutes; + expect(routes.has('landingPage')).toBeTruthy(); + expect(routes.get('landingPage')).toBe(LandingPage); + }); + it('should include configured routes only', () => { const routerMapper = new RouterRouteMapperImpl({ - root: { + codeList: { props: { - title: 'Hello', - children: '

Welcome

', + codeLists: [], + onUpdateCodeList: () => {}, }, }, }); const routes = routerMapper.configuredRoutes; - expect(routes.has('root')).toBeTruthy(); - expect(routes.get('codeList')).toBeUndefined(); + expect(routes.has('codeList')).toBeTruthy(); + expect(routes.get('images')).toBeUndefined(); }); it('should not include unsupported routes', () => { - const routerMapper = new RouterRouteMapperImpl(mockPageConfig); + const routerMapper = new RouterRouteMapperImpl(mockPagesConfig); const routes = routerMapper.configuredRoutes; expect(routes.has('nonExistentPage')).toBeFalsy(); }); diff --git a/frontend/libs/studio-content-library/src/utils/router/RouterRouteMapper.ts b/frontend/libs/studio-content-library/src/utils/router/RouterRouteMapper.ts index 5fd04d5f96b..e05fca14698 100644 --- a/frontend/libs/studio-content-library/src/utils/router/RouterRouteMapper.ts +++ b/frontend/libs/studio-content-library/src/utils/router/RouterRouteMapper.ts @@ -1,11 +1,16 @@ import { type ComponentProps, type ReactElement } from 'react'; -import { Root } from '../../pages/Root'; -import { CodeList } from '../../pages/CodeList'; -import type { PageConfig } from '../../types/PagesProps'; +import { CodeList } from '../../ContentLibrary/pages/CodeList'; +import type { PageName } from '../../types/PageName'; +import { LandingPage } from '../../ContentLibrary/pages/LandingPage'; +import type { PagesConfig } from '../../types/PagesProps'; +import { Images } from '../../ContentLibrary/pages/Images'; -type PageProps = ComponentProps; +type PageProps = + | ComponentProps + | ComponentProps + | ComponentProps; -type PageComponent

= (props: P) => ReactElement; +export type PageComponent

= (props: P) => ReactElement; type PageMap = Map; @@ -20,21 +25,22 @@ export class RouterRouteMapperImpl implements RouterRouteMapper { return this._configuredRoutes; } - constructor(private pages: PageConfig) { + constructor(private pages: PagesConfig) { this._configuredRoutes = this.getConfiguredRoutes(this.pages); } - private getConfiguredRoutes(pages: PageConfig): PageMap { - const pageMap = new Map ReactElement>(); + private getConfiguredRoutes(pages: PagesConfig): PageMap { + const pageMap = new Map(); - Object.keys(pages).forEach((page) => { - if (page === 'root') { - pageMap.set('root', Root); - } + pageMap.set('landingPage', LandingPage); + Object.keys(pages).forEach((page: PageName) => { if (page === 'codeList') { pageMap.set('codeList', CodeList); } + if (page === 'images') { + pageMap.set('images', Images); + } }); return pageMap; diff --git a/frontend/libs/studio-hooks/package.json b/frontend/libs/studio-hooks/package.json index f4ff62184e8..46fbd7aad54 100644 --- a/frontend/libs/studio-hooks/package.json +++ b/frontend/libs/studio-hooks/package.json @@ -22,5 +22,8 @@ "jest-environment-jsdom": "^29.7.0", "ts-jest": "^29.1.1", "typescript": "5.6.2" + }, + "peerDependencies": { + "react-router-dom": ">=6.0.0" } } diff --git a/frontend/libs/studio-hooks/src/hooks/useOrgAppScopedStorage.test.tsx b/frontend/libs/studio-hooks/src/hooks/useOrgAppScopedStorage.test.tsx new file mode 100644 index 00000000000..760240946b5 --- /dev/null +++ b/frontend/libs/studio-hooks/src/hooks/useOrgAppScopedStorage.test.tsx @@ -0,0 +1,69 @@ +import { renderHook } from '@testing-library/react'; +import { type UseOrgAppScopedStorage, useOrgAppScopedStorage } from './useOrgAppScopedStorage'; +import { useParams } from 'react-router-dom'; + +jest.mock('react-router-dom', () => ({ + useParams: jest.fn(), +})); + +const mockedOrg: string = 'testOrg'; +const mockedApp: string = 'testApp'; +const scopedStorageKey: string = 'testOrg-testApp'; +const storagesToTest: Array = ['localStorage', 'sessionStorage']; + +describe('useOrgAppScopedStorage', () => { + afterEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + }); + + it.each(storagesToTest)( + 'initializes ScopedStorageImpl with correct storage scope, %s', + (storage) => { + const { result } = renderUseOrgAppScopedStorage({ storage }); + + result.current.setItem('key', 'value'); + + expect(result.current.setItem).toBeDefined(); + expect(result.current.getItem).toBeDefined(); + expect(result.current.removeItem).toBeDefined(); + expect(window[storage].getItem(scopedStorageKey)).toBe('{"key":"value"}'); + }, + ); + + it.each(storagesToTest)('should retrieve parsed objects from %s', (storage) => { + const { result } = renderUseOrgAppScopedStorage({ storage }); + + result.current.setItem('person', { name: 'John', age: 18 }); + + expect(result.current.getItem('person')).toEqual({ + name: 'John', + age: 18, + }); + }); + + it.each(storagesToTest)('should be possible to remove item from %s', (storage) => { + const { result } = renderUseOrgAppScopedStorage({ storage }); + + result.current.setItem('key', 'value'); + result.current.removeItem('key'); + expect(result.current.getItem('key')).toBeUndefined(); + }); + + it('should use localStorage as default storage', () => { + const { result } = renderUseOrgAppScopedStorage({}); + result.current.setItem('key', 'value'); + + expect(window.localStorage.getItem(scopedStorageKey)).toBe('{"key":"value"}'); + }); +}); + +const renderUseOrgAppScopedStorage = ({ storage }: UseOrgAppScopedStorage) => { + (useParams as jest.Mock).mockReturnValue({ org: mockedOrg, app: mockedApp }); + const { result } = renderHook(() => + useOrgAppScopedStorage({ + storage, + }), + ); + return { result }; +}; diff --git a/frontend/libs/studio-hooks/src/hooks/useOrgAppScopedStorage.ts b/frontend/libs/studio-hooks/src/hooks/useOrgAppScopedStorage.ts new file mode 100644 index 00000000000..b9582cdba59 --- /dev/null +++ b/frontend/libs/studio-hooks/src/hooks/useOrgAppScopedStorage.ts @@ -0,0 +1,36 @@ +import { useParams } from 'react-router-dom'; +import { + type ScopedStorage, + type ScopedStorageResult, + ScopedStorageImpl, +} from '@studio/pure-functions'; + +type OrgAppParams = { + org: string; + app: string; +}; + +const supportedStorageMap: Record = { + localStorage: window.localStorage, + sessionStorage: window.sessionStorage, +}; + +export type UseOrgAppScopedStorage = { + storage?: 'localStorage' | 'sessionStorage'; +}; + +type UseOrgAppScopedStorageResult = ScopedStorageResult; +export const useOrgAppScopedStorage = ({ + storage = 'localStorage', +}: UseOrgAppScopedStorage): UseOrgAppScopedStorageResult => { + const { org, app } = useParams(); + + const storageKey: string = `${org}-${app}`; + const scopedStorage = new ScopedStorageImpl(supportedStorageMap[storage], storageKey); + + return { + setItem: scopedStorage.setItem, + getItem: scopedStorage.getItem, + removeItem: scopedStorage.removeItem, + }; +}; diff --git a/frontend/libs/studio-icons/src/react/icons/CodeListsIcon.tsx b/frontend/libs/studio-icons/src/react/icons/CodeListsIcon.tsx new file mode 100644 index 00000000000..54511bbb09f --- /dev/null +++ b/frontend/libs/studio-icons/src/react/icons/CodeListsIcon.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { SvgTemplate } from './SvgTemplate'; +import type { IconProps } from '../types'; + +export const CodeListsIcon = (props: IconProps): React.ReactElement => ( + + + +); diff --git a/frontend/libs/studio-icons/src/react/icons/index.ts b/frontend/libs/studio-icons/src/react/icons/index.ts index 541c847d68b..76234f2d6bb 100644 --- a/frontend/libs/studio-icons/src/react/icons/index.ts +++ b/frontend/libs/studio-icons/src/react/icons/index.ts @@ -2,6 +2,7 @@ export { AccordionIcon } from './AccordionIcon'; export { ArrayIcon } from './ArrayIcon'; export { BooleanIcon } from './BooleanIcon'; export { CheckboxIcon } from './CheckboxIcon'; +export { CodeListsIcon } from './CodeListsIcon'; export { CombinationIcon } from './CombinationIcon'; export { ConfirmationTaskIcon } from './ConfirmationTaskIcon'; export { DataTaskIcon } from './DataTaskIcon'; diff --git a/frontend/libs/studio-pure-functions/src/ArrayUtils/ArrayUtils.test.ts b/frontend/libs/studio-pure-functions/src/ArrayUtils/ArrayUtils.test.ts index 686536b1465..6b2bb066296 100644 --- a/frontend/libs/studio-pure-functions/src/ArrayUtils/ArrayUtils.test.ts +++ b/frontend/libs/studio-pure-functions/src/ArrayUtils/ArrayUtils.test.ts @@ -117,4 +117,144 @@ describe('ArrayUtils', () => { expect(ArrayUtils.prepend(['a', 'b', 'c'], 'd')).toEqual(['d', 'a', 'b', 'c']); }); }); + + describe('replaceLastItem', () => { + it('should replace the last item in an array and return the modified array', () => { + expect(ArrayUtils.replaceLastItem([1, 2, 3], 99)).toEqual([1, 2, 99]); + }); + + it('should handle arrays with only one item', () => { + expect(ArrayUtils.replaceLastItem([5], 42)).toEqual([42]); + }); + + it('should return an empty array when called on an empty array', () => { + expect(ArrayUtils.replaceLastItem([], 10)).toEqual([]); + }); + }); + + describe('areItemsUnique', () => { + it('Returns true if all items are unique', () => { + expect(ArrayUtils.areItemsUnique([1, 2, 3])).toBe(true); + expect(ArrayUtils.areItemsUnique(['a', 'b', 'c'])).toBe(true); + expect(ArrayUtils.areItemsUnique(['abc', 'bcd', 'cde'])).toBe(true); + expect(ArrayUtils.areItemsUnique([true, false])).toBe(true); + expect(ArrayUtils.areItemsUnique([1, 'b', true])).toBe(true); + expect(ArrayUtils.areItemsUnique([0, '', false, null, undefined])).toBe(true); + }); + + it('Returns true if array is empty', () => { + expect(ArrayUtils.areItemsUnique([])).toBe(true); + }); + + it('Returns false if there is at least one duplicated item', () => { + expect(ArrayUtils.areItemsUnique([1, 2, 1])).toBe(false); + expect(ArrayUtils.areItemsUnique(['a', 'a', 'c'])).toBe(false); + expect(ArrayUtils.areItemsUnique(['abc', 'bcd', 'bcd'])).toBe(false); + expect(ArrayUtils.areItemsUnique([true, false, true])).toBe(false); + expect(ArrayUtils.areItemsUnique([1, 'b', false, 1])).toBe(false); + expect(ArrayUtils.areItemsUnique([null, null])).toBe(false); + expect(ArrayUtils.areItemsUnique([undefined, undefined])).toBe(false); + }); + }); + + describe('swapArrayElements', () => { + it('Swaps two elements in an array', () => { + const arr: string[] = ['a', 'b', 'c', 'd', 'e', 'f']; + expect(ArrayUtils.swapArrayElements(arr, 'a', 'b')).toEqual(['b', 'a', 'c', 'd', 'e', 'f']); + }); + }); + + describe('insertArrayElementAtPos', () => { + const arr = ['a', 'b', 'c']; + + it('Inserts element at given position', () => { + expect(ArrayUtils.insertArrayElementAtPos(arr, 'M', 0)).toEqual(['M', 'a', 'b', 'c']); + expect(ArrayUtils.insertArrayElementAtPos(arr, 'M', 1)).toEqual(['a', 'M', 'b', 'c']); + expect(ArrayUtils.insertArrayElementAtPos(arr, 'M', 3)).toEqual(['a', 'b', 'c', 'M']); + }); + + it('Inserts element at the end if the position number is too large', () => { + expect(ArrayUtils.insertArrayElementAtPos(arr, 'M', 9)).toEqual(['a', 'b', 'c', 'M']); + }); + + it('Inserts element at the end if the position number is negative', () => { + expect(ArrayUtils.insertArrayElementAtPos(arr, 'M', -1)).toEqual(['a', 'b', 'c', 'M']); + }); + }); + + describe('mapByKey', () => { + it('Returns an array of values mapped by the given key', () => { + const array = [ + { a: 1, b: 2 }, + { a: 2, b: 'c' }, + { a: 3, b: true, c: 'abc' }, + ]; + expect(ArrayUtils.mapByKey(array, 'a')).toEqual([1, 2, 3]); + }); + }); + + describe('replaceByPredicate', () => { + it('Replaces the first item matching the predicate with the given item', () => { + const array = ['test1', 'test2', 'test3']; + const predicate = (item: string) => item === 'test2'; + const replaceWith = 'test4'; + expect(ArrayUtils.replaceByPredicate(array, predicate, replaceWith)).toEqual([ + 'test1', + 'test4', + 'test3', + ]); + }); + }); + + describe('rplaceItemsByValue', () => { + it('Replaces all items matching the given value with the given replacement', () => { + const array = ['a', 'b', 'c']; + expect(ArrayUtils.replaceItemsByValue(array, 'b', 'd')).toEqual(['a', 'd', 'c']); + }); + }); + + describe('moveArrayItem', () => { + it('Moves the item at the given index to the given position when the new position is BEFORE', () => { + const array = ['a', 'b', 'c', 'd', 'e', 'f']; + expect(ArrayUtils.moveArrayItem(array, 4, 1)).toEqual(['a', 'e', 'b', 'c', 'd', 'f']); + }); + + it('Moves the item at the given index to the given position when the new position is after', () => { + const array = ['a', 'b', 'c', 'd', 'e', 'f']; + expect(ArrayUtils.moveArrayItem(array, 1, 4)).toEqual(['a', 'c', 'd', 'e', 'b', 'f']); + }); + + it('Keeps the array unchanged if the two indices are the same', () => { + const array = ['a', 'b', 'c', 'd', 'e', 'f']; + expect(ArrayUtils.moveArrayItem(array, 1, 1)).toEqual(array); + }); + }); + + describe('generateUniqueStringWithNumber', () => { + it('Returns prefix + 0 when the array is empty', () => { + expect(ArrayUtils.generateUniqueStringWithNumber([], 'prefix')).toBe('prefix0'); + }); + + it('Returns prefix + 0 when the array does not contain this value already', () => { + const array = ['something', 'something else']; + expect(ArrayUtils.generateUniqueStringWithNumber(array, 'prefix')).toBe('prefix0'); + }); + + it('Returns prefix + number based on the existing values', () => { + const array = ['prefix0', 'prefix1', 'prefix2']; + expect(ArrayUtils.generateUniqueStringWithNumber(array, 'prefix')).toBe('prefix3'); + }); + + it('Returns number only when the prefix is empty', () => { + const array = ['0', '1', '2']; + expect(ArrayUtils.generateUniqueStringWithNumber(array)).toBe('3'); + }); + }); + + describe('removeEmptyStrings', () => { + it('Removes empty strings from an array', () => { + const array = ['0', '1', '', '2', '']; + expect(ArrayUtils.removeEmptyStrings(array)).toEqual(['0', '1', '2']); + }); + }); }); diff --git a/frontend/libs/studio-pure-functions/src/ArrayUtils/ArrayUtils.ts b/frontend/libs/studio-pure-functions/src/ArrayUtils/ArrayUtils.ts index 95104904b89..0056739527d 100644 --- a/frontend/libs/studio-pure-functions/src/ArrayUtils/ArrayUtils.ts +++ b/frontend/libs/studio-pure-functions/src/ArrayUtils/ArrayUtils.ts @@ -67,4 +67,119 @@ export class ArrayUtils { public static prepend(array: T[], item: T): T[] { return [item, ...array]; } + + /** + * Replaces the last item in an array. + * @param array The array of interest. + * @param replaceWith The item to replace the last item with. + * @returns The array with the last item replaced. + */ + static replaceLastItem = (array: T[], replaceWith: T): T[] => { + if (array.length === 0) { + return array; + } + array[array.length - 1] = replaceWith; + return array; + }; + + /** + * Checks if all items in the given array are unique. + * @param array The array of interest. + * @returns True if all items in the array are unique and false otherwise. + */ + static areItemsUnique = (array: T[]): boolean => array.length === new Set(array).size; + + /** + * Swaps the first values with the given values. + * @param array Array to swap items in. + * @param itemA First value to swap. + * @param itemB Second value to swap. + * @returns Array with swapped items. + */ + static swapArrayElements = (array: T[], itemA: T, itemB: T): T[] => { + const out = [...array]; + const indexA = array.indexOf(itemA); + const indexB = array.indexOf(itemB); + out[indexA] = itemB; + out[indexB] = itemA; + return out; + }; + + /** + * Inserts an item at a given position in an array. + * @param array Array to remove item from. + * @param item Item to remove. + * @param targetPos Position to remove item from. + * @returns Array with item inserted at given position. + */ + static insertArrayElementAtPos = (array: T[], item: T, targetPos: number): T[] => { + const out = [...array]; + if (targetPos >= array.length || targetPos < 0) out.push(item); + else out.splice(targetPos, 0, item); + return out; + }; + + /** + * Maps an array of objects by a given key. + * @param array The array of objects. + * @param key The key to map by. + * @returns An array of values mapped by the given key. + */ + static mapByKey = (array: T[], key: K): T[K][] => + array.map((item) => item[key]); + + /** + * Returns an array of which the items matching the given predicate are replaced with the given item. + * @param array The array of interest. + * @param predicate The predicate to match items by. + * @param replaceWith The item to replace the matching items with. + * @returns A shallow copy of the array with the matching items replaced. + */ + static replaceByPredicate = ( + array: T[], + predicate: (item: T) => boolean, + replaceWith: T, + ): T[] => { + const out = [...array]; + const index = array.findIndex(predicate); + if (index > -1) out[index] = replaceWith; + return out; + }; + + /** + * Returns an array of which the items matching the given value are replaced with the given item. + * @param array The array of interest. + * @param value The value to match items by. + * @param replaceWith The item to replace the matching items with. + */ + static replaceItemsByValue = (array: T[], value: T, replaceWith: T): T[] => + ArrayUtils.replaceByPredicate(array, (item) => item === value, replaceWith); + + /** + * Returns an array where the item at the given index is moved to the given index. + * @param array The array of interest. + * @param from The index of the item to move. + * @param to The index to move the item to. + */ + static moveArrayItem = (array: T[], from: number, to: number): T[] => { + const out = [...array]; + const item = out.splice(from, 1)[0]; + out.splice(to, 0, item); + return out; + }; + + /** Returns a string that is not already present in the given array by appending a number to the given prefix. */ + static generateUniqueStringWithNumber = (array: string[], prefix: string = ''): string => { + let i = 0; + let uniqueString = prefix + i; + while (array.includes(uniqueString)) { + i++; + uniqueString = prefix + i; + } + return uniqueString; + }; + + /** Removes empty strings from a string array */ + static removeEmptyStrings = (array: string[]): string[] => + ArrayUtils.removeItemByValue(array, ''); } diff --git a/frontend/libs/studio-pure-functions/src/ScopedStorage/ScopedStorage.test.ts b/frontend/libs/studio-pure-functions/src/ScopedStorage/ScopedStorage.test.ts new file mode 100644 index 00000000000..6163672b265 --- /dev/null +++ b/frontend/libs/studio-pure-functions/src/ScopedStorage/ScopedStorage.test.ts @@ -0,0 +1,121 @@ +import { type ScopedStorage, ScopedStorageImpl } from './ScopedStorage'; + +describe('ScopedStorage', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + describe('add new key', () => { + it('should create a single scoped key with the provided key-value pair as its value', () => { + const scopedStorage = new ScopedStorageImpl(window.localStorage, 'unit/test'); + scopedStorage.setItem('firstName', 'Random Value'); + expect(scopedStorage.getItem('firstName')).toBe('Random Value'); + }); + }); + + describe('get item', () => { + it('should return "null" if key does not exist', () => { + const scopedStorage = new ScopedStorageImpl(window.localStorage, 'unit/test'); + expect(scopedStorage.getItem('firstName')).toBeNull(); + }); + }); + + describe('update existing key', () => { + it('should append a new key-value pair to the existing scoped key', () => { + const scopedStorage = new ScopedStorageImpl(window.localStorage, 'unit/test'); + scopedStorage.setItem('firstKey', 'first value'); + scopedStorage.setItem('secondKey', 'secondValue'); + + expect(scopedStorage.getItem('firstKey')).toBe('first value'); + expect(scopedStorage.getItem('secondKey')).toBe('secondValue'); + }); + + it('should update the value of an existing key-value pair within the scoped key if the value has changed', () => { + const scopedStorage = new ScopedStorageImpl(window.localStorage, 'unit/test'); + scopedStorage.setItem('firstKey', 'first value'); + scopedStorage.setItem('firstKey', 'first value is updated'); + expect(scopedStorage.getItem('firstKey')).toBe('first value is updated'); + }); + }); + + describe('delete values from key', () => { + it('should remove a specific key-value pair from the existing scoped key', () => { + const scopedStorage = new ScopedStorageImpl(window.localStorage, 'unit/test'); + scopedStorage.setItem('firstKey', 'first value'); + expect(scopedStorage.getItem('firstKey')).toBeDefined(); + + scopedStorage.removeItem('firstKey'); + expect(scopedStorage.getItem('firstKey')).toBeUndefined(); + }); + + it('should not remove key if it does not exist', () => { + const removeItemMock = jest.fn(); + const customStorage = { + getItem: jest.fn().mockImplementation(() => null), + removeItem: removeItemMock, + setItem: jest.fn(), + }; + + const scopedStorage = new ScopedStorageImpl(customStorage, 'unit/test'); + scopedStorage.removeItem('keyDoesNotExist'); + + expect(removeItemMock).not.toHaveBeenCalled(); + }); + }); + + describe('Storage parsing', () => { + const consoleErrorMock = jest.fn(); + const originalConsoleError = console.error; + beforeEach(() => { + console.error = consoleErrorMock; + }); + + afterEach(() => { + console.error = originalConsoleError; + }); + + it('should console.error when parsing the storage fails', () => { + window.localStorage.setItem('unit/test', '{"person";{"name":"tester"}}'); + const scopedStorage = new ScopedStorageImpl(window.localStorage, 'unit/test'); + expect(scopedStorage.getItem('person')).toBeNull(); + expect(consoleErrorMock).toHaveBeenCalledWith( + expect.stringContaining( + 'Failed to parse storage with key unit/test. Ensure that the storage is a valid JSON string. Error: SyntaxError:', + ), + ); + }); + }); + + // Verify that Dependency Inversion works as expected + describe('when using localStorage', () => { + it('should store and retrieve values using localStorage', () => { + const scopedStorage = new ScopedStorageImpl(window.sessionStorage, 'local/storage'); + scopedStorage.setItem('firstNameInSession', 'Random Session Value'); + expect(scopedStorage.getItem('firstNameInSession')).toBe('Random Session Value'); + }); + }); + + describe('when using sessionStorage', () => { + it('should store and retrieve values using sessionStorage', () => { + const scopedStorage = new ScopedStorageImpl(window.sessionStorage, 'session/storage'); + scopedStorage.setItem('firstNameInSession', 'Random Session Value'); + expect(scopedStorage.getItem('firstNameInSession')).toBe('Random Session Value'); + }); + }); + + describe('when using a custom storage implementation', () => { + it('should store and retrieve values using the provided custom storage', () => { + const setItemMock = jest.fn(); + + const customStorage: ScopedStorage = { + setItem: setItemMock, + getItem: jest.fn(), + removeItem: jest.fn(), + }; + + const scopedStorage = new ScopedStorageImpl(customStorage, 'unit/test'); + scopedStorage.setItem('testKey', 'testValue'); + expect(setItemMock).toHaveBeenCalledWith('unit/test', '{"testKey":"testValue"}'); + }); + }); +}); diff --git a/frontend/libs/studio-pure-functions/src/ScopedStorage/ScopedStorage.ts b/frontend/libs/studio-pure-functions/src/ScopedStorage/ScopedStorage.ts new file mode 100644 index 00000000000..0fc48637a6f --- /dev/null +++ b/frontend/libs/studio-pure-functions/src/ScopedStorage/ScopedStorage.ts @@ -0,0 +1,80 @@ +type StorageKey = string; + +export interface ScopedStorage extends Pick {} + +export interface ScopedStorageResult extends ScopedStorage { + setItem: (key: string, value: T) => void; + getItem: (key: string) => T; + removeItem: (key: string) => void; +} + +export class ScopedStorageImpl implements ScopedStorage { + private readonly storageKey: StorageKey; + private readonly scopedStorage: ScopedStorage; + + constructor( + private storage: ScopedStorage, + private key: StorageKey, + ) { + this.storageKey = this.key; + this.scopedStorage = this.storage; + this.setItem = this.setItem.bind(this); + this.getItem = this.getItem.bind(this); + this.removeItem = this.removeItem.bind(this); + } + + public setItem(key: string, value: T): void { + const storageRecords: T = this.getAllRecordsInStorage(); + this.saveToStorage( + JSON.stringify({ + ...storageRecords, + [key]: value, + }), + ); + } + + public getItem(key: string) { + const records: T = this.getAllRecordsInStorage(); + + if (!records) { + return null; + } + + return records[key] as T; + } + + public removeItem(key: string): void { + const storageRecords: T | null = this.getAllRecordsInStorage(); + + if (!storageRecords) { + return; + } + + const storageCopy = { ...storageRecords }; + delete storageCopy[key]; + this.saveToStorage(JSON.stringify({ ...storageCopy })); + } + + private getAllRecordsInStorage(): T | null { + return this.parseStorageData(this.scopedStorage.getItem(this.storageKey)); + } + + private saveToStorage(value: string) { + this.storage.setItem(this.storageKey, value); + } + + private parseStorageData(storage: string | null): T | null { + if (!storage) { + return null; + } + + try { + return JSON.parse(storage) satisfies T; + } catch (error) { + console.error( + `Failed to parse storage with key ${this.storageKey}. Ensure that the storage is a valid JSON string. Error: ${error}`, + ); + return null; + } + } +} diff --git a/frontend/libs/studio-pure-functions/src/ScopedStorage/index.ts b/frontend/libs/studio-pure-functions/src/ScopedStorage/index.ts new file mode 100644 index 00000000000..0b4b3d5747c --- /dev/null +++ b/frontend/libs/studio-pure-functions/src/ScopedStorage/index.ts @@ -0,0 +1 @@ +export { ScopedStorageImpl, type ScopedStorage, type ScopedStorageResult } from './ScopedStorage'; diff --git a/frontend/libs/studio-pure-functions/src/StringUtils/StringUtils.test.ts b/frontend/libs/studio-pure-functions/src/StringUtils/StringUtils.test.ts index ec036675abd..73bf38ab0ec 100644 --- a/frontend/libs/studio-pure-functions/src/StringUtils/StringUtils.test.ts +++ b/frontend/libs/studio-pure-functions/src/StringUtils/StringUtils.test.ts @@ -52,4 +52,56 @@ describe('StringUtils', () => { expect(input).toBe('abc/def/ghi'); }); }); + + describe('replaceEnd', () => { + it('Replaces the given substring with the given replacement at the end of the string', () => { + expect(StringUtils.replaceEnd('abc/def/ghi', 'ghi', 'xyz')).toBe('abc/def/xyz'); + }); + + it('Does not replace the given substring other places than at the end', () => { + expect(StringUtils.replaceEnd('abcdefghi', 'abc', 'xyz')).toBe('abcdefghi'); + expect(StringUtils.replaceEnd('abcdefghi', 'def', 'xyz')).toBe('abcdefghi'); + expect(StringUtils.replaceEnd('abcdefghidef', 'def', 'xyz')).toBe('abcdefghixyz'); + }); + }); + + describe('replaceStart', () => { + it('Replaces the given substring with the given replacement at the start of the string', () => { + expect(StringUtils.replaceStart('abc/def/ghi', 'abc', 'xyz')).toBe('xyz/def/ghi'); + }); + + it('Does not replace the given substring other places than at the start', () => { + expect(StringUtils.replaceStart('abcdefghi', 'ghi', 'xyz')).toBe('abcdefghi'); + expect(StringUtils.replaceStart('abcdefghi', 'def', 'xyz')).toBe('abcdefghi'); + expect(StringUtils.replaceStart('defabcdefghi', 'def', 'xyz')).toBe('xyzabcdefghi'); + }); + }); + + describe('substringBeforeLast', () => { + it('Returns substring before last occurrence of separator', () => { + expect(StringUtils.substringBeforeLast('abc/def/ghi', '/')).toBe('abc/def'); + }); + + it('Returns whole string if separator is not found', () => { + expect(StringUtils.substringBeforeLast('abc', '/')).toBe('abc'); + }); + + it('Returns whole string if there are no characters before the last separator', () => { + expect(StringUtils.substringBeforeLast('/abc', '/')).toBe(''); + }); + }); + + describe('substringAfterLast', () => { + it('Returns substring after last occurrence of separator', () => { + expect(StringUtils.substringAfterLast('abc/def/ghi', '/')).toBe('ghi'); + }); + + it('Returns whole string if separator is not found', () => { + expect(StringUtils.substringAfterLast('abc', '/')).toBe('abc'); + }); + + it('Returns empty string if there are no characters after the last separator', () => { + expect(StringUtils.substringAfterLast('abc/def/', '/')).toBe(''); + }); + }); }); diff --git a/frontend/libs/studio-pure-functions/src/StringUtils/StringUtils.ts b/frontend/libs/studio-pure-functions/src/StringUtils/StringUtils.ts index f38304fb861..b5a3927feb4 100644 --- a/frontend/libs/studio-pure-functions/src/StringUtils/StringUtils.ts +++ b/frontend/libs/studio-pure-functions/src/StringUtils/StringUtils.ts @@ -1,3 +1,5 @@ +import { ArrayUtils } from '../ArrayUtils'; + export class StringUtils { /** * Removes any of the given substrings from the start of the string. @@ -32,4 +34,48 @@ export class StringUtils { } return str; }; + + /** + * Replaces the given substring with the given replacement at the end of the string. + * If the substring does not appear at the end of the string, the string is returned unchanged. + * @param str The string to search in. + * @param substring The substring to search for. + * @param replacement The replacement to replace the substring with. + * @returns The string with the substring replaced at the end. + */ + static replaceEnd = (str: string, substring: string, replacement: string): string => + str.replace(new RegExp(substring + '$'), replacement); + + /** + * Replaces the given substring with the given replacement at the start of the string. + * If the substring does not appear at the start of the string, the string is returned unchanged. + * @param str The string to search in. + * @param substring The substring to search for. + * @param replacement The replacement to replace the substring with. + * @returns The string with the substring replaced at the start. + */ + static replaceStart = (str: string, substring: string, replacement: string): string => { + if (str.startsWith(substring)) { + return replacement + str.slice(substring.length); + } + return str; + }; + + /** + * Returns substring before last occurrence of separator. + * @param str The string to search in. + * @param separator The separator to search for. + * @returns The substring before the last occurrence of the given separator. + */ + static substringBeforeLast = (str: string, separator: string): string => + str.includes(separator) ? str.substring(0, str.lastIndexOf(separator)) : str; + + /** + * Returns substring after last occurrence of separator. + * @param str The string to search in. + * @param separator The separator to search for. + * @returns The substring after the last occurrence of the given separator. + */ + static substringAfterLast = (str: string, separator: string): string => + ArrayUtils.last(str.split(separator)) || ''; } diff --git a/frontend/libs/studio-pure-functions/src/index.ts b/frontend/libs/studio-pure-functions/src/index.ts index fb31ab06c5e..7e38f84007c 100644 --- a/frontend/libs/studio-pure-functions/src/index.ts +++ b/frontend/libs/studio-pure-functions/src/index.ts @@ -3,5 +3,6 @@ export * from './BlobDownloader'; export * from './DateUtils'; export * from './NumberUtils'; export * from './ObjectUtils'; +export * from './ScopedStorage'; export * from './StringUtils'; export * from './types'; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigSequenceFlow/ConfigSequenceFlow.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigSequenceFlow/ConfigSequenceFlow.tsx index e2343dab2dd..4d888b84aa9 100644 --- a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigSequenceFlow/ConfigSequenceFlow.tsx +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigSequenceFlow/ConfigSequenceFlow.tsx @@ -10,7 +10,7 @@ import { PlusIcon } from '@studio/icons'; import { useBpmnContext } from '../../../contexts/BpmnContext'; import { Paragraph } from '@digdir/designsystemet-react'; import { BpmnExpressionModeler } from '../../../utils/bpmnModeler/BpmnExpressionModeler'; -import { useExpressionTexts } from 'app-shared/components/Expression/useExpressionTexts'; +import { useExpressionTexts } from 'app-shared/hooks/useExpressionTexts'; import { useTranslation } from 'react-i18next'; import classes from './ConfigSequenceFlow.module.css'; import { ConfigIcon } from '../../../components/ConfigPanel/ConfigContent/ConfigIcon'; diff --git a/frontend/packages/process-editor/src/hooks/useBpmnEditor.ts b/frontend/packages/process-editor/src/hooks/useBpmnEditor.ts index 0d8b2a84690..cf6c51b7ea0 100644 --- a/frontend/packages/process-editor/src/hooks/useBpmnEditor.ts +++ b/frontend/packages/process-editor/src/hooks/useBpmnEditor.ts @@ -36,7 +36,11 @@ export const useBpmnEditor = (): UseBpmnViewerResult => { taskEvent, taskType: bpmnDetails.taskType, }); - if (bpmnDetails.taskType === 'data' || bpmnDetails.taskType === 'payment') + if ( + bpmnDetails.taskType === 'data' || + bpmnDetails.taskType === 'payment' || + bpmnDetails.taskType === 'signing' + ) addAction(bpmnDetails.id); }; diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumList.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumList.tsx index c202eccd2e3..ae27c271845 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumList.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumList.tsx @@ -8,7 +8,6 @@ import { useTranslation } from 'react-i18next'; import { PlusIcon } from '@studio/icons'; import { findDuplicateValues } from './utils'; import { useSchemaEditorAppContext } from '@altinn/schema-editor/hooks/useSchemaEditorAppContext'; -import { removeEmptyStrings } from 'app-shared/utils/arrayUtils'; import { StudioButton } from '@studio/components'; export type EnumListProps = { @@ -44,7 +43,7 @@ export const EnumList = ({ schemaNode }: EnumListProps): JSX.Element => { const duplicates: string[] = findDuplicateValues(newEnumList); if (duplicates === null) { - const newNode = { ...schemaNode, enum: removeEmptyStrings(newEnumList) }; + const newNode = { ...schemaNode, enum: ArrayUtils.removeEmptyStrings(newEnumList) }; save(schemaModel.updateNode(newNode.schemaPointer, newNode)); } diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/utils.ts b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/utils.ts index c5e92bb0219..752bda76999 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/utils.ts +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/utils.ts @@ -1,9 +1,9 @@ -import { areItemsUnique, removeEmptyStrings } from 'app-shared/utils/arrayUtils'; +import { ArrayUtils } from '@studio/pure-functions'; export const findDuplicateValues = (array: string[]): string[] | null => { - const arrayWithoutEmptyStrings: string[] = removeEmptyStrings(array); + const arrayWithoutEmptyStrings: string[] = ArrayUtils.removeEmptyStrings(array); - if (areItemsUnique(arrayWithoutEmptyStrings)) return null; + if (ArrayUtils.areItemsUnique(arrayWithoutEmptyStrings)) return null; return findDuplicates(arrayWithoutEmptyStrings); }; diff --git a/frontend/packages/schema-model/src/lib/SchemaModel/SchemaModel.ts b/frontend/packages/schema-model/src/lib/SchemaModel/SchemaModel.ts index 85263007fe8..06484fd850c 100644 --- a/frontend/packages/schema-model/src/lib/SchemaModel/SchemaModel.ts +++ b/frontend/packages/schema-model/src/lib/SchemaModel/SchemaModel.ts @@ -16,16 +16,9 @@ import { isNodeValidParent, isReference, } from '../utils'; -import { - generateUniqueStringWithNumber, - insertArrayElementAtPos, - moveArrayItem, - replaceItemsByValue, -} from 'app-shared/utils/arrayUtils'; import { ROOT_POINTER, UNIQUE_POINTER_PREFIX } from '../constants'; import type { ReferenceNode } from '../../types/ReferenceNode'; import { ObjectUtils, ArrayUtils, StringUtils } from '@studio/pure-functions'; -import { replaceStart } from 'app-shared/utils/stringUtils'; import { createDefinitionPointer, createPropertyPointer, @@ -199,7 +192,7 @@ export class SchemaModel extends SchemaModelBase { private addChildPointer = (target: NodePosition, newPointer: string): void => { const parent = this.getNodeBySchemaPointer(target.parentPointer) as FieldNode | CombinationNode; if (!isNodeValidParent(parent)) throw new Error('Invalid parent node.'); - parent.children = insertArrayElementAtPos(parent.children, newPointer, target.index); + parent.children = ArrayUtils.insertArrayElementAtPos(parent.children, newPointer, target.index); }; public addFieldType = (name: string): FieldNode => { @@ -249,7 +242,7 @@ export class SchemaModel extends SchemaModelBase { toIndex: number, ): UiSchemaNode => { const finalIndex = ArrayUtils.getValidIndex(parent.children, toIndex); - parent.children = moveArrayItem(parent.children, fromIndex, finalIndex); + parent.children = ArrayUtils.moveArrayItem(parent.children, fromIndex, finalIndex); this.synchronizeCombinationChildPointers(parent); return this.getNodeBySchemaPointer(parent.children[toIndex]); }; @@ -303,7 +296,7 @@ export class SchemaModel extends SchemaModelBase { ): UiSchemaNode => { const currentIndex = this.getIndexOfChildNode(schemaPointer); const finalIndex = ArrayUtils.getValidIndex(parent.children, newIndex); - parent.children = moveArrayItem(parent.children, currentIndex, finalIndex); + parent.children = ArrayUtils.moveArrayItem(parent.children, currentIndex, finalIndex); return this.getNodeBySchemaPointer(parent.children[finalIndex]); }; @@ -359,7 +352,7 @@ export class SchemaModel extends SchemaModelBase { private changePointerInParent(oldPointer: string, newPointer: string): void { const parentNode = this.getParentNode(oldPointer); if (parentNode) { - const children = replaceItemsByValue(parentNode.children, oldPointer, newPointer); + const children = ArrayUtils.replaceItemsByValue(parentNode.children, oldPointer, newPointer); this.updateNodeData(parentNode.schemaPointer, { ...parentNode, children }); } } @@ -375,7 +368,7 @@ export class SchemaModel extends SchemaModelBase { const node = this.getNodeBySchemaPointer(newPointer); // Expects the node map to be updated if (isFieldOrCombination(node) && node.children) { const makeNewPointer = (schemaPointer: string) => - replaceStart(schemaPointer, oldPointer, newPointer); + StringUtils.replaceStart(schemaPointer, oldPointer, newPointer); node.children.forEach((childPointer) => { const newPointer = makeNewPointer(childPointer); this.changePointer(childPointer, newPointer); @@ -426,14 +419,14 @@ export class SchemaModel extends SchemaModelBase { const node = this.getNodeBySchemaPointer(schemaPointer); const childPointers = isFieldOrCombination(node) ? node.children : []; const childNames = childPointers.map(extractNameFromPointer); - return generateUniqueStringWithNumber(childNames, namePrefix); + return ArrayUtils.generateUniqueStringWithNumber(childNames, namePrefix); } public generateUniqueDefinitionName(namePrefix: string = ''): string { const definitions = this.getDefinitions(); const definitionPointers = definitions.map((node) => node.schemaPointer); const definitionNames = definitionPointers.map(extractNameFromPointer); - return generateUniqueStringWithNumber(definitionNames, namePrefix); + return ArrayUtils.generateUniqueStringWithNumber(definitionNames, namePrefix); } public changeCombinationType( diff --git a/frontend/packages/schema-model/src/lib/mappers/getPointers.ts b/frontend/packages/schema-model/src/lib/mappers/getPointers.ts index 59b7ef25d1f..3121797a80b 100644 --- a/frontend/packages/schema-model/src/lib/mappers/getPointers.ts +++ b/frontend/packages/schema-model/src/lib/mappers/getPointers.ts @@ -1,5 +1,5 @@ import type { UiSchemaNodes } from '../../types'; -import { mapByKey } from 'app-shared/utils/arrayUtils'; +import { ArrayUtils } from '@studio/pure-functions'; /** * Returns all pointers from uiSchema. @@ -7,4 +7,4 @@ import { mapByKey } from 'app-shared/utils/arrayUtils'; * @returns An array of pointers. */ export const getPointers = (uiSchema: UiSchemaNodes): string[] => - mapByKey(uiSchema, 'schemaPointer'); + ArrayUtils.mapByKey(uiSchema, 'schemaPointer'); diff --git a/frontend/packages/schema-model/src/lib/mutations/ui-schema-reducers.test.ts b/frontend/packages/schema-model/src/lib/mutations/ui-schema-reducers.test.ts index d8b7eb144b1..d115e07bfa2 100644 --- a/frontend/packages/schema-model/src/lib/mutations/ui-schema-reducers.test.ts +++ b/frontend/packages/schema-model/src/lib/mutations/ui-schema-reducers.test.ts @@ -47,13 +47,13 @@ import { expect } from '@jest/globals'; import { CombinationKind, FieldType, Keyword, ObjectKind, StrRestrictionKey } from '../../types'; import { ROOT_POINTER } from '../constants'; import { getPointers } from '../mappers/getPointers'; -import { substringAfterLast, substringBeforeLast } from 'app-shared/utils/stringUtils'; import type { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; import { validateTestUiSchema } from '../../../test/validateTestUiSchema'; import { SchemaModel } from '../SchemaModel'; import type { FieldNode } from '../../types/FieldNode'; import type { ReferenceNode } from '../../types/ReferenceNode'; import type { CombinationNode } from '../../types/CombinationNode'; +import { StringUtils } from '@studio/pure-functions'; describe('ui-schema-reducers', () => { let result: SchemaModel; @@ -73,7 +73,7 @@ describe('ui-schema-reducers', () => { it('Converts a property to a root level definition', () => { const { schemaPointer } = stringNodeMock; result = promoteProperty(createNewModelMock(), schemaPointer); - const expectedPointer = `${ROOT_POINTER}/$defs/${substringAfterLast(schemaPointer, '/')}`; + const expectedPointer = `${ROOT_POINTER}/$defs/${StringUtils.substringAfterLast(schemaPointer, '/')}`; expect(getPointers(result.asArray())).toContain(expectedPointer); expect(result.getNodeBySchemaPointer(expectedPointer)).toMatchObject({ fieldType: stringNodeMock.fieldType, @@ -223,7 +223,7 @@ describe('ui-schema-reducers', () => { const name = 'new name'; const callback = jest.fn(); const args: SetPropertyNameArgs = { path: schemaPointer, name, callback }; - const expectedPointer = substringBeforeLast(schemaPointer, '/') + '/' + name; + const expectedPointer = StringUtils.substringBeforeLast(schemaPointer, '/') + '/' + name; it('Sets the name of the given property', () => { result = setPropertyName(createNewModelMock(), args); diff --git a/frontend/packages/schema-model/src/lib/mutations/ui-schema-reducers.ts b/frontend/packages/schema-model/src/lib/mutations/ui-schema-reducers.ts index bb824511f99..bc16412edfa 100644 --- a/frontend/packages/schema-model/src/lib/mutations/ui-schema-reducers.ts +++ b/frontend/packages/schema-model/src/lib/mutations/ui-schema-reducers.ts @@ -4,7 +4,7 @@ import { isField, isReference, splitPointerInBaseAndName } from '../utils'; import { convertPropToType } from './convert-node'; import type { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; import { castRestrictionType } from '../restrictions'; -import { swapArrayElements } from 'app-shared/utils/arrayUtils'; +import { ArrayUtils } from '@studio/pure-functions'; import { changeNameInPointer } from '../pointerUtils'; export const promoteProperty: UiSchemaReducer = (uiSchema, path) => { @@ -206,6 +206,7 @@ export const changeChildrenOrder: UiSchemaReducer = ( if (baseA !== baseB) return uiSchema; const newSchema = uiSchema.deepClone(); const parentNode = newSchema.getParentNode(pointerA); - if (parentNode) parentNode.children = swapArrayElements(parentNode.children, pointerA, pointerB); + if (parentNode) + parentNode.children = ArrayUtils.swapArrayElements(parentNode.children, pointerA, pointerB); return newSchema; }; diff --git a/frontend/packages/schema-model/test/validateTestUiSchema.ts b/frontend/packages/schema-model/test/validateTestUiSchema.ts index 12fa4077962..2010155b44a 100644 --- a/frontend/packages/schema-model/test/validateTestUiSchema.ts +++ b/frontend/packages/schema-model/test/validateTestUiSchema.ts @@ -1,7 +1,6 @@ import type { UiSchemaNodes } from '../src'; import { FieldType, ObjectKind, ROOT_POINTER } from '../src'; import { getPointers } from '../src/lib/mappers/getPointers'; -import { areItemsUnique, mapByKey } from 'app-shared/utils/arrayUtils'; import { isField, isFieldOrCombination, @@ -20,7 +19,7 @@ export const hasRootNode = (uiSchema: UiSchemaNodes) => /** Verifies that all pointers are unique */ export const pointersAreUnique = (uiSchema: UiSchemaNodes) => - expect(areItemsUnique(getPointers(uiSchema))).toBe(true); + expect(ArrayUtils.areItemsUnique(getPointers(uiSchema))).toBe(true); /** Verifies that all pointers referenced to as children exist */ export const allPointersExist = (uiSchema: UiSchemaNodes) => { @@ -33,7 +32,10 @@ export const allPointersExist = (uiSchema: UiSchemaNodes) => { /** Verifies that all nodes except the root node have a parent */ export const nodesHaveParent = (uiSchema: UiSchemaNodes) => { - const allChildPointers = mapByKey(uiSchema.filter(isFieldOrCombination), 'children').flat(); + const allChildPointers = ArrayUtils.mapByKey( + uiSchema.filter(isFieldOrCombination), + 'children', + ).flat(); ArrayUtils.removeItemByValue(getPointers(uiSchema), ROOT_POINTER).forEach((schemaPointer) => { expect(allChildPointers).toContain(schemaPointer); }); diff --git a/frontend/packages/shared/src/components/FileSelector.test.tsx b/frontend/packages/shared/src/components/FileSelector.test.tsx deleted file mode 100644 index c66a9077aeb..00000000000 --- a/frontend/packages/shared/src/components/FileSelector.test.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import type { IFileSelectorProps } from './FileSelector'; -import { FileSelector } from './FileSelector'; -import { textMock } from '@studio/testing/mocks/i18nMock'; -import { Button } from '@digdir/designsystemet-react'; -import { fileSelectorInputId } from '@studio/testing/testids'; -import { toast } from 'react-toastify'; - -jest.mock('react-toastify', () => ({ - toast: { - error: jest.fn(), - }, -})); - -const user = userEvent.setup(); - -const renderFileSelector = (props: Partial = {}) => { - const allProps: IFileSelectorProps = { - submitHandler: jest.fn(), - busy: false, - formFileName: '', - ...props, - }; - - render(); -}; - -const customButtonText = 'Lorem ipsum'; -const testCustomButtonRenderer = (onClick: React.MouseEventHandler) => ( - -); - -describe('FileSelector', () => { - it('should not call submitHandler when no files are selected', async () => { - const handleSubmit = jest.fn(); - renderFileSelector({ submitHandler: handleSubmit }); - - const fileInput = screen.getByTestId(fileSelectorInputId); - await user.upload(fileInput, null); - - expect(handleSubmit).not.toHaveBeenCalled(); - }); - - it('should call submitHandler when a file is selected', async () => { - const file = new File(['hello'], 'hello.png', { type: 'image/png' }); - const handleSubmit = jest.fn(); - renderFileSelector({ submitHandler: handleSubmit }); - - const fileInput = screen.getByTestId(fileSelectorInputId); - await user.upload(fileInput, file); - - expect(handleSubmit).toHaveBeenCalledWith(expect.any(FormData), 'hello.png'); - }); - - it('Should show text on the button by default', async () => { - renderFileSelector(); - expect( - screen.getByRole('button', { name: textMock('app_data_modelling.upload_xsd') }), - ).toBeInTheDocument(); - }); - - it('Should show custom button', async () => { - renderFileSelector({ submitButtonRenderer: testCustomButtonRenderer }); - expect(screen.getByRole('button', { name: customButtonText })).toBeInTheDocument(); - }); - - it('Should call file input onClick handler when the default upload button is clicked', async () => { - renderFileSelector(); - const button = screen.getByRole('button', { name: textMock('app_data_modelling.upload_xsd') }); - const fileInput = screen.getByTestId(fileSelectorInputId); - fileInput.onclick = jest.fn(); - await user.click(button); - expect(fileInput.onclick).toHaveBeenCalled(); - }); - - it('Should call file input onClick handler when the custom upload button is clicked', async () => { - renderFileSelector({ submitButtonRenderer: testCustomButtonRenderer }); - const button = screen.getByRole('button', { name: customButtonText }); - const fileInput = screen.getByTestId(fileSelectorInputId); - fileInput.onclick = jest.fn(); - await user.click(button); - expect(fileInput.onclick).toHaveBeenCalled(); - }); - - it('Should show a toast error when an invalid file name is uploaded', async () => { - const invalidFileName = '123_invalid_name"%#$&'; - const file = new File(['datamodell'], invalidFileName); - renderFileSelector(); - const fileInput = screen.getByTestId(fileSelectorInputId); - await user.upload(fileInput, file); - expect(toast.error).toHaveBeenCalledWith( - textMock('schema_editor.invalid_datamodel_upload_filename'), - ); - }); -}); diff --git a/frontend/packages/shared/src/components/FileSelector.tsx b/frontend/packages/shared/src/components/FileSelector.tsx deleted file mode 100644 index a9a86af00f2..00000000000 --- a/frontend/packages/shared/src/components/FileSelector.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { StudioButton } from '@studio/components'; -import { UploadIcon } from '@studio/icons'; -import { fileSelectorInputId } from '@studio/testing/testids'; -import { toast } from 'react-toastify'; -import classes from './FileSelector.module.css'; - -export interface IFileSelectorProps { - accept?: string; - busy: boolean; - disabled?: boolean; - formFileName: string; - submitButtonRenderer?: (fileInputClickHandler: (event: any) => void) => JSX.Element; - submitHandler: (file: FormData, fileName: string) => void; -} - -export const FileSelector = ({ - accept = undefined, - busy, - disabled, - formFileName, - submitButtonRenderer, - submitHandler, -}: IFileSelectorProps) => { - const { t } = useTranslation(); - const defaultSubmitButtonRenderer = (fileInputClickHandler: (event: any) => void) => ( - } - onClick={fileInputClickHandler} - disabled={disabled} - variant='tertiary' - > - {t('app_data_modelling.upload_xsd')} - - ); - - const fileInput = React.useRef(null); - - const handleSubmit = (event?: React.FormEvent) => { - event?.preventDefault(); - const file = fileInput?.current?.files?.item(0); - if (!file.name.match(/^[a-zA-Z][a-zA-Z0-9_.\-æÆøØåÅ ]*$/)) { - toast.error(t('schema_editor.invalid_datamodel_upload_filename')); - fileInput.current.value = ''; - return; - } - - if (file) { - const formData = new FormData(); - formData.append(formFileName, file); - submitHandler(formData, file.name); - } - }; - - const handleInputChange = () => { - const file = fileInput?.current?.files?.item(0); - if (file) handleSubmit(); - }; - - return ( -

- - {(submitButtonRenderer ?? defaultSubmitButtonRenderer)(() => fileInput?.current?.click())} -
- ); -}; diff --git a/frontend/packages/shared/src/components/atoms/AltinnContentIcon.tsx b/frontend/packages/shared/src/components/atoms/AltinnContentIcon.tsx deleted file mode 100644 index 0f8e5c06b5c..00000000000 --- a/frontend/packages/shared/src/components/atoms/AltinnContentIcon.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; - -export function AltinnContentIcon() { - return ( - <> - - - - - - - - - - - - - ); -} - -export default AltinnContentIcon; diff --git a/frontend/packages/shared/src/components/molecules/AltinnContentLoader.tsx b/frontend/packages/shared/src/components/molecules/AltinnContentLoader.tsx index d8a98675fc5..d80ec5e8bbd 100644 --- a/frontend/packages/shared/src/components/molecules/AltinnContentLoader.tsx +++ b/frontend/packages/shared/src/components/molecules/AltinnContentLoader.tsx @@ -1,23 +1,24 @@ -import React from 'react'; +import React, { type ReactNode } from 'react'; import type { IContentLoaderProps } from 'react-content-loader'; import ContentLoader from 'react-content-loader'; -import AltinnContentIconComponent from '../atoms/AltinnContentIcon'; export type IAltinnContentLoaderProps = { /** The height of the loader, defaults to 200 */ height?: number; /** The width of the loader, defaults to 400 */ width?: number; + children: ReactNode; } & IContentLoaderProps; -export const AltinnContentLoader = (props: React.PropsWithChildren) => { +export const AltinnContentLoader = ({ + height, + width, + children, + ...rest +}: IAltinnContentLoaderProps) => { return ( - - {props.children ? props.children : } + + {children} ); }; diff --git a/frontend/packages/shared/src/constants.js b/frontend/packages/shared/src/constants.js index a4dea619350..193e01a5748 100644 --- a/frontend/packages/shared/src/constants.js +++ b/frontend/packages/shared/src/constants.js @@ -3,6 +3,7 @@ export const APP_DEVELOPMENT_BASENAME = '/editor'; export const DASHBOARD_BASENAME = '/dashboard'; export const DASHBOARD_ROOT_ROUTE = '/'; export const RESOURCEADM_BASENAME = '/resourceadm'; +export const STUDIO_LIBRARY_BASENAME = '/library'; export const PREVIEW_BASENAME = '/preview'; export const STUDIO_ROOT_BASENAME = '/'; export const DEFAULT_LANGUAGE = 'nb'; diff --git a/frontend/packages/shared/src/hooks/useConfirmationDialogOnPageLeave.test.tsx b/frontend/packages/shared/src/hooks/useConfirmationDialogOnPageLeave.test.tsx deleted file mode 100644 index cd92fdbe68e..00000000000 --- a/frontend/packages/shared/src/hooks/useConfirmationDialogOnPageLeave.test.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import React from 'react'; -import { render as rtlRender, waitFor } from '@testing-library/react'; -import { useConfirmationDialogOnPageLeave } from './useConfirmationDialogOnPageLeave'; -import { RouterProvider, createMemoryRouter, useBeforeUnload } from 'react-router-dom'; - -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useBeforeUnload: jest.fn(), -})); - -const confirmationMessage = 'test'; - -const Component = ({ showConfirmationDialog }: { showConfirmationDialog: boolean }) => { - useConfirmationDialogOnPageLeave(showConfirmationDialog, confirmationMessage); - return null; -}; - -const render = (showConfirmationDialog: boolean) => { - const router = createMemoryRouter([ - { - path: '/', - element: , - }, - { - path: '/test', - element: null, - }, - ]); - - const { rerender } = rtlRender(); - return { - rerender, - router, - }; -}; - -describe('useConfirmationDialogOnPageLeave', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should call useBeforeUnload with the expected arguments', () => { - const showConfirmationDialog = true; - render(showConfirmationDialog); - - expect(useBeforeUnload).toHaveBeenCalledWith(expect.any(Function), { - capture: true, - }); - }); - - it('should prevent navigation if showConfirmationDialog is true', () => { - const event = { - type: 'beforeunload', - returnValue: confirmationMessage, - } as BeforeUnloadEvent; - event.preventDefault = jest.fn(); - - const showConfirmationDialog = true; - render(showConfirmationDialog); - - const callbackFn = (useBeforeUnload as jest.MockedFunction).mock - .calls[0][0]; - callbackFn(event); - - expect(event.preventDefault).toHaveBeenCalled(); - expect(event.returnValue).toBe(confirmationMessage); - }); - - it('should not prevent navigation if showConfirmationDialog is false', () => { - const event = { - type: 'beforeunload', - returnValue: '', - } as BeforeUnloadEvent; - event.preventDefault = jest.fn(); - - const showConfirmationDialog = false; - render(showConfirmationDialog); - - const callbackFn = (useBeforeUnload as jest.MockedFunction).mock - .calls[0][0]; - callbackFn(event); - - expect(event.preventDefault).not.toHaveBeenCalled(); - expect(event.returnValue).toBe(''); - }); - - it('doesnt show confirmation dialog when there are no unsaved changes', async () => { - window.confirm = jest.fn(); - - const showConfirmationDialog = false; - const { router } = render(showConfirmationDialog); - - await waitFor(() => router.navigate('/test')); - - expect(window.confirm).toHaveBeenCalledTimes(0); - expect(router.state.location.pathname).toBe('/test'); - }); - - it('show confirmation dialog when there are unsaved changes', async () => { - window.confirm = jest.fn(); - - const showConfirmationDialog = true; - const { router } = render(showConfirmationDialog); - - await waitFor(() => router.navigate('/test')); - - expect(window.confirm).toHaveBeenCalledTimes(1); - expect(router.state.location.pathname).toBe('/'); - }); - - it('cancel redirection when clicking cancel', async () => { - window.confirm = jest.fn(() => false); - - const showConfirmationDialog = true; - const { router } = render(showConfirmationDialog); - - await waitFor(() => router.navigate('/test')); - - expect(window.confirm).toHaveBeenCalledTimes(1); - expect(router.state.location.pathname).toBe('/'); - }); - - it('redirect when clicking OK', async () => { - window.confirm = jest.fn(() => true); - - const showConfirmationDialog = true; - const { router } = render(showConfirmationDialog); - - await waitFor(() => router.navigate('/test')); - - expect(window.confirm).toHaveBeenCalledTimes(1); - expect(router.state.location.pathname).toBe('/test'); - }); -}); diff --git a/frontend/packages/shared/src/hooks/useConfirmationDialogOnPageLeave.ts b/frontend/packages/shared/src/hooks/useConfirmationDialogOnPageLeave.ts deleted file mode 100644 index 24b7a993d0c..00000000000 --- a/frontend/packages/shared/src/hooks/useConfirmationDialogOnPageLeave.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useCallback, useEffect } from 'react'; -import { useBeforeUnload, useBlocker } from 'react-router-dom'; - -export const useConfirmationDialogOnPageLeave = ( - showConfirmationDialog: boolean, - confirmationMessage: string, -) => { - useBeforeUnload( - useCallback( - (event: BeforeUnloadEvent) => { - if (showConfirmationDialog) { - event.preventDefault(); - event.returnValue = confirmationMessage; - } - }, - [showConfirmationDialog, confirmationMessage], - ), - { capture: true }, - ); - - const blocker = useBlocker(({ currentLocation, nextLocation }) => { - return showConfirmationDialog && currentLocation.pathname !== nextLocation.pathname; - }); - - useEffect(() => { - if (blocker.state === 'blocked') { - if (window.confirm(confirmationMessage)) { - blocker.proceed(); - } else { - blocker.reset(); - } - } - }, [blocker, confirmationMessage]); -}; diff --git a/frontend/packages/shared/src/components/Expression/useExpressionTexts.ts b/frontend/packages/shared/src/hooks/useExpressionTexts.ts similarity index 100% rename from frontend/packages/shared/src/components/Expression/useExpressionTexts.ts rename to frontend/packages/shared/src/hooks/useExpressionTexts.ts diff --git a/frontend/packages/shared/src/mocks/apiErrorMocks.ts b/frontend/packages/shared/src/mocks/apiErrorMocks.ts deleted file mode 100644 index 6825624341e..00000000000 --- a/frontend/packages/shared/src/mocks/apiErrorMocks.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { AxiosResponse } from 'axios'; -import { AxiosError } from 'axios'; - -export const createApiErrorMock = (status?: number): AxiosError => { - const error = new AxiosError(); - error.response = { - status, - } as AxiosResponse; - return error; -}; diff --git a/frontend/packages/shared/src/types/ComponentSpecificConfig.ts b/frontend/packages/shared/src/types/ComponentSpecificConfig.ts index a5f68ead45f..d36b87df446 100644 --- a/frontend/packages/shared/src/types/ComponentSpecificConfig.ts +++ b/frontend/packages/shared/src/types/ComponentSpecificConfig.ts @@ -376,7 +376,7 @@ export type ComponentSpecificConfig = { rowsAfter?: GridRow[]; labelSettings?: LabelSettings; }; - [ComponentType.SubForm]: FormComponentProps; + [ComponentType.Subform]: FormComponentProps; [ComponentType.Summary]: SummarizableComponentProps & { componentRef: string; largeGroup?: boolean; diff --git a/frontend/packages/shared/src/types/ComponentType.ts b/frontend/packages/shared/src/types/ComponentType.ts index 959c9b30513..7fa28585a34 100644 --- a/frontend/packages/shared/src/types/ComponentType.ts +++ b/frontend/packages/shared/src/types/ComponentType.ts @@ -37,7 +37,7 @@ export enum ComponentType { PrintButton = 'PrintButton', RadioButtons = 'RadioButtons', RepeatingGroup = 'RepeatingGroup', - SubForm = 'SubForm', + Subform = 'Subform', Summary = 'Summary', Summary2 = 'Summary2', TextArea = 'TextArea', diff --git a/frontend/packages/shared/src/types/api/LayoutSetPayload.ts b/frontend/packages/shared/src/types/api/LayoutSetPayload.ts index 70dad189389..5fb9a7d6164 100644 --- a/frontend/packages/shared/src/types/api/LayoutSetPayload.ts +++ b/frontend/packages/shared/src/types/api/LayoutSetPayload.ts @@ -5,7 +5,7 @@ export interface LayoutSetPayload { layoutSetConfig: LayoutSetConfig; } -type SubFormConfig = { +type SubformConfig = { type: 'subform'; }; @@ -16,4 +16,4 @@ type RegularLayoutSetConfig = { export type LayoutSetConfig = { id: string; dataType?: string; -} & (SubFormConfig | RegularLayoutSetConfig); +} & (SubformConfig | RegularLayoutSetConfig); diff --git a/frontend/packages/shared/src/utils/arrayUtils.test.ts b/frontend/packages/shared/src/utils/arrayUtils.test.ts deleted file mode 100644 index f67891a41c4..00000000000 --- a/frontend/packages/shared/src/utils/arrayUtils.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { - areItemsUnique, - generateUniqueStringWithNumber, - insertArrayElementAtPos, - mapByKey, - moveArrayItem, - removeEmptyStrings, - replaceByPredicate, - replaceItemsByValue, - swapArrayElements, -} from './arrayUtils'; - -describe('arrayUtils', () => { - describe('areItemsUnique', () => { - it('Returns true if all items are unique', () => { - expect(areItemsUnique([1, 2, 3])).toBe(true); - expect(areItemsUnique(['a', 'b', 'c'])).toBe(true); - expect(areItemsUnique(['abc', 'bcd', 'cde'])).toBe(true); - expect(areItemsUnique([true, false])).toBe(true); - expect(areItemsUnique([1, 'b', true])).toBe(true); - expect(areItemsUnique([0, '', false, null, undefined])).toBe(true); - }); - - it('Returns true if array is empty', () => { - expect(areItemsUnique([])).toBe(true); - }); - - it('Returns false if there is at least one duplicated item', () => { - expect(areItemsUnique([1, 2, 1])).toBe(false); - expect(areItemsUnique(['a', 'a', 'c'])).toBe(false); - expect(areItemsUnique(['abc', 'bcd', 'bcd'])).toBe(false); - expect(areItemsUnique([true, false, true])).toBe(false); - expect(areItemsUnique([1, 'b', false, 1])).toBe(false); - expect(areItemsUnique([null, null])).toBe(false); - expect(areItemsUnique([undefined, undefined])).toBe(false); - }); - }); - - describe('insertArrayElementAtPos', () => { - const arr = ['a', 'b', 'c']; - - it('Inserts element at given position', () => { - expect(insertArrayElementAtPos(arr, 'M', 0)).toEqual(['M', 'a', 'b', 'c']); - expect(insertArrayElementAtPos(arr, 'M', 1)).toEqual(['a', 'M', 'b', 'c']); - expect(insertArrayElementAtPos(arr, 'M', 3)).toEqual(['a', 'b', 'c', 'M']); - }); - - it('Inserts element at the end if the position number is too large', () => { - expect(insertArrayElementAtPos(arr, 'M', 9)).toEqual(['a', 'b', 'c', 'M']); - }); - - it('Inserts element at the end if the position number is negative', () => { - expect(insertArrayElementAtPos(arr, 'M', -1)).toEqual(['a', 'b', 'c', 'M']); - }); - }); - - describe('swapArrayElements', () => { - it('Swaps two elements in an array', () => { - const arr: string[] = ['a', 'b', 'c', 'd', 'e', 'f']; - expect(swapArrayElements(arr, 'a', 'b')).toEqual(['b', 'a', 'c', 'd', 'e', 'f']); - }); - }); - - describe('mapByKey', () => { - it('Returns an array of values mapped by the given key', () => { - const array = [ - { a: 1, b: 2 }, - { a: 2, b: 'c' }, - { a: 3, b: true, c: 'abc' }, - ]; - expect(mapByKey(array, 'a')).toEqual([1, 2, 3]); - }); - }); - - describe('rplaceItemsByValue', () => { - it('Replaces all items matching the given value with the given replacement', () => { - const array = ['a', 'b', 'c']; - expect(replaceItemsByValue(array, 'b', 'd')).toEqual(['a', 'd', 'c']); - }); - }); - - describe('replaceByPredicate', () => { - it('Replaces the first item matching the predicate with the given item', () => { - const array = ['test1', 'test2', 'test3']; - const predicate = (item: string) => item === 'test2'; - const replaceWith = 'test4'; - expect(replaceByPredicate(array, predicate, replaceWith)).toEqual([ - 'test1', - 'test4', - 'test3', - ]); - }); - }); - - describe('moveArrayItem', () => { - it('Moves the item at the given index to the given position when the new position is BEFORE', () => { - const array = ['a', 'b', 'c', 'd', 'e', 'f']; - expect(moveArrayItem(array, 4, 1)).toEqual(['a', 'e', 'b', 'c', 'd', 'f']); - }); - - it('Moves the item at the given index to the given position when the new position is after', () => { - const array = ['a', 'b', 'c', 'd', 'e', 'f']; - expect(moveArrayItem(array, 1, 4)).toEqual(['a', 'c', 'd', 'e', 'b', 'f']); - }); - - it('Keeps the array unchanged if the two indices are the same', () => { - const array = ['a', 'b', 'c', 'd', 'e', 'f']; - expect(moveArrayItem(array, 1, 1)).toEqual(array); - }); - }); - - describe('generateUniqueStringWithNumber', () => { - it('Returns prefix + 0 when the array is empty', () => { - expect(generateUniqueStringWithNumber([], 'prefix')).toBe('prefix0'); - }); - - it('Returns prefix + 0 when the array does not contain this value already', () => { - const array = ['something', 'something else']; - expect(generateUniqueStringWithNumber(array, 'prefix')).toBe('prefix0'); - }); - - it('Returns prefix + number based on the existing values', () => { - const array = ['prefix0', 'prefix1', 'prefix2']; - expect(generateUniqueStringWithNumber(array, 'prefix')).toBe('prefix3'); - }); - - it('Returns number only when the prefix is empty', () => { - const array = ['0', '1', '2']; - expect(generateUniqueStringWithNumber(array)).toBe('3'); - }); - }); - - describe('removeEmptyStrings', () => { - it('Removes empty strings from an array', () => { - const array = ['0', '1', '', '2', '']; - expect(removeEmptyStrings(array)).toEqual(['0', '1', '2']); - }); - }); -}); diff --git a/frontend/packages/shared/src/utils/arrayUtils.ts b/frontend/packages/shared/src/utils/arrayUtils.ts deleted file mode 100644 index 92f490c4650..00000000000 --- a/frontend/packages/shared/src/utils/arrayUtils.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { ArrayUtils } from '@studio/pure-functions'; - -/** - * Replaces the last item in an array. - * @param array The array of interest. - * @param replaceWith The item to replace the last item with. - * @returns The array with the last item replaced. - */ -export const replaceLastItem = (array: T[], replaceWith: T): T[] => { - array[array.length - 1] = replaceWith; - return array; -}; - -/** - * Checks if all items in the given array are unique. - * @param array The array of interest. - * @returns True if all items in the array are unique and false otherwise. - */ -export const areItemsUnique = (array: T[]): boolean => array.length === new Set(array).size; - -/** - * Swaps the first values with the given values. - * @param array Array to swap items in. - * @param itemA First value to swap. - * @param itemB Second value to swap. - * @returns Array with swapped items. - */ -export const swapArrayElements = (array: T[], itemA: T, itemB: T): T[] => { - const out = [...array]; - const indexA = array.indexOf(itemA); - const indexB = array.indexOf(itemB); - out[indexA] = itemB; - out[indexB] = itemA; - return out; -}; - -/** - * Inserts an item at a given position in an array. - * @param array Array to remove item from. - * @param item Item to remove. - * @param targetPos Position to remove item from. - * @returns Array with item inserted at given position. - */ -export const insertArrayElementAtPos = (array: T[], item: T, targetPos: number): T[] => { - const out = [...array]; - if (targetPos >= array.length || targetPos < 0) out.push(item); - else out.splice(targetPos, 0, item); - return out; -}; - -/** - * Maps an array of objects by a given key. - * @param array The array of objects. - * @param key The key to map by. - * @returns An array of values mapped by the given key. - */ -export const mapByKey = (array: T[], key: K): T[K][] => - array.map((item) => item[key]); - -/** - * Returns an array of which the items matching the given value are replaced with the given item. - * @param array The array of interest. - * @param value The value to match items by. - * @param replaceWith The item to replace the matching items with. - */ -export const replaceItemsByValue = (array: T[], value: T, replaceWith: T): T[] => - replaceByPredicate(array, (item) => item === value, replaceWith); - -/** - * Returns an array of which the items matching the given predicate are replaced with the given item. - * @param array The array of interest. - * @param predicate The predicate to match items by. - * @param replaceWith The item to replace the matching items with. - * @returns A shallow copy of the array with the matching items replaced. - */ -export const replaceByPredicate = ( - array: T[], - predicate: (item: T) => boolean, - replaceWith: T, -): T[] => { - const out = [...array]; - const index = array.findIndex(predicate); - if (index > -1) out[index] = replaceWith; - return out; -}; - -/** - * Returns an array where the item at the given index is moved to the given index. - * @param array The array of interest. - * @param from The index of the item to move. - * @param to The index to move the item to. - */ -export const moveArrayItem = (array: T[], from: number, to: number): T[] => { - const out = [...array]; - const item = out.splice(from, 1)[0]; - out.splice(to, 0, item); - return out; -}; - -/** Returns a string that is not already present in the given array by appending a number to the given prefix. */ -export const generateUniqueStringWithNumber = (array: string[], prefix: string = ''): string => { - let i = 0; - let uniqueString = prefix + i; - while (array.includes(uniqueString)) { - i++; - uniqueString = prefix + i; - } - return uniqueString; -}; - -/** Removes empty strings from a string array */ -export const removeEmptyStrings = (array: string[]): string[] => - ArrayUtils.removeItemByValue(array, ''); diff --git a/frontend/packages/shared/src/utils/featureToggleUtils.ts b/frontend/packages/shared/src/utils/featureToggleUtils.ts index 8f3dc9010f2..a610516af17 100644 --- a/frontend/packages/shared/src/utils/featureToggleUtils.ts +++ b/frontend/packages/shared/src/utils/featureToggleUtils.ts @@ -10,6 +10,7 @@ export type SupportedFeatureFlags = | 'resourceMigration' | 'multipleDataModelsPerTask' | 'exportForm' + | 'addComponentModal' | 'subform' | 'summary2'; diff --git a/frontend/packages/shared/src/utils/stringUtils.test.ts b/frontend/packages/shared/src/utils/stringUtils.test.ts deleted file mode 100644 index 65a3603972d..00000000000 --- a/frontend/packages/shared/src/utils/stringUtils.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { - replaceEnd, - replaceStart, - substringAfterLast, - substringBeforeLast, -} from 'app-shared/utils/stringUtils'; - -describe('stringUtils', () => { - describe('substringAfterLast', () => { - it('Returns substring after last occurrence of separator', () => { - expect(substringAfterLast('abc/def/ghi', '/')).toBe('ghi'); - }); - - it('Returns whole string if separator is not found', () => { - expect(substringAfterLast('abc', '/')).toBe('abc'); - }); - - it('Returns empty string if there are no characters after the last separator', () => { - expect(substringAfterLast('abc/def/', '/')).toBe(''); - }); - }); - - describe('substringBeforeLast', () => { - it('Returns substring before last occurrence of separator', () => { - expect(substringBeforeLast('abc/def/ghi', '/')).toBe('abc/def'); - }); - - it('Returns whole string if separator is not found', () => { - expect(substringBeforeLast('abc', '/')).toBe('abc'); - }); - - it('Returns whole string if there are no characters before the last separator', () => { - expect(substringBeforeLast('/abc', '/')).toBe(''); - }); - }); - - describe('replaceStart', () => { - it('Replaces the given substring with the given replacement at the start of the string', () => { - expect(replaceStart('abc/def/ghi', 'abc', 'xyz')).toBe('xyz/def/ghi'); - }); - - it('Does not replace the given substring other places than at the start', () => { - expect(replaceStart('abcdefghi', 'ghi', 'xyz')).toBe('abcdefghi'); - expect(replaceStart('abcdefghi', 'def', 'xyz')).toBe('abcdefghi'); - expect(replaceStart('defabcdefghi', 'def', 'xyz')).toBe('xyzabcdefghi'); - }); - }); - - describe('replaceEnd', () => { - it('Replaces the given substring with the given replacement at the end of the string', () => { - expect(replaceEnd('abc/def/ghi', 'ghi', 'xyz')).toBe('abc/def/xyz'); - }); - - it('Does not replace the given substring other places than at the end', () => { - expect(replaceEnd('abcdefghi', 'abc', 'xyz')).toBe('abcdefghi'); - expect(replaceEnd('abcdefghi', 'def', 'xyz')).toBe('abcdefghi'); - expect(replaceEnd('abcdefghidef', 'def', 'xyz')).toBe('abcdefghixyz'); - }); - }); -}); diff --git a/frontend/packages/shared/src/utils/stringUtils.ts b/frontend/packages/shared/src/utils/stringUtils.ts deleted file mode 100644 index 5cd182b7cb9..00000000000 --- a/frontend/packages/shared/src/utils/stringUtils.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { ArrayUtils } from '@studio/pure-functions'; - -/** - * Returns substring after last occurrence of separator. - * @param str The string to search in. - * @param separator The separator to search for. - * @returns The substring after the last occurrence of the given separator. - */ -export const substringAfterLast = (str: string, separator: string): string => - ArrayUtils.last(str.split(separator)) || ''; - -/** - * Returns substring before last occurrence of separator. - * @param str The string to search in. - * @param separator The separator to search for. - * @returns The substring before the last occurrence of the given separator. - */ -export const substringBeforeLast = (str: string, separator: string): string => - str.includes(separator) ? str.substring(0, str.lastIndexOf(separator)) : str; - -/** - * Replaces the given substring with the given replacement at the start of the string. - * If the substring does not appear at the start of the string, the string is returned unchanged. - * @param str The string to search in. - * @param substring The substring to search for. - * @param replacement The replacement to replace the substring with. - * @returns The string with the substring replaced at the start. - */ -export const replaceStart = (str: string, substring: string, replacement: string): string => { - if (str.startsWith(substring)) { - return replacement + str.slice(substring.length); - } - return str; -}; - -/** - * Replaces the given substring with the given replacement at the end of the string. - * If the substring does not appear at the end of the string, the string is returned unchanged. - * @param str The string to search in. - * @param substring The substring to search for. - * @param replacement The replacement to replace the substring with. - * @returns The string with the substring replaced at the end. - */ -export const replaceEnd = (str: string, substring: string, replacement: string): string => - str.replace(new RegExp(substring + '$'), replacement); diff --git a/frontend/packages/ux-editor-v3/package.json b/frontend/packages/ux-editor-v3/package.json index aec88e29933..84f6c8627da 100644 --- a/frontend/packages/ux-editor-v3/package.json +++ b/frontend/packages/ux-editor-v3/package.json @@ -11,7 +11,6 @@ "react-dnd": "16.0.1", "react-dnd-html5-backend": "16.0.1", "react-dom": "18.3.1", - "react-modal": "3.16.1", "react-redux": "9.1.2", "redux": "5.0.1", "typescript": "5.6.2", diff --git a/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutoComplete.tsx b/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutoComplete.tsx index 897713794d3..6fc0dd4d643 100644 --- a/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutoComplete.tsx +++ b/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutoComplete.tsx @@ -2,9 +2,9 @@ import type { ChangeEvent } from 'react'; import React, { useEffect, useMemo, useState } from 'react'; import type { IGenericEditComponent } from '../componentConfig'; import { stringToArray, arrayToString } from '../../../utils/stringUtils'; -import { replaceLastItem } from 'app-shared/utils/arrayUtils'; import { FormField } from '../../FormField'; import { StudioButton, StudioPopover, StudioTextfield } from '@studio/components'; +import { ArrayUtils } from '@studio/pure-functions'; const getLastWord = (value: string) => value.split(' ').pop(); const stdAutocompleteOpts = [ @@ -79,7 +79,7 @@ export const EditAutoComplete = ({ component, handleComponentChange }: IGenericE const buildNewText = (word: string): string => { const wordParts = stringToArray(autocompleteText, ' '); - const newWordParts = replaceLastItem(wordParts, word); + const newWordParts = ArrayUtils.replaceLastItem(wordParts, word); return arrayToString(newWordParts); }; diff --git a/frontend/packages/ux-editor-v3/src/components/config/editModal/EditDataModelBindings.tsx b/frontend/packages/ux-editor-v3/src/components/config/editModal/EditDataModelBindings.tsx index 94069b70a3e..61d0b11c481 100644 --- a/frontend/packages/ux-editor-v3/src/components/config/editModal/EditDataModelBindings.tsx +++ b/frontend/packages/ux-editor-v3/src/components/config/editModal/EditDataModelBindings.tsx @@ -9,7 +9,7 @@ import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmen import { LinkIcon } from '@studio/icons'; import { StudioButton } from '@studio/components'; import classes from './EditDataModelBindings.module.css'; -import { InputActionWrapper } from 'app-shared/components/InputActionWrapper'; +import { InputActionWrapper } from './InputActionWrapper'; import { useAppContext } from '../../../hooks/useAppContext'; export interface EditDataModelBindingsProps extends IGenericEditComponent { diff --git a/frontend/packages/shared/src/components/InputActionWrapper/InputActionWrapper.module.css b/frontend/packages/ux-editor-v3/src/components/config/editModal/InputActionWrapper/InputActionWrapper.module.css similarity index 100% rename from frontend/packages/shared/src/components/InputActionWrapper/InputActionWrapper.module.css rename to frontend/packages/ux-editor-v3/src/components/config/editModal/InputActionWrapper/InputActionWrapper.module.css diff --git a/frontend/packages/shared/src/components/InputActionWrapper/InputActionWrapper.test.tsx b/frontend/packages/ux-editor-v3/src/components/config/editModal/InputActionWrapper/InputActionWrapper.test.tsx similarity index 100% rename from frontend/packages/shared/src/components/InputActionWrapper/InputActionWrapper.test.tsx rename to frontend/packages/ux-editor-v3/src/components/config/editModal/InputActionWrapper/InputActionWrapper.test.tsx diff --git a/frontend/packages/shared/src/components/InputActionWrapper/InputActionWrapper.tsx b/frontend/packages/ux-editor-v3/src/components/config/editModal/InputActionWrapper/InputActionWrapper.tsx similarity index 100% rename from frontend/packages/shared/src/components/InputActionWrapper/InputActionWrapper.tsx rename to frontend/packages/ux-editor-v3/src/components/config/editModal/InputActionWrapper/InputActionWrapper.tsx diff --git a/frontend/packages/shared/src/components/InputActionWrapper/index.ts b/frontend/packages/ux-editor-v3/src/components/config/editModal/InputActionWrapper/index.ts similarity index 100% rename from frontend/packages/shared/src/components/InputActionWrapper/index.ts rename to frontend/packages/ux-editor-v3/src/components/config/editModal/InputActionWrapper/index.ts diff --git a/frontend/packages/ux-editor-v3/src/hooks/useValidateComponent.ts b/frontend/packages/ux-editor-v3/src/hooks/useValidateComponent.ts index 627551f3c3e..48ee1aa6dc7 100644 --- a/frontend/packages/ux-editor-v3/src/hooks/useValidateComponent.ts +++ b/frontend/packages/ux-editor-v3/src/hooks/useValidateComponent.ts @@ -1,4 +1,4 @@ -import { areItemsUnique } from 'app-shared/utils/arrayUtils'; +import { ArrayUtils } from '@studio/pure-functions'; import { ComponentTypeV3 } from 'app-shared/types/ComponentTypeV3'; import type { FormCheckboxesComponent, @@ -36,7 +36,7 @@ const validateOptionGroup = ( isValid: false, error: ErrorCode.NoOptions, }; - } else if (!areItemsUnique(component.options.map((option) => option.value))) { + } else if (!ArrayUtils.areItemsUnique(component.options.map((option) => option.value))) { return { isValid: false, error: ErrorCode.DuplicateValues, diff --git a/frontend/packages/ux-editor-v3/src/utils/formLayoutUtils.ts b/frontend/packages/ux-editor-v3/src/utils/formLayoutUtils.ts index 9515d411613..cbd8ec08f11 100644 --- a/frontend/packages/ux-editor-v3/src/utils/formLayoutUtils.ts +++ b/frontend/packages/ux-editor-v3/src/utils/formLayoutUtils.ts @@ -7,7 +7,6 @@ import type { IToolbarElement, } from '../types/global'; import { BASE_CONTAINER_ID, MAX_NESTED_GROUP_LEVEL } from 'app-shared/constants'; -import { insertArrayElementAtPos } from 'app-shared/utils/arrayUtils'; import { ArrayUtils, ObjectUtils } from '@studio/pure-functions'; import { ComponentTypeV3 } from 'app-shared/types/ComponentTypeV3'; import type { FormComponent } from '../types/FormComponent'; @@ -301,7 +300,7 @@ export const moveLayoutItem = ( newLayout.order[oldContainerId], id, ); - newLayout.order[newContainerId] = insertArrayElementAtPos( + newLayout.order[newContainerId] = ArrayUtils.insertArrayElementAtPos( newLayout.order[newContainerId], id, newPosition, diff --git a/frontend/packages/ux-editor/package.json b/frontend/packages/ux-editor/package.json index 0456d883558..6ad917fc51e 100644 --- a/frontend/packages/ux-editor/package.json +++ b/frontend/packages/ux-editor/package.json @@ -10,7 +10,6 @@ "react-dnd": "16.0.1", "react-dnd-html5-backend": "16.0.1", "react-dom": "18.3.1", - "react-modal": "3.16.1", "typescript": "5.6.2", "uuid": "10.0.0" }, diff --git a/frontend/packages/ux-editor/src/classes/Subformutils.test.ts b/frontend/packages/ux-editor/src/classes/SubformUtils.test.ts similarity index 62% rename from frontend/packages/ux-editor/src/classes/Subformutils.test.ts rename to frontend/packages/ux-editor/src/classes/SubformUtils.test.ts index a9e33d4dbca..83e83b8580b 100644 --- a/frontend/packages/ux-editor/src/classes/Subformutils.test.ts +++ b/frontend/packages/ux-editor/src/classes/SubformUtils.test.ts @@ -1,26 +1,26 @@ -import { SubFormUtilsImpl } from './SubFormUtils'; +import { SubformUtilsImpl } from './SubformUtils'; import type { LayoutSetConfig } from 'app-shared/types/api/LayoutSetsResponse'; -describe('SubFormUtilsImpl', () => { +describe('SubformUtilsImpl', () => { describe('hasSubform', () => { it('should return false for hasSubforms when there are no subform layout sets', () => { const layoutSets: Array = [{ id: '1' }]; - const subFormUtils = new SubFormUtilsImpl(layoutSets); - expect(subFormUtils.hasSubforms).toBe(false); + const subformUtils = new SubformUtilsImpl(layoutSets); + expect(subformUtils.hasSubforms).toBe(false); }); it('should return true for hasSubforms when there are subform layout sets', () => { const layoutSets: Array = [{ id: '1', type: 'subform' }]; - const subFormUtils = new SubFormUtilsImpl(layoutSets); - expect(subFormUtils.hasSubforms).toBe(true); + const subformUtils = new SubformUtilsImpl(layoutSets); + expect(subformUtils.hasSubforms).toBe(true); }); }); describe('subformLayoutSetsIds', () => { it('should return an empty array for subformLayoutSetsIds when there are no subform layout sets', () => { const layoutSets: Array = [{ id: '1' }]; - const subFormUtils = new SubFormUtilsImpl(layoutSets); - expect(subFormUtils.subformLayoutSetsIds).toEqual([]); + const subformUtils = new SubformUtilsImpl(layoutSets); + expect(subformUtils.subformLayoutSetsIds).toEqual([]); }); it('should return the correct subform layout set IDs', () => { @@ -29,8 +29,8 @@ describe('SubFormUtilsImpl', () => { { id: '2' }, { id: '3', type: 'subform' }, ]; - const subFormUtils = new SubFormUtilsImpl(layoutSets); - expect(subFormUtils.subformLayoutSetsIds).toEqual(['1', '3']); + const subformUtils = new SubformUtilsImpl(layoutSets); + expect(subformUtils.subformLayoutSetsIds).toEqual(['1', '3']); }); }); }); diff --git a/frontend/packages/ux-editor/src/classes/SubFormUtils.ts b/frontend/packages/ux-editor/src/classes/SubformUtils.ts similarity index 63% rename from frontend/packages/ux-editor/src/classes/SubFormUtils.ts rename to frontend/packages/ux-editor/src/classes/SubformUtils.ts index 1cda8bd7507..ebd270e2825 100644 --- a/frontend/packages/ux-editor/src/classes/SubFormUtils.ts +++ b/frontend/packages/ux-editor/src/classes/SubformUtils.ts @@ -1,15 +1,15 @@ import type { LayoutSetConfig } from 'app-shared/types/api/LayoutSetsResponse'; -type SubFormLayoutSet = LayoutSetConfig & { +type SubformLayoutSet = LayoutSetConfig & { type: 'subform'; }; -interface SubFormUtils { +interface SubformUtils { hasSubforms: boolean; subformLayoutSetsIds: Array; } -export class SubFormUtilsImpl implements SubFormUtils { +export class SubformUtilsImpl implements SubformUtils { constructor(private readonly layoutSets: Array) {} public get hasSubforms(): boolean { @@ -17,12 +17,12 @@ export class SubFormUtilsImpl implements SubFormUtils { } public get subformLayoutSetsIds(): Array { - return this.getSubformLayoutSets.map((set: SubFormLayoutSet) => set.id); + return this.getSubformLayoutSets.map((set: SubformLayoutSet) => set.id); } - private get getSubformLayoutSets(): Array { + private get getSubformLayoutSets(): Array { return (this.layoutSets || []).filter( (set) => set.type === 'subform', - ) as Array; + ) as Array; } } diff --git a/frontend/packages/ux-editor/src/components/Elements/LayoutSetsContainer.tsx b/frontend/packages/ux-editor/src/components/Elements/LayoutSetsContainer.tsx index ded81d7fc39..d9faf1b9839 100644 --- a/frontend/packages/ux-editor/src/components/Elements/LayoutSetsContainer.tsx +++ b/frontend/packages/ux-editor/src/components/Elements/LayoutSetsContainer.tsx @@ -5,7 +5,7 @@ import { useText, useAppContext } from '../../hooks'; import classes from './LayoutSetsContainer.module.css'; import { ExportForm } from './ExportForm'; import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; -import { SubFormWrapper } from './SubForm/SubFormWrapper'; +import { SubformWrapper } from './Subform/SubformWrapper'; import { StudioCombobox } from '@studio/components'; export function LayoutSetsContainer() { @@ -59,9 +59,9 @@ export function LayoutSetsContainer() { {shouldDisplayFeature('exportForm') && } {shouldDisplayFeature('subform') && ( - )} diff --git a/frontend/packages/ux-editor/src/components/Elements/SubForm/SubFormWrapper.tsx b/frontend/packages/ux-editor/src/components/Elements/SubForm/SubFormWrapper.tsx deleted file mode 100644 index a94beb2b1a3..00000000000 --- a/frontend/packages/ux-editor/src/components/Elements/SubForm/SubFormWrapper.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { LayoutSets } from 'app-shared/types/api/LayoutSetsResponse'; -import React from 'react'; -import { CreateSubFormWrapper } from './CreateSubFormWrapper'; -import { DeleteSubFormWrapper } from './DeleteSubFormWrapper'; - -type SubFormWrapperProps = { - layoutSets: LayoutSets; - onSubFormCreated: (layoutSetName: string) => void; - selectedLayoutSet: string; -}; - -export const SubFormWrapper = ({ - layoutSets, - onSubFormCreated, - selectedLayoutSet, -}: SubFormWrapperProps): React.ReactElement => { - return ( -
- - -
- ); -}; diff --git a/frontend/packages/ux-editor/src/components/Elements/SubForm/CreateSubFormWrapper.module.css b/frontend/packages/ux-editor/src/components/Elements/Subform/CreateSubformWrapper.module.css similarity index 100% rename from frontend/packages/ux-editor/src/components/Elements/SubForm/CreateSubFormWrapper.module.css rename to frontend/packages/ux-editor/src/components/Elements/Subform/CreateSubformWrapper.module.css diff --git a/frontend/packages/ux-editor/src/components/Elements/SubForm/CreateSubFormWrapper.test.tsx b/frontend/packages/ux-editor/src/components/Elements/Subform/CreateSubformWrapper.test.tsx similarity index 60% rename from frontend/packages/ux-editor/src/components/Elements/SubForm/CreateSubFormWrapper.test.tsx rename to frontend/packages/ux-editor/src/components/Elements/Subform/CreateSubformWrapper.test.tsx index 6d9e4f93d1c..79568f610c4 100644 --- a/frontend/packages/ux-editor/src/components/Elements/SubForm/CreateSubFormWrapper.test.tsx +++ b/frontend/packages/ux-editor/src/components/Elements/Subform/CreateSubformWrapper.test.tsx @@ -2,55 +2,55 @@ import React from 'react'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../../../testing/mocks'; -import { CreateSubFormWrapper } from './CreateSubFormWrapper'; +import { CreateSubformWrapper } from './CreateSubformWrapper'; import { textMock } from '@studio/testing/mocks/i18nMock'; import { app, org } from '@studio/testing/testids'; import { layoutSetsMock, layoutSet1NameMock } from '@altinn/ux-editor/testing/layoutSetsMock'; import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext'; -const subFormName = 'underskjema'; +const subformName = 'underskjema'; -describe('CreateSubFormWrapper', () => { +describe('CreateSubformWrapper', () => { it('should open dialog when clicking "create subform" button', async () => { const user = userEvent.setup(); - renderCreateSubFormWrapper(); + renderCreateSubformWrapper(); - const createSubFormButton = screen.getByRole('button', { + const createSubformButton = screen.getByRole('button', { name: textMock('ux_editor.create.subform'), }); - await user.click(createSubFormButton); + await user.click(createSubformButton); expect(await screen.findByRole('dialog')).toBeInTheDocument(); }); - it('should call onSubFormCreated when subform is created', async () => { + it('should call onSubformCreated when subform is created', async () => { const user = userEvent.setup(); - const onSubFormCreated = jest.fn(); - renderCreateSubFormWrapper(onSubFormCreated); + const onSubformCreated = jest.fn(); + renderCreateSubformWrapper(onSubformCreated); - const createSubFormButton = screen.getByRole('button', { + const createSubformButton = screen.getByRole('button', { name: textMock('ux_editor.create.subform'), }); - await user.click(createSubFormButton); + await user.click(createSubformButton); const input = screen.getByRole('textbox'); - await user.type(input, subFormName); + await user.type(input, subformName); const confirmButton = screen.getByRole('button', { name: textMock('ux_editor.create.subform.confirm_button'), }); await user.click(confirmButton); - expect(onSubFormCreated).toHaveBeenCalledWith(subFormName); + expect(onSubformCreated).toHaveBeenCalledWith(subformName); }); it('should disable confirm button when name already exist', async () => { const user = userEvent.setup(); - renderCreateSubFormWrapper(); + renderCreateSubformWrapper(); - const createSubFormButton = screen.getByRole('button', { + const createSubformButton = screen.getByRole('button', { name: textMock('ux_editor.create.subform'), }); - await user.click(createSubFormButton); + await user.click(createSubformButton); const input = screen.getByRole('textbox'); await user.type(input, layoutSet1NameMock); @@ -62,36 +62,36 @@ describe('CreateSubFormWrapper', () => { }); it('should add subform when name is valid', async () => { - const onSubFormCreatedMock = jest.fn(); + const onSubformCreatedMock = jest.fn(); const user = userEvent.setup(); const addLayoutSet = jest.fn(); - renderCreateSubFormWrapper(onSubFormCreatedMock, { addLayoutSet }); + renderCreateSubformWrapper(onSubformCreatedMock, { addLayoutSet }); - const createSubFormButton = screen.getByRole('button', { + const createSubformButton = screen.getByRole('button', { name: textMock('ux_editor.create.subform'), }); - await user.click(createSubFormButton); + await user.click(createSubformButton); const input = screen.getByRole('textbox'); - await user.type(input, subFormName); + await user.type(input, subformName); const confirmButton = screen.getByRole('button', { name: textMock('ux_editor.create.subform.confirm_button'), }); await user.click(confirmButton); - expect(addLayoutSet).toHaveBeenCalledWith(org, app, subFormName, { - layoutSetConfig: { id: subFormName, type: 'subform' }, + expect(addLayoutSet).toHaveBeenCalledWith(org, app, subformName, { + layoutSetConfig: { id: subformName, type: 'subform' }, }); }); }); -const renderCreateSubFormWrapper = ( - onSubFormCreated?: jest.Mock, +const renderCreateSubformWrapper = ( + onSubformCreated?: jest.Mock, queries: Partial = {}, ) => { return renderWithProviders( - , + , { queries }, ); }; diff --git a/frontend/packages/ux-editor/src/components/Elements/SubForm/CreateSubFormWrapper.tsx b/frontend/packages/ux-editor/src/components/Elements/Subform/CreateSubformWrapper.tsx similarity index 74% rename from frontend/packages/ux-editor/src/components/Elements/SubForm/CreateSubFormWrapper.tsx rename to frontend/packages/ux-editor/src/components/Elements/Subform/CreateSubformWrapper.tsx index 75f7d2b12a9..0ce957979c2 100644 --- a/frontend/packages/ux-editor/src/components/Elements/SubForm/CreateSubFormWrapper.tsx +++ b/frontend/packages/ux-editor/src/components/Elements/Subform/CreateSubformWrapper.tsx @@ -6,19 +6,19 @@ import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmen import { useTranslation } from 'react-i18next'; import { useValidateLayoutSetName } from 'app-shared/hooks/useValidateLayoutSetName'; import type { LayoutSets } from 'app-shared/types/api/LayoutSetsResponse'; -import classes from './CreateSubFormWrapper.module.css'; +import classes from './CreateSubformWrapper.module.css'; -type CreateSubFormWrapperProps = { +type CreateSubformWrapperProps = { layoutSets: LayoutSets; - onSubFormCreated: (layoutSetName: string) => void; + onSubformCreated: (layoutSetName: string) => void; }; -export const CreateSubFormWrapper = ({ +export const CreateSubformWrapper = ({ layoutSets, - onSubFormCreated, -}: CreateSubFormWrapperProps) => { + onSubformCreated, +}: CreateSubformWrapperProps) => { const [createNewOpen, setCreateNewOpen] = useState(false); - const [newSubFormName, setNewSubFormName] = useState(''); + const [newSubformName, setNewSubformName] = useState(''); const [nameError, setNameError] = useState(''); const { t } = useTranslation(); const { validateLayoutSetName } = useValidateLayoutSetName(); @@ -29,19 +29,19 @@ export const CreateSubFormWrapper = ({ setCreateNewOpen(false); addLayoutSet({ - layoutSetIdToUpdate: newSubFormName, + layoutSetIdToUpdate: newSubformName, layoutSetConfig: { - id: newSubFormName, + id: newSubformName, type: 'subform', }, }); - onSubFormCreated(newSubFormName); + onSubformCreated(newSubformName); }; - const onNameChange = (subFormName: string) => { - const subFormNameValidation = validateLayoutSetName(subFormName, layoutSets); - setNameError(subFormNameValidation); - setNewSubFormName(subFormName); + const onNameChange = (subformName: string) => { + const subformNameValidation = validateLayoutSetName(subformName, layoutSets); + setNameError(subformNameValidation); + setNewSubformName(subformName); }; return ( @@ -59,7 +59,7 @@ export const CreateSubFormWrapper = ({ onNameChange(e.target.value)} error={nameError} /> @@ -67,7 +67,7 @@ export const CreateSubFormWrapper = ({ className={classes.confirmCreateButton} variant='secondary' onClick={onCreateConfirmClick} - disabled={!newSubFormName || !!nameError} + disabled={!newSubformName || !!nameError} > {t('ux_editor.create.subform.confirm_button')} diff --git a/frontend/packages/ux-editor/src/components/Elements/SubForm/DeleteSubFormWrapper.test.tsx b/frontend/packages/ux-editor/src/components/Elements/Subform/DeleteSubformWrapper.test.tsx similarity index 65% rename from frontend/packages/ux-editor/src/components/Elements/SubForm/DeleteSubFormWrapper.test.tsx rename to frontend/packages/ux-editor/src/components/Elements/Subform/DeleteSubformWrapper.test.tsx index 7a753ea8fe7..3fc14c032af 100644 --- a/frontend/packages/ux-editor/src/components/Elements/SubForm/DeleteSubFormWrapper.test.tsx +++ b/frontend/packages/ux-editor/src/components/Elements/Subform/DeleteSubformWrapper.test.tsx @@ -1,46 +1,46 @@ import React from 'react'; -import { DeleteSubFormWrapper } from './DeleteSubFormWrapper'; +import { DeleteSubformWrapper } from './DeleteSubformWrapper'; import userEvent from '@testing-library/user-event'; import { screen } from '@testing-library/react'; import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext'; import { layoutSetsMock, layoutSet1NameMock, - layoutSet3SubFormNameMock, + layoutSet3SubformNameMock, } from '../../../testing/layoutSetsMock'; import { renderWithProviders } from '../../../testing/mocks'; import { textMock } from '@studio/testing/mocks/i18nMock'; import { app, org } from '@studio/testing/testids'; -describe('DeleteSubFormWrapper', () => { +describe('DeleteSubformWrapper', () => { it('should disable delete button when selected layoutset is not a subform', () => { - renderDeleteSubFormWrapper(layoutSet1NameMock); + renderDeleteSubformWrapper(layoutSet1NameMock); - const deleteSubFormButton = screen.getByRole('button', { + const deleteSubformButton = screen.getByRole('button', { name: textMock('ux_editor.delete.subform'), }); - expect(deleteSubFormButton).toBeDisabled(); + expect(deleteSubformButton).toBeDisabled(); }); it('should enable delete button when selected layoutset is a subform', () => { - renderDeleteSubFormWrapper(layoutSet3SubFormNameMock); + renderDeleteSubformWrapper(layoutSet3SubformNameMock); - const deleteSubFormButton = screen.getByRole('button', { + const deleteSubformButton = screen.getByRole('button', { name: textMock('ux_editor.delete.subform'), }); - expect(deleteSubFormButton).toBeEnabled(); + expect(deleteSubformButton).toBeEnabled(); }); it('should not call deleteLayoutSet when delete button is clicked but not confirmed', async () => { jest.spyOn(window, 'confirm').mockImplementation(() => false); const deleteLayoutSet = jest.fn(); const user = userEvent.setup(); - renderDeleteSubFormWrapper(layoutSet3SubFormNameMock, { deleteLayoutSet }); + renderDeleteSubformWrapper(layoutSet3SubformNameMock, { deleteLayoutSet }); - const deleteSubFormButton = screen.getByRole('button', { + const deleteSubformButton = screen.getByRole('button', { name: textMock('ux_editor.delete.subform'), }); - await user.click(deleteSubFormButton); + await user.click(deleteSubformButton); expect(deleteLayoutSet).not.toHaveBeenCalled(); }); @@ -49,24 +49,24 @@ describe('DeleteSubFormWrapper', () => { jest.spyOn(window, 'confirm').mockImplementation(() => true); const deleteLayoutSet = jest.fn(); const user = userEvent.setup(); - renderDeleteSubFormWrapper(layoutSet3SubFormNameMock, { deleteLayoutSet }); + renderDeleteSubformWrapper(layoutSet3SubformNameMock, { deleteLayoutSet }); - const deleteSubFormButton = screen.getByRole('button', { + const deleteSubformButton = screen.getByRole('button', { name: textMock('ux_editor.delete.subform'), }); - await user.click(deleteSubFormButton); + await user.click(deleteSubformButton); expect(deleteLayoutSet).toHaveBeenCalled(); - expect(deleteLayoutSet).toHaveBeenCalledWith(org, app, layoutSet3SubFormNameMock); + expect(deleteLayoutSet).toHaveBeenCalledWith(org, app, layoutSet3SubformNameMock); }); }); -const renderDeleteSubFormWrapper = ( +const renderDeleteSubformWrapper = ( selectedLayoutSet: string, queries: Partial = {}, ) => { return renderWithProviders( - , + , { queries }, ); }; diff --git a/frontend/packages/ux-editor/src/components/Elements/SubForm/DeleteSubFormWrapper.tsx b/frontend/packages/ux-editor/src/components/Elements/Subform/DeleteSubformWrapper.tsx similarity index 78% rename from frontend/packages/ux-editor/src/components/Elements/SubForm/DeleteSubFormWrapper.tsx rename to frontend/packages/ux-editor/src/components/Elements/Subform/DeleteSubformWrapper.tsx index de1c315903a..3f771ed3c88 100644 --- a/frontend/packages/ux-editor/src/components/Elements/SubForm/DeleteSubFormWrapper.tsx +++ b/frontend/packages/ux-editor/src/components/Elements/Subform/DeleteSubformWrapper.tsx @@ -4,32 +4,32 @@ import { useDeleteLayoutSetMutation } from 'app-development/hooks/mutations/useD import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; import type { LayoutSets } from 'app-shared/types/api/LayoutSetsResponse'; import { useTranslation } from 'react-i18next'; -import { SubFormUtils } from './SubFormUtils'; +import { SubformUtils } from './SubformUtils'; -type DeleteSubFormWrapperProps = { +type DeleteSubformWrapperProps = { layoutSets: LayoutSets; selectedLayoutSet: string; }; -export const DeleteSubFormWrapper = ({ +export const DeleteSubformWrapper = ({ layoutSets, selectedLayoutSet, -}: DeleteSubFormWrapperProps): React.ReactElement => { +}: DeleteSubformWrapperProps): React.ReactElement => { const { org, app } = useStudioEnvironmentParams(); const { mutate: deleteLayoutSet } = useDeleteLayoutSetMutation(org, app); const { t } = useTranslation(); - const onDeleteSubForm = () => { + const onDeleteSubform = () => { deleteLayoutSet({ layoutSetIdToUpdate: selectedLayoutSet }); }; const isRegularLayoutSet = !Boolean( - SubFormUtils.findSubFormById(layoutSets.sets, selectedLayoutSet), + SubformUtils.findSubformById(layoutSets.sets, selectedLayoutSet), ); return ( { - describe('findSubFormById', () => { +describe('SubformUtils', () => { + describe('findSubformById', () => { const layoutSets: Array = [{ id: '1' }, { id: '2', type: 'subform' }, { id: '3' }]; it('should return the layout set when it is a subform', () => { - const result = SubFormUtils.findSubFormById(layoutSets, '2'); + const result = SubformUtils.findSubformById(layoutSets, '2'); expect(result).toEqual({ id: '2', type: 'subform' }); }); it('should return null when the layout set is not a subform', () => { - const result = SubFormUtils.findSubFormById(layoutSets, '1'); + const result = SubformUtils.findSubformById(layoutSets, '1'); expect(result).toBeNull(); }); it('should return null when the layout set is not found', () => { - const result = SubFormUtils.findSubFormById(layoutSets, 'non-existent-id'); + const result = SubformUtils.findSubformById(layoutSets, 'non-existent-id'); expect(result).toBeNull(); }); }); diff --git a/frontend/packages/ux-editor/src/components/Elements/SubForm/SubFormUtils.ts b/frontend/packages/ux-editor/src/components/Elements/Subform/SubformUtils.ts similarity index 67% rename from frontend/packages/ux-editor/src/components/Elements/SubForm/SubFormUtils.ts rename to frontend/packages/ux-editor/src/components/Elements/Subform/SubformUtils.ts index bc2670805f8..c7af57e8a90 100644 --- a/frontend/packages/ux-editor/src/components/Elements/SubForm/SubFormUtils.ts +++ b/frontend/packages/ux-editor/src/components/Elements/Subform/SubformUtils.ts @@ -2,8 +2,8 @@ import type { LayoutSet } from 'app-shared/types/api/LayoutSetsResponse'; const SUBFORM_IDENTIFIER = 'subform'; -export class SubFormUtils { - public static findSubFormById( +export class SubformUtils { + public static findSubformById( layoutSets: Array, layoutSetId: string, ): LayoutSet | null { @@ -11,10 +11,10 @@ export class SubFormUtils { if (!foundLayoutSet) return null; - return SubFormUtils.isLayoutSetSubForm(foundLayoutSet) ? foundLayoutSet : null; + return SubformUtils.isLayoutSetSubform(foundLayoutSet) ? foundLayoutSet : null; } - private static isLayoutSetSubForm(layoutSet: LayoutSet): boolean { + private static isLayoutSetSubform(layoutSet: LayoutSet): boolean { return layoutSet.type === SUBFORM_IDENTIFIER; } } diff --git a/frontend/packages/ux-editor/src/components/Elements/Subform/SubformWrapper.tsx b/frontend/packages/ux-editor/src/components/Elements/Subform/SubformWrapper.tsx new file mode 100644 index 00000000000..1654affec3a --- /dev/null +++ b/frontend/packages/ux-editor/src/components/Elements/Subform/SubformWrapper.tsx @@ -0,0 +1,23 @@ +import type { LayoutSets } from 'app-shared/types/api/LayoutSetsResponse'; +import React from 'react'; +import { CreateSubformWrapper } from './CreateSubformWrapper'; +import { DeleteSubformWrapper } from './DeleteSubformWrapper'; + +type SubformWrapperProps = { + layoutSets: LayoutSets; + onSubformCreated: (layoutSetName: string) => void; + selectedLayoutSet: string; +}; + +export const SubformWrapper = ({ + layoutSets, + onSubformCreated, + selectedLayoutSet, +}: SubformWrapperProps): React.ReactElement => { + return ( +
+ + +
+ ); +}; diff --git a/frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/index.ts b/frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/index.ts deleted file mode 100644 index 4c9bc5c96da..00000000000 --- a/frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { EditSubFormTableColumns } from './EditSubFormTableColumns'; diff --git a/frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/ColumnElement/ColumnElement.module.css b/frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/ColumnElement/ColumnElement.module.css similarity index 100% rename from frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/ColumnElement/ColumnElement.module.css rename to frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/ColumnElement/ColumnElement.module.css diff --git a/frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/ColumnElement/ColumnElement.test.tsx b/frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/ColumnElement/ColumnElement.test.tsx similarity index 100% rename from frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/ColumnElement/ColumnElement.test.tsx rename to frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/ColumnElement/ColumnElement.test.tsx diff --git a/frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/ColumnElement/ColumnElement.tsx b/frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/ColumnElement/ColumnElement.tsx similarity index 100% rename from frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/ColumnElement/ColumnElement.tsx rename to frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/ColumnElement/ColumnElement.tsx diff --git a/frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/ColumnElement/index.ts b/frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/ColumnElement/index.ts similarity index 100% rename from frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/ColumnElement/index.ts rename to frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/ColumnElement/index.ts diff --git a/frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/EditSubFormTableColumns.module.css b/frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/EditSubformTableColumns.module.css similarity index 100% rename from frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/EditSubFormTableColumns.module.css rename to frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/EditSubformTableColumns.module.css diff --git a/frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/EditSubFormTableColumns.test.tsx b/frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/EditSubformTableColumns.test.tsx similarity index 84% rename from frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/EditSubFormTableColumns.test.tsx rename to frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/EditSubformTableColumns.test.tsx index 59928aaf55f..63729c5cb74 100644 --- a/frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/EditSubFormTableColumns.test.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/EditSubformTableColumns.test.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { screen } from '@testing-library/react'; import { - EditSubFormTableColumns, - type EditSubFormTableColumnsProps, -} from './EditSubFormTableColumns'; + EditSubformTableColumns, + type EditSubformTableColumnsProps, +} from './EditSubformTableColumns'; import { textMock } from '@studio/testing/mocks/i18nMock'; import { renderWithProviders } from 'dashboard/testing/mocks'; import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; @@ -12,14 +12,14 @@ import userEvent from '@testing-library/user-event'; import { ComponentType } from 'app-shared/types/ComponentType'; import { componentMocks } from '@altinn/ux-editor/testing/componentMocks'; -const subFormComponentMock = componentMocks[ComponentType.SubForm]; +const subformComponentMock = componentMocks[ComponentType.Subform]; -const defaultProps: EditSubFormTableColumnsProps = { - component: subFormComponentMock, +const defaultProps: EditSubformTableColumnsProps = { + component: subformComponentMock, handleComponentChange: jest.fn(), }; -describe('EditSubFormTableColumns', () => { +describe('EditSubformTableColumns', () => { afterEach(() => { jest.clearAllMocks(); }); @@ -28,8 +28,8 @@ describe('EditSubFormTableColumns', () => { const handleComponentChangeMock = jest.fn(); const user = userEvent.setup(); - renderEditSubFormTableColumns({ - component: { ...subFormComponentMock, tableColumns: undefined }, + renderEditSubformTableColumns({ + component: { ...subformComponentMock, tableColumns: undefined }, handleComponentChange: handleComponentChangeMock, }); @@ -48,7 +48,7 @@ describe('EditSubFormTableColumns', () => { const handleComponentChangeMock = jest.fn(); const user = userEvent.setup(); - renderEditSubFormTableColumns({ + renderEditSubformTableColumns({ handleComponentChange: handleComponentChangeMock, }); @@ -67,12 +67,12 @@ describe('EditSubFormTableColumns', () => { const handleComponentChangeMock = jest.fn(); const user = userEvent.setup(); - renderEditSubFormTableColumns({ + renderEditSubformTableColumns({ handleComponentChange: handleComponentChangeMock, }); const headerInputbutton = screen.getByRole('button', { - name: `${textMock('ux_editor.properties_panel.subform_table_columns.header_content_label')}: ${subFormComponentMock.tableColumns[0].headerContent}`, + name: `${textMock('ux_editor.properties_panel.subform_table_columns.header_content_label')}: ${subformComponentMock.tableColumns[0].headerContent}`, }); await user.click(headerInputbutton); @@ -95,7 +95,7 @@ describe('EditSubFormTableColumns', () => { const handleComponentChangeMock = jest.fn(); const user = userEvent.setup(); - renderEditSubFormTableColumns({ + renderEditSubformTableColumns({ handleComponentChange: handleComponentChangeMock, }); @@ -113,9 +113,9 @@ describe('EditSubFormTableColumns', () => { }); }); -const renderEditSubFormTableColumns = (props: Partial = {}) => { +const renderEditSubformTableColumns = (props: Partial = {}) => { const queryClient = createQueryClientMock(); - return renderWithProviders(, { + return renderWithProviders(, { ...queriesMock, queryClient, }); diff --git a/frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/EditSubFormTableColumns.tsx b/frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/EditSubformTableColumns.tsx similarity index 90% rename from frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/EditSubFormTableColumns.tsx rename to frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/EditSubformTableColumns.tsx index 1e6f6cd718c..bfbcb7e4eba 100644 --- a/frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/EditSubFormTableColumns.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/EditSubformTableColumns.tsx @@ -1,5 +1,5 @@ import React, { type ReactElement } from 'react'; -import classes from './EditSubFormTableColumns.module.css'; +import classes from './EditSubformTableColumns.module.css'; import { StudioButton, StudioHeading } from '@studio/components'; import { useTranslation } from 'react-i18next'; import { type IGenericEditComponent } from '../../config/componentConfig'; @@ -9,12 +9,12 @@ import { filterOutTableColumn, updateComponentWithSubform } from './utils'; import { useUniqueKeys } from '@studio/hooks'; import { ColumnElement } from './ColumnElement'; -export type EditSubFormTableColumnsProps = IGenericEditComponent; +export type EditSubformTableColumnsProps = IGenericEditComponent; -export const EditSubFormTableColumns = ({ +export const EditSubformTableColumns = ({ component, handleComponentChange, -}: EditSubFormTableColumnsProps): ReactElement => { +}: EditSubformTableColumnsProps): ReactElement => { const { t } = useTranslation(); const tableColumns: TableColumn[] = component?.tableColumns ?? []; diff --git a/frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/index.ts b/frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/index.ts new file mode 100644 index 00000000000..cef4ebcc7d6 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/index.ts @@ -0,0 +1 @@ +export { EditSubformTableColumns } from './EditSubformTableColumns'; diff --git a/frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/types/TableColumn.ts b/frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/types/TableColumn.ts similarity index 100% rename from frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/types/TableColumn.ts rename to frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/types/TableColumn.ts diff --git a/frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/utils/editSubFormTableColumnsUtils.test.ts b/frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/utils/editSubformTableColumnsUtils.test.ts similarity index 85% rename from frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/utils/editSubFormTableColumnsUtils.test.ts rename to frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/utils/editSubformTableColumnsUtils.test.ts index 11ef2891b3e..95211b1a7c5 100644 --- a/frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/utils/editSubFormTableColumnsUtils.test.ts +++ b/frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/utils/editSubformTableColumnsUtils.test.ts @@ -1,4 +1,4 @@ -import { updateComponentWithSubform, filterOutTableColumn } from './editSubFormTableColumnsUtils'; +import { updateComponentWithSubform, filterOutTableColumn } from './editSubformTableColumnsUtils'; import { type FormItem } from '@altinn/ux-editor/types/FormItem'; import { ComponentType } from 'app-shared/types/ComponentType'; import { type TableColumn } from '../types/TableColumn'; @@ -18,25 +18,25 @@ const mockTableColumn3: TableColumn = { cellContent: { query: 'query 3', default: 'default 3' }, }; -const subFormComponentMock = componentMocks[ComponentType.SubForm]; +const subformComponentMock = componentMocks[ComponentType.Subform]; -describe('editSubFormTableColumnsUtils', () => { +describe('editSubformTableColumnsUtils', () => { describe('updateComponentWithSubform', () => { it('should add table columns to the component', () => { const tableColumnsToAdd = [mockTableColumn2, mockTableColumn3]; - const updatedComponent = updateComponentWithSubform(subFormComponentMock, tableColumnsToAdd); + const updatedComponent = updateComponentWithSubform(subformComponentMock, tableColumnsToAdd); expect(updatedComponent.tableColumns).toEqual([ - subFormComponentMock.tableColumns[0], + subformComponentMock.tableColumns[0], mockTableColumn2, mockTableColumn3, ]); }); it('should handle case where the component has no initial tableColumns', () => { - const componentWithoutColumns: FormItem = { - ...subFormComponentMock, + const componentWithoutColumns: FormItem = { + ...subformComponentMock, tableColumns: undefined, }; @@ -51,9 +51,9 @@ describe('editSubFormTableColumnsUtils', () => { }); it('should return the same component if tableColumnsToAdd is an empty array', () => { - const updatedComponent = updateComponentWithSubform(subFormComponentMock, []); + const updatedComponent = updateComponentWithSubform(subformComponentMock, []); - expect(updatedComponent.tableColumns).toEqual([subFormComponentMock.tableColumns[0]]); + expect(updatedComponent.tableColumns).toEqual([subformComponentMock.tableColumns[0]]); }); }); diff --git a/frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/utils/editSubFormTableColumnsUtils.ts b/frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/utils/editSubformTableColumnsUtils.ts similarity index 87% rename from frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/utils/editSubFormTableColumnsUtils.ts rename to frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/utils/editSubformTableColumnsUtils.ts index fe0f6144b1c..fa642a74b09 100644 --- a/frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/utils/editSubFormTableColumnsUtils.ts +++ b/frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/utils/editSubformTableColumnsUtils.ts @@ -3,9 +3,9 @@ import { type ComponentType } from 'app-shared/types/ComponentType'; import { type TableColumn } from '../types/TableColumn'; export const updateComponentWithSubform = ( - component: FormItem, + component: FormItem, tableColumnsToAdd: TableColumn[], -): FormItem => { +): FormItem => { return { ...component, tableColumns: [...(component?.tableColumns ?? []), ...tableColumnsToAdd], diff --git a/frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/utils/index.ts b/frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/utils/index.ts similarity index 64% rename from frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/utils/index.ts rename to frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/utils/index.ts index 25f7e5966eb..b05d8bfbd76 100644 --- a/frontend/packages/ux-editor/src/components/Properties/EditSubFormTableColumns/utils/index.ts +++ b/frontend/packages/ux-editor/src/components/Properties/EditSubformTableColumns/utils/index.ts @@ -1 +1 @@ -export { updateComponentWithSubform, filterOutTableColumn } from './editSubFormTableColumnsUtils'; +export { updateComponentWithSubform, filterOutTableColumn } from './editSubformTableColumnsUtils'; diff --git a/frontend/packages/ux-editor/src/components/Properties/Properties.test.tsx b/frontend/packages/ux-editor/src/components/Properties/Properties.test.tsx index 17d526e4de0..b86d7839142 100644 --- a/frontend/packages/ux-editor/src/components/Properties/Properties.test.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/Properties.test.tsx @@ -256,8 +256,8 @@ describe('Properties', () => { it('render properties accordions for a subform component when it is linked to a subform layoutSet', () => { editFormComponentSpy.mockReturnValue(); renderProperties({ - formItem: { ...componentMocks[ComponentType.SubForm], layoutSet: layoutSetName }, - formItemId: componentMocks[ComponentType.SubForm].id, + formItem: { ...componentMocks[ComponentType.Subform], layoutSet: layoutSetName }, + formItemId: componentMocks[ComponentType.Subform].id, }); expect(screen.getByText(textMock('right_menu.text'))).toBeInTheDocument(); expect(screen.getByText(textMock('right_menu.data_model_bindings'))).toBeInTheDocument(); diff --git a/frontend/packages/ux-editor/src/components/Properties/Properties.tsx b/frontend/packages/ux-editor/src/components/Properties/Properties.tsx index ff03e5b6a69..1009b572dfa 100644 --- a/frontend/packages/ux-editor/src/components/Properties/Properties.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/Properties.tsx @@ -32,7 +32,7 @@ export const Properties = () => { } }; - const isNotSubformOrHasLayoutSet = formItem.type !== 'SubForm' || !!formItem.layoutSet; + const isNotSubformOrHasLayoutSet = formItem.type !== 'Subform' || !!formItem.layoutSet; return (
diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.module.css b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.module.css new file mode 100644 index 00000000000..7def1fa7574 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.module.css @@ -0,0 +1,10 @@ +.savelayoutSetButton { + display: flex; + align-self: flex-start; + border: 2px solid var(--success-color); + color: var(--success-color); +} + +.headerIcon { + font-size: large; +} diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.test.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.test.tsx new file mode 100644 index 00000000000..970ce181232 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.test.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { renderWithProviders } from '../../../../../../testing/mocks'; +import { CreateNewSubformLayoutSet } from './CreateNewSubformLayoutSet'; +import type { ComponentType } from 'app-shared/types/ComponentType'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { screen, waitFor } from '@testing-library/react'; +import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; +import { app, org } from '@studio/testing/testids'; +import { QueryKey } from 'app-shared/types/QueryKey'; +import { layoutSets } from 'app-shared/mocks/mocks'; +import type { LayoutSets } from 'app-shared/types/api/LayoutSetsResponse'; +import userEvent from '@testing-library/user-event'; +import type { FormComponent } from '../../../../../../types/FormComponent'; +import { AppContext } from '../../../../../../AppContext'; +import { appContextMock } from '../../../../../../testing/appContextMock'; + +const onSubFormCreatedMock = jest.fn(); + +describe('CreateNewSubformLayoutSet ', () => { + afterEach(jest.clearAllMocks); + + it('displays the card with label and input field', () => { + renderCreateNewSubformLayoutSet(); + const card = screen.getByRole('textbox', { + name: textMock('ux_editor.component_properties.subform.created_layout_set_name'), + }); + + expect(card).toBeInTheDocument(); + }); + + it('displays the input field', () => { + renderCreateNewSubformLayoutSet(); + const input = screen.getByRole('textbox'); + expect(input).toBeInTheDocument(); + }); + + it('displays the save button', () => { + renderCreateNewSubformLayoutSet(); + const saveButton = screen.getByRole('button', { name: textMock('general.close') }); + expect(saveButton).toBeInTheDocument(); + }); + + it('calls onSubFormCreated when save button is clicked', async () => { + const user = userEvent.setup(); + renderCreateNewSubformLayoutSet(); + const input = screen.getByRole('textbox'); + await user.type(input, 'NewSubForm'); + const saveButton = screen.getByRole('button', { name: textMock('general.close') }); + await user.click(saveButton); + await waitFor(() => expect(onSubFormCreatedMock).toHaveBeenCalledTimes(1)); + expect(onSubFormCreatedMock).toHaveBeenCalledWith('NewSubForm'); + }); +}); + +const renderCreateNewSubformLayoutSet = ( + layoutSetsMock: LayoutSets = layoutSets, + componentProps: Partial> = {}, +) => { + const queryClient = createQueryClientMock(); + queryClient.setQueryData([QueryKey.LayoutSets, org, app], layoutSetsMock); + return renderWithProviders( + + + , + { queryClient }, + ); +}; diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.tsx new file mode 100644 index 00000000000..ef9b52de926 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.tsx @@ -0,0 +1,61 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StudioButton, StudioCard, StudioTextfield } from '@studio/components'; +import { ClipboardIcon, CheckmarkIcon } from '@studio/icons'; +import { useAddLayoutSetMutation } from 'app-development/hooks/mutations/useAddLayoutSetMutation'; +import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; +import classes from './CreateNewSubformLayoutSet.module.css'; + +type CreateNewSubformLayoutSetProps = { + onSubFormCreated: (layoutSetName: string) => void; +}; + +export const CreateNewSubformLayoutSet = ({ + onSubFormCreated, +}: CreateNewSubformLayoutSetProps): React.ReactElement => { + const { t } = useTranslation(); + const [newSubForm, setNewSubForm] = useState(''); + const { org, app } = useStudioEnvironmentParams(); + const { mutate: addLayoutSet } = useAddLayoutSetMutation(org, app); + + const createNewSubform = () => { + if (!newSubForm) return; + addLayoutSet({ + layoutSetIdToUpdate: newSubForm, + layoutSetConfig: { + id: newSubForm, + type: 'subform', + }, + }); + onSubFormCreated(newSubForm); + setNewSubForm(''); + }; + + function handleChange(e: React.ChangeEvent) { + setNewSubForm(e.target.value); + } + + return ( + + + + + + + } + onClick={createNewSubform} + title={t('general.close')} + variant='tertiary' + color='success' + /> + + + ); +}; diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/index.ts b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/index.ts new file mode 100644 index 00000000000..39c8808d341 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/index.ts @@ -0,0 +1 @@ +export { CreateNewSubformLayoutSet } from './CreateNewSubformLayoutSet'; diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/DefinedLayoutSet/DefinedLayoutSet.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/DefinedLayoutSet/DefinedLayoutSet.tsx index 1b78a5ff4a0..3dedfbec09e 100644 --- a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/DefinedLayoutSet/DefinedLayoutSet.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/DefinedLayoutSet/DefinedLayoutSet.tsx @@ -5,31 +5,31 @@ import { useTranslation } from 'react-i18next'; import classes from './DefinedLayoutSet.module.css'; type DefinedLayoutSetProps = { - existingLayoutSetForSubForm: string; + existingLayoutSetForSubform: string; onClick: () => void; }; export const DefinedLayoutSet = ({ - existingLayoutSetForSubForm, + existingLayoutSetForSubform, onClick, }: DefinedLayoutSetProps) => { const { t } = useTranslation(); const value = ( - {existingLayoutSetForSubForm} + {existingLayoutSetForSubform} ); return ( diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/EditLayoutSet.module.css b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/EditLayoutSet.module.css new file mode 100644 index 00000000000..cec24eef80a --- /dev/null +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/EditLayoutSet.module.css @@ -0,0 +1,4 @@ +.button { + padding-left: 0; + border-radius: var(--fds-sizing-1); +} diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/EditLayoutSet.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/EditLayoutSet.tsx index eff8e7bcd9c..6968f45919a 100644 --- a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/EditLayoutSet.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/EditLayoutSet.tsx @@ -2,53 +2,74 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { DefinedLayoutSet } from './DefinedLayoutSet/DefinedLayoutSet'; import { SelectLayoutSet } from './SelectLayoutSet/SelectLayoutSet'; -import { StudioRecommendedNextAction } from '@studio/components'; +import { StudioParagraph, StudioProperty, StudioRecommendedNextAction } from '@studio/components'; +import { PlusIcon } from '@studio/icons'; +import classes from './EditLayoutSet.module.css'; +import { CreateNewSubformLayoutSet } from './CreateNewSubformLayoutSet'; type EditLayoutSetProps = { existingLayoutSetForSubform: string; onUpdateLayoutSet: (layoutSetId: string) => void; + onSubFormCreated: (layoutSetName: string) => void; }; export const EditLayoutSet = ({ existingLayoutSetForSubform, onUpdateLayoutSet, + onSubFormCreated, }: EditLayoutSetProps): React.ReactElement => { const { t } = useTranslation(); const [isLayoutSetSelectorVisible, setIsLayoutSetSelectorVisible] = useState(false); + const [showCreateSubform, setShowCreateSubform] = useState(false); + + function handleClick() { + setShowCreateSubform(true); + } if (isLayoutSetSelectorVisible) { return ( ); } - const layoutSetIsUndefined = !existingLayoutSetForSubform; if (layoutSetIsUndefined) { return ( - - - + <> + + + {t('ux_editor.component_properties.subform.create_layout_set_description')} + + + } + onClick={handleClick} + /> + + {showCreateSubform && } + ); } return ( setIsLayoutSetSelectorVisible(true)} /> ); diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/SelectLayoutSet/SelectLayoutSet.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/SelectLayoutSet/SelectLayoutSet.tsx index d0eff68e556..e4a5af2a737 100644 --- a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/SelectLayoutSet/SelectLayoutSet.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/SelectLayoutSet/SelectLayoutSet.tsx @@ -5,18 +5,18 @@ import classes from './SelectLayoutSet.module.css'; import { EditLayoutSetButtons } from './EditLayoutSetButtons/EditLayoutSetButtons'; import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; import { useLayoutSetsQuery } from 'app-shared/hooks/queries/useLayoutSetsQuery'; -import { SubFormUtilsImpl } from '../../../../../../classes/SubFormUtils'; +import { SubformUtilsImpl } from '../../../../../../classes/SubformUtils'; import cn from 'classnames'; type SelectLayoutSetProps = { - existingLayoutSetForSubForm: string; + existingLayoutSetForSubform: string; onUpdateLayoutSet: (layoutSetId: string) => void; onSetLayoutSetSelectorVisible: (visible: boolean) => void; showButtons?: boolean; }; export const SelectLayoutSet = ({ - existingLayoutSetForSubForm, + existingLayoutSetForSubform, onUpdateLayoutSet, onSetLayoutSetSelectorVisible, showButtons, @@ -24,7 +24,7 @@ export const SelectLayoutSet = ({ const { t } = useTranslation(); const { org, app } = useStudioEnvironmentParams(); const { data: layoutSets } = useLayoutSetsQuery(org, app); - const subFormUtils = new SubFormUtilsImpl(layoutSets.sets); + const subformUtils = new SubformUtilsImpl(layoutSets.sets); const addLinkToLayoutSet = (layoutSetId: string): void => { onUpdateLayoutSet(layoutSetId); @@ -53,7 +53,7 @@ export const SelectLayoutSet = ({ return (
- {subFormUtils.subformLayoutSetsIds.map((option) => ( + {subformUtils.subformLayoutSetsIds.map((option) => ( diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSetForSubform.test.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSetForSubform.test.tsx index 0140a74acfc..9be5df6e5d6 100644 --- a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSetForSubform.test.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSetForSubform.test.tsx @@ -18,20 +18,20 @@ import { appContextMock } from '../../../../testing/appContextMock'; const handleComponentChangeMock = jest.fn(); const setSelectedFormLayoutSetMock = jest.fn(); -describe('EditLayoutSetForSubForm', () => { +describe('EditLayoutSetForSubform', () => { afterEach(jest.clearAllMocks); it('displays "no existing subform layout sets" message if no subform layout set exist', () => { - renderEditLayoutSetForSubForm(); - const noExistingSubFormForLayoutSet = screen.getByText( + renderEditLayoutSetForSubform(); + const noExistingSubformForLayoutSet = screen.getByText( textMock('ux_editor.component_properties.subform.no_layout_sets_acting_as_subform'), ); - expect(noExistingSubFormForLayoutSet).toBeInTheDocument(); + expect(noExistingSubformForLayoutSet).toBeInTheDocument(); }); it('displays the headers for recommendNextAction if subform layout sets exists', () => { const subformLayoutSetId = 'subformLayoutSetId'; - renderEditLayoutSetForSubForm({ sets: [{ id: subformLayoutSetId, type: 'subform' }] }); + renderEditLayoutSetForSubform({ sets: [{ id: subformLayoutSetId, type: 'subform' }] }); const setLayoutSetButton = screen.getByRole('heading', { name: textMock('ux_editor.component_properties.subform.choose_layout_set_header'), }); @@ -40,16 +40,39 @@ describe('EditLayoutSetForSubForm', () => { it('displays the description for recommendNextAction if subform layout sets exists', () => { const subformLayoutSetId = 'subformLayoutSetId'; - renderEditLayoutSetForSubForm({ sets: [{ id: subformLayoutSetId, type: 'subform' }] }); + renderEditLayoutSetForSubform({ sets: [{ id: subformLayoutSetId, type: 'subform' }] }); const setLayoutSetButton = screen.getByText( textMock('ux_editor.component_properties.subform.choose_layout_set_description'), ); expect(setLayoutSetButton).toBeInTheDocument(); }); + it('displays a button(Opprett et nytt skjema) to set a layout set for the subform', async () => { + const subformLayoutSetId = 'subformLayoutSetId'; + renderEditLayoutSetForSubform({ sets: [{ id: subformLayoutSetId, type: 'subform' }] }); + const createNewLayoutSetButton = screen.getByRole('button', { + name: textMock('ux_editor.component_properties.subform.create_layout_set_button'), + }); + expect(createNewLayoutSetButton).toBeInTheDocument(); + }); + + it('renders CreateNewLayoutSet component when clicking the create new layout set button', async () => { + const user = userEvent.setup(); + const subformLayoutSetId = 'subformLayoutSetId'; + renderEditLayoutSetForSubform({ sets: [{ id: subformLayoutSetId, type: 'subform' }] }); + const createNewLayoutSetButton = screen.getByRole('button', { + name: textMock('ux_editor.component_properties.subform.create_layout_set_button'), + }); + await user.click(createNewLayoutSetButton); + const createNewLayoutSetComponent = screen.getByRole('textbox', { + name: textMock('ux_editor.component_properties.subform.created_layout_set_name'), + }); + expect(createNewLayoutSetComponent).toBeInTheDocument(); + }); + it('displays a select to choose a layout set for the subform', async () => { const subformLayoutSetId = 'subformLayoutSetId'; - renderEditLayoutSetForSubForm({ sets: [{ id: subformLayoutSetId, type: 'subform' }] }); + renderEditLayoutSetForSubform({ sets: [{ id: subformLayoutSetId, type: 'subform' }] }); const selectLayoutSet = getSelectForLayoutSet(); const options = within(selectLayoutSet).getAllByRole('option'); expect(options).toHaveLength(2); @@ -62,7 +85,7 @@ describe('EditLayoutSetForSubForm', () => { it('calls handleComponentChange when setting a layout set for the subform', async () => { const user = userEvent.setup(); const subformLayoutSetId = 'subformLayoutSetId'; - renderEditLayoutSetForSubForm({ sets: [{ id: subformLayoutSetId, type: 'subform' }] }); + renderEditLayoutSetForSubform({ sets: [{ id: subformLayoutSetId, type: 'subform' }] }); const selectLayoutSet = getSelectForLayoutSet(); await user.selectOptions(selectLayoutSet, subformLayoutSetId); expect(handleComponentChangeMock).toHaveBeenCalledTimes(1); @@ -76,7 +99,7 @@ describe('EditLayoutSetForSubForm', () => { it('should display the selected layout set in document after the user choose it', async () => { const user = userEvent.setup(); const subformLayoutSetId = 'subformLayoutSetId'; - renderEditLayoutSetForSubForm({ sets: [{ id: subformLayoutSetId, type: 'subform' }] }); + renderEditLayoutSetForSubform({ sets: [{ id: subformLayoutSetId, type: 'subform' }] }); const selectLayoutSet = getSelectForLayoutSet(); await user.selectOptions(selectLayoutSet, subformLayoutSetId); expect(screen.getByText(subformLayoutSetId)).toBeInTheDocument(); @@ -85,7 +108,7 @@ describe('EditLayoutSetForSubForm', () => { it('should display the select again with its buttons when the user clicks on the seleced layoutset', async () => { const user = userEvent.setup(); const subformLayoutSetId = 'subformLayoutSetId'; - renderEditLayoutSetForSubForm( + renderEditLayoutSetForSubform( { sets: [{ id: subformLayoutSetId, type: 'subform' }] }, { layoutSet: subformLayoutSetId }, ); @@ -99,7 +122,7 @@ describe('EditLayoutSetForSubForm', () => { it('calls handleComponentChange with no layout set for component if selecting the empty option', async () => { const user = userEvent.setup(); const subformLayoutSetId = 'subformLayoutSetId'; - renderEditLayoutSetForSubForm({ sets: [{ id: subformLayoutSetId, type: 'subform' }] }); + renderEditLayoutSetForSubform({ sets: [{ id: subformLayoutSetId, type: 'subform' }] }); const selectLayoutSet = getSelectForLayoutSet(); const emptyOptionText = textMock('ux_editor.component_properties.subform.choose_layout_set'); await user.selectOptions(selectLayoutSet, emptyOptionText); @@ -111,10 +134,30 @@ describe('EditLayoutSetForSubForm', () => { ); }); + it('calls handleComponentChange after creating a new layout set and clicking Lukk button', async () => { + const user = userEvent.setup(); + const subformLayoutSetId = 'subformLayoutSetId'; + renderEditLayoutSetForSubform({ sets: [{ id: subformLayoutSetId, type: 'subform' }] }); + const createNewLayoutSetButton = screen.getByRole('button', { + name: textMock('ux_editor.component_properties.subform.create_layout_set_button'), + }); + await user.click(createNewLayoutSetButton); + const input = screen.getByRole('textbox'); + await user.type(input, 'NewSubForm'); + const saveButton = screen.getByRole('button', { name: textMock('general.close') }); + await user.click(saveButton); + expect(handleComponentChangeMock).toHaveBeenCalledTimes(1); + expect(handleComponentChangeMock).toHaveBeenCalledWith( + expect.objectContaining({ + layoutSet: 'NewSubForm', + }), + ); + }); + it('closes the view mode when clicking close button after selecting a layout set', async () => { const user = userEvent.setup(); const subformLayoutSetId = 'subformLayoutSetId'; - renderEditLayoutSetForSubForm( + renderEditLayoutSetForSubform( { sets: [{ id: subformLayoutSetId, type: 'subform' }] }, { layoutSet: subformLayoutSetId }, ); @@ -132,7 +175,7 @@ describe('EditLayoutSetForSubForm', () => { it('calls handleComponentChange with no layout set for component when clicking delete button', async () => { const user = userEvent.setup(); const subformLayoutSetId = 'subformLayoutSetId'; - renderEditLayoutSetForSubForm( + renderEditLayoutSetForSubform( { sets: [{ id: subformLayoutSetId, type: 'subform' }] }, { layoutSet: subformLayoutSetId }, ); @@ -151,7 +194,7 @@ describe('EditLayoutSetForSubForm', () => { it('displays a button with the existing layout set for the subform if set', () => { const subformLayoutSetId = 'subformLayoutSetId'; - renderEditLayoutSetForSubForm( + renderEditLayoutSetForSubform( { sets: [{ id: subformLayoutSetId, type: 'subform' }] }, { layoutSet: subformLayoutSetId }, ); @@ -166,7 +209,7 @@ describe('EditLayoutSetForSubForm', () => { it('opens view mode when a layout set for the subform is set', async () => { const user = userEvent.setup(); const subformLayoutSetId = 'subformLayoutSetId'; - renderEditLayoutSetForSubForm( + renderEditLayoutSetForSubform( { sets: [{ id: subformLayoutSetId, type: 'subform' }] }, { layoutSet: subformLayoutSetId }, ); @@ -186,9 +229,9 @@ const getSelectForLayoutSet = () => name: textMock('ux_editor.component_properties.subform.choose_layout_set_label'), }); -const renderEditLayoutSetForSubForm = ( +const renderEditLayoutSetForSubform = ( layoutSetsMock: LayoutSets = layoutSets, - componentProps: Partial> = {}, + componentProps: Partial> = {}, ) => { const queryClient = createQueryClientMock(); queryClient.setQueryData([QueryKey.LayoutSets, org, app], layoutSetsMock); @@ -197,7 +240,7 @@ const renderEditLayoutSetForSubForm = ( value={{ ...appContextMock, setSelectedFormLayoutSetName: setSelectedFormLayoutSetMock }} > , diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSetForSubform.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSetForSubform.tsx index e158d7ffaf4..db9441e69b3 100644 --- a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSetForSubform.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSetForSubform.tsx @@ -2,10 +2,11 @@ import React from 'react'; import { EditLayoutSet } from './EditLayoutSet'; import { NoSubformLayoutsExist } from './NoSubformLayoutsExist'; import type { ComponentType } from 'app-shared/types/ComponentType'; -import { SubFormUtilsImpl } from '../../../../classes/SubFormUtils'; +import { SubformUtilsImpl } from '../../../../classes/SubformUtils'; import { useLayoutSetsQuery } from 'app-shared/hooks/queries/useLayoutSetsQuery'; import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; import type { IGenericEditComponent } from '../../../../components/config/componentConfig'; +import { useAppContext } from '../../../../hooks'; export const EditLayoutSetForSubform = ({ handleComponentChange, @@ -13,10 +14,11 @@ export const EditLayoutSetForSubform = ({ }: IGenericEditComponent): React.ReactElement => { const { org, app } = useStudioEnvironmentParams(); const { data: layoutSets } = useLayoutSetsQuery(org, app); + const { setSelectedFormLayoutSetName } = useAppContext(); - const subFormUtils = new SubFormUtilsImpl(layoutSets.sets); + const subformUtils = new SubformUtilsImpl(layoutSets.sets); - if (!subFormUtils.hasSubforms) { + if (!subformUtils.hasSubforms) { return ; } @@ -25,10 +27,16 @@ export const EditLayoutSetForSubform = ({ handleComponentChange(updatedComponent); }; + function handleCreatedSubForm(layoutSetName: string) { + setSelectedFormLayoutSetName(layoutSetName); + handleUpdatedLayoutSet(layoutSetName); + } + return ( ); }; diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/PropertiesHeader.test.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/PropertiesHeader.test.tsx index 20babe7477d..de05ced479d 100644 --- a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/PropertiesHeader.test.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/PropertiesHeader.test.tsx @@ -105,7 +105,7 @@ describe('PropertiesHeader', () => { renderPropertiesHeader({ formItem: { ...component1Mock, - type: ComponentType.SubForm, + type: ComponentType.Subform, layoutSet: layoutSetName, id: subformLayoutSetId, }, @@ -122,7 +122,7 @@ describe('PropertiesHeader', () => { renderPropertiesHeader({ formItem: { ...component1Mock, - type: ComponentType.SubForm, + type: ComponentType.Subform, }, }); expect( @@ -134,7 +134,7 @@ describe('PropertiesHeader', () => { renderPropertiesHeader({ formItem: { ...component1Mock, - type: ComponentType.SubForm, + type: ComponentType.Subform, }, }); expect(screen.queryByText(textMock('right_menu.text'))).not.toBeInTheDocument(); diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/PropertiesHeader.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/PropertiesHeader.tsx index 8452125a566..60887f9fb4e 100644 --- a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/PropertiesHeader.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/PropertiesHeader.tsx @@ -42,7 +42,7 @@ export const PropertiesHeader = ({ />
- {formItem.type === ComponentType.SubForm && ( + {formItem.type === ComponentType.Subform && ( { props: { ...props, formItem: { - ...componentMocks[ComponentType.SubForm], + ...componentMocks[ComponentType.Subform], }, }, }); @@ -317,14 +317,14 @@ describe('TextTab', () => { expect(addColumnButton).toBeInTheDocument(); }); - it('should call handleUpdate when handleComponentChange is triggered from EditSubFormTableColumns', async () => { + it('should call handleUpdate when handleComponentChange is triggered from EditSubformTableColumns', async () => { const user = userEvent.setup(); render({ props: { ...props, formItem: { - ...componentMocks[ComponentType.SubForm], + ...componentMocks[ComponentType.Subform], }, }, }); diff --git a/frontend/packages/ux-editor/src/components/Properties/Text.tsx b/frontend/packages/ux-editor/src/components/Properties/Text.tsx index 523ad896a84..3b3d9a87368 100644 --- a/frontend/packages/ux-editor/src/components/Properties/Text.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/Text.tsx @@ -12,7 +12,7 @@ import type { ComponentSpecificConfig } from 'app-shared/types/ComponentSpecific import { useAppContext } from '../../hooks'; import { EditImage } from '../config/editModal/EditImage'; import classes from './Text.module.css'; -import { EditSubFormTableColumns } from './EditSubFormTableColumns'; +import { EditSubformTableColumns } from './EditSubformTableColumns'; import { type FormContainer } from '@altinn/ux-editor/types/FormContainer'; export const Text = () => { @@ -79,8 +79,8 @@ export const Text = () => { )} - {form.type === ComponentType.SubForm && ( - + {form.type === ComponentType.Subform && ( + )} ); diff --git a/frontend/packages/shared/src/components/Expression/Expression.tsx b/frontend/packages/ux-editor/src/components/config/ExpressionContent/Expression/Expression.tsx similarity index 83% rename from frontend/packages/shared/src/components/Expression/Expression.tsx rename to frontend/packages/ux-editor/src/components/config/ExpressionContent/Expression/Expression.tsx index 54945d45dc0..4fa95ab2895 100644 --- a/frontend/packages/shared/src/components/Expression/Expression.tsx +++ b/frontend/packages/ux-editor/src/components/config/ExpressionContent/Expression/Expression.tsx @@ -1,7 +1,7 @@ import React from 'react'; import type { StudioExpressionProps } from '@studio/components'; import { StudioExpression } from '@studio/components'; -import { useExpressionTexts } from './useExpressionTexts'; +import { useExpressionTexts } from 'app-shared/hooks/useExpressionTexts'; export type ExpressionProps = Omit; diff --git a/frontend/packages/shared/src/components/Expression/index.ts b/frontend/packages/ux-editor/src/components/config/ExpressionContent/Expression/index.ts similarity index 100% rename from frontend/packages/shared/src/components/Expression/index.ts rename to frontend/packages/ux-editor/src/components/config/ExpressionContent/Expression/index.ts diff --git a/frontend/packages/ux-editor/src/components/config/ExpressionContent/ExpressionContent.tsx b/frontend/packages/ux-editor/src/components/config/ExpressionContent/ExpressionContent.tsx index 5c1e240ea14..4d0eef78fd8 100644 --- a/frontend/packages/ux-editor/src/components/config/ExpressionContent/ExpressionContent.tsx +++ b/frontend/packages/ux-editor/src/components/config/ExpressionContent/ExpressionContent.tsx @@ -8,7 +8,7 @@ import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmen import { useDataModelMetadataQuery } from '../../../hooks/queries/useDataModelMetadataQuery'; import { Paragraph } from '@digdir/designsystemet-react'; import classes from './ExpressionContent.module.css'; -import { Expression as ExpressionWithTexts } from 'app-shared/components/Expression'; +import { Expression as ExpressionWithTexts } from './Expression'; import { useText, useAppContext } from '../../../hooks'; export interface ExpressionContentProps { diff --git a/frontend/packages/ux-editor/src/components/config/FormComponentConfig.test.tsx b/frontend/packages/ux-editor/src/components/config/FormComponentConfig.test.tsx index e97a4e206f8..eb817551ba3 100644 --- a/frontend/packages/ux-editor/src/components/config/FormComponentConfig.test.tsx +++ b/frontend/packages/ux-editor/src/components/config/FormComponentConfig.test.tsx @@ -62,7 +62,7 @@ describe('FormComponentConfig', () => { id: 'subform-unit-test-id', layoutSet: 'subform-unit-test-layout-set', itemType: 'COMPONENT', - type: ComponentType.SubForm, + type: ComponentType.Subform, }, schema: { properties: { diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/AddItemContent.module.css b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/AddItemContent.module.css new file mode 100644 index 00000000000..1b78dd45868 --- /dev/null +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/AddItemContent.module.css @@ -0,0 +1,39 @@ +.allComponentsWrapper { + display: flex; + flex-direction: row; + flex-wrap: wrap; + height: 100%; + flex: 3; + padding: 20px; + overflow-y: scroll; +} + +.componentButton { + height: 50px; + width: 180px; + margin: 8px; +} + +.componentCategory { + padding-top: 12px; +} + +.componentHelpText { + width: 100%; +} + +.componentsInfoWrapper { + flex: 1; + background-color: var(--fds-semantic-surface-info-subtle); + padding: 20px; + height: 600px; + position: sticky; +} + +.root { + display: flex; + flex-direction: row; + overflow-y: hidden; + height: 100%; + /* min-width: 70vw; */ +} diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/AddItemContent.tsx b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/AddItemContent.tsx new file mode 100644 index 00000000000..def0d76a6f0 --- /dev/null +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/AddItemContent.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import type { ComponentType } from 'app-shared/types/ComponentType'; +import type { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; +import type { IToolbarElement } from '../../../types/global'; +import classes from './AddItemContent.module.css'; +import { ItemCategory } from './ItemCategory'; +import type { AddedItem } from './types'; +import { ItemInfo } from './ItemInfo'; +import { useFormLayouts } from '../../../hooks'; +import { generateComponentId } from '../../../utils/generateId'; +import { StudioParagraph } from '@studio/components'; + +export type AddItemContentProps = { + item: AddedItem | null; + setItem: (item: AddedItem | null) => void; + onAddItem: (addedItem: AddedItem) => void; + onCancel: () => void; + availableComponents: KeyValuePairs; +}; + +export const AddItemContent = ({ + item, + setItem, + onAddItem, + onCancel, + availableComponents, +}: AddItemContentProps) => { + const layouts = useFormLayouts(); + + return ( +
+
+ + Klikk på en komponent for å se mer informasjon om den. + + {Object.keys(availableComponents).map((key) => { + return ( + generateComponentId(type, layouts)} + /> + ); + })} +
+
+ generateComponentId(type, layouts)} + item={item} + setItem={setItem} + /> +
+
+ ); +}; diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/AddItemModal.tsx b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/AddItemModal.tsx new file mode 100644 index 00000000000..489a663d582 --- /dev/null +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/AddItemModal.tsx @@ -0,0 +1,103 @@ +import React, { useCallback, useRef } from 'react'; +import { + addItemOfType, + getAvailableChildComponentsForContainer, + getItem, +} from '../../../utils/formLayoutUtils'; +import { useAddItemToLayoutMutation } from '../../../hooks/mutations/useAddItemToLayoutMutation'; +import { useFormItemContext } from '../../FormItemContext'; +import { useAppContext } from '../../../hooks'; +import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; +import type { IInternalLayout } from '../../../types/global'; +import type { ComponentType } from 'app-shared/types/ComponentType'; +import { StudioButton, StudioModal } from '@studio/components'; +import type { AddedItem } from './types'; +import { BASE_CONTAINER_ID } from 'app-shared/constants'; +import { AddItemContent } from './AddItemContent'; +import { PlusCircleIcon } from '@studio/icons'; +import { usePreviewContext } from 'app-development/contexts/PreviewContext'; + +export type AddItemProps = { + containerId: string; + layout: IInternalLayout; +}; + +export const AddItemModal = ({ containerId, layout }: AddItemProps) => { + const [selectedItem, setSelectedItem] = React.useState(null); + + const { doReloadPreview } = usePreviewContext(); + const handleCloseModal = () => { + setSelectedItem(null); + modalRef.current?.close(); + }; + const { handleEdit } = useFormItemContext(); + + const { org, app } = useStudioEnvironmentParams(); + const { selectedFormLayoutSetName } = useAppContext(); + + const { mutate: addItemToLayout } = useAddItemToLayoutMutation( + org, + app, + selectedFormLayoutSetName, + ); + + const modalRef = useRef(null); + + const addItem = (type: ComponentType, parentId: string, index: number, newId: string) => { + const updatedLayout = addItemOfType(layout, type, newId, parentId, index); + + addItemToLayout( + { componentType: type, newId, parentId, index }, + { + onSuccess: () => { + doReloadPreview(); + }, + }, + ); + handleEdit(getItem(updatedLayout, newId)); + }; + + const onAddComponent = (addedItem: AddedItem) => { + addItem( + addedItem.componentType, + containerId, + layout.order[containerId].length, + addedItem.componentId, + ); + handleCloseModal(); + }; + + const handleOpenModal = useCallback(() => { + modalRef.current?.showModal(); + }, []); + + return ( +
+ + } + onClick={handleOpenModal} + variant='tertiary' + fullWidth + > + Legg til komponent + + + + + +
+ ); +}; diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemCategory/ItemCategory.module.css b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemCategory/ItemCategory.module.css new file mode 100644 index 00000000000..0685e31c04e --- /dev/null +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemCategory/ItemCategory.module.css @@ -0,0 +1,20 @@ +.componentButton { + margin: 8px; + justify-content: start; + width: 270px; +} + +.componentsWrapper { + display: flex; + flex-direction: column; + height: 100%; + flex-wrap: wrap; + justify-content: start; + align-items: start; +} + +.itemCategory { + margin-bottom: 12px; + margin-right: 12px; + max-width: calc(50% - 12px); +} diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemCategory/ItemCategory.tsx b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemCategory/ItemCategory.tsx new file mode 100644 index 00000000000..f2662d95c1c --- /dev/null +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemCategory/ItemCategory.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { StudioButton, StudioCard, StudioHeading } from '@studio/components'; +import classes from './ItemCategory.module.css'; +import { useTranslation } from 'react-i18next'; +import type { IToolbarElement } from '../../../../types/global'; +import type { AddedItem } from '../types'; +import type { ComponentType } from 'app-shared/types/ComponentType'; +import { getComponentTitleByComponentType } from '../../../../utils/language'; + +export type ItemCategoryProps = { + items: IToolbarElement[]; + category: string; + selectedItemType: ComponentType; + setAddedItem(addedItem: AddedItem): void; + generateComponentId: (type: string) => string; +}; + +export const ItemCategory = ({ + items, + category, + selectedItemType, + setAddedItem, + generateComponentId, +}: ItemCategoryProps) => { + const { t } = useTranslation(); + + return ( + + + {t(`ux_editor.component_category.${category}`)} + +
+ {items.map((item: IToolbarElement) => ( + { + setAddedItem({ + componentType: item.type, + componentId: generateComponentId(item.type), + }); + }} + /> + ))} +
+
+ ); +}; + +type ComponentButtonProps = { + tooltipContent: string; + selected: boolean; + icon: React.ComponentType; + onClick: () => void; +}; +function ComponentButton({ tooltipContent, selected, icon, onClick }: ComponentButtonProps) { + return ( + + {tooltipContent} + + ); +} diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemCategory/index.ts b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemCategory/index.ts new file mode 100644 index 00000000000..60d2c7c51f9 --- /dev/null +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemCategory/index.ts @@ -0,0 +1 @@ +export { ItemCategory } from './ItemCategory'; diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemInfo/ItemInfo.module.css b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemInfo/ItemInfo.module.css new file mode 100644 index 00000000000..9fc6bf7f4b7 --- /dev/null +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemInfo/ItemInfo.module.css @@ -0,0 +1,38 @@ +.allComponentsWrapper { + display: flex; + flex-direction: column; + height: 100%; + flex: 3; + padding: 20px; +} + +.componentButton { + height: 50px; + width: 180px; + margin: 8px; +} + +.componentCategory { + padding-top: 12px; +} + +.componentHelpText { + width: 100%; +} + +.componentsInfoWrapper { + flex: 1; + background-color: var(--fds-semantic-surface-info-subtle); + padding: 20px; +} + +.componentsWrapper { + display: flex; + flex-direction: row; + height: 100%; + flex-wrap: wrap; +} + +.root { + min-width: 360px; +} diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemInfo/ItemInfo.tsx b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemInfo/ItemInfo.tsx new file mode 100644 index 00000000000..525282495d7 --- /dev/null +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemInfo/ItemInfo.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { + StudioHeading, + StudioIconTextfield, + StudioParagraph, + StudioRecommendedNextAction, +} from '@studio/components'; +import { + getComponentHelperTextByComponentType, + getComponentTitleByComponentType, +} from '../../../../utils/language'; +import type { AddedItem } from '../types'; +import { useTranslation } from 'react-i18next'; +import { PencilIcon } from '@studio/icons'; +import classes from './ItemInfo.module.css'; + +export type ItemInfoProps = { + item: AddedItem | null; + onAddItem: (item: AddedItem) => void; + onCancel: () => void; + setItem: (item: AddedItem | null) => void; + generateComponentId: (type: string) => string; +}; + +export const ItemInfo = ({ + item, + onAddItem, + onCancel, + setItem, + generateComponentId, +}: ItemInfoProps) => { + const { t } = useTranslation(); + return ( +
+ + {!item && t('ux_editor.component_add_item.info_heading')} + {item && getComponentTitleByComponentType(item.componentType, t)} + + {!item &&

{t('ux_editor.component_add_item.info_no_component_selected')}

} + {item && ( +
+ + {getComponentHelperTextByComponentType(item.componentType, t)} + +
+ )} + {item && ( + { + onAddItem(item); + setItem(null); + }} + onSkip={() => { + onCancel(); + setItem(null); + }} + saveButtonText='Legg til' + skipButtonText='Avbryt' + title={`Legg til ${getComponentTitleByComponentType(item.componentType, t)}`} + description='Vi lager automatisk en unik ID for komponenten. Du kan endre den her til noe du selv ønsker, eller la den være som den er. Du kan også endre denne id-en senere.' + > + } + label={t('Komponent ID')} + value={item.componentId} + onChange={(event: any) => { + setItem({ ...item, componentId: event.target.value }); + }} + /> + + )} +
+ ); +}; diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemInfo/index.ts b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemInfo/index.ts new file mode 100644 index 00000000000..4bfa8e07920 --- /dev/null +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemInfo/index.ts @@ -0,0 +1 @@ +export { ItemInfo } from './ItemInfo'; diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/README.md b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/README.md new file mode 100644 index 00000000000..f5dfd802abc --- /dev/null +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/README.md @@ -0,0 +1,11 @@ +## POC for modal to add component - used for live user testing + +We implemented a POC of an alternative way of adding components. All files in this enclosing folder are part of the POC. +Please note that not all components/code in this folder is implemented according to our development guidelines, on account +of it being a POC. If we decide to use these files going forward after the user test, some refactoring and cleanup should be +done. + +If we at some point after the user test decide not to go forward with the concept, or to implement it in a different way, we can +delete this folder. + +The component `AddItemModal.tsx` is used in the file `FormLayout.tsx`. diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/index.ts b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/index.ts new file mode 100644 index 00000000000..f54ca4f5db0 --- /dev/null +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/index.ts @@ -0,0 +1 @@ +export { AddItemModal } from './AddItemModal'; diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/types.ts b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/types.ts new file mode 100644 index 00000000000..217a75e3db5 --- /dev/null +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/types.ts @@ -0,0 +1,6 @@ +import type { ComponentType } from 'app-shared/types/ComponentType'; + +export type AddedItem = { + componentType: ComponentType; + componentId: string; +}; diff --git a/frontend/packages/ux-editor/src/containers/DesignView/FormLayout.tsx b/frontend/packages/ux-editor/src/containers/DesignView/FormLayout.tsx index eede7362302..95ad44c25e6 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/FormLayout.tsx +++ b/frontend/packages/ux-editor/src/containers/DesignView/FormLayout.tsx @@ -5,6 +5,9 @@ import { hasMultiPageGroup } from '../../utils/formLayoutUtils'; import { useTranslation } from 'react-i18next'; import { Alert, Paragraph } from '@digdir/designsystemet-react'; import { FormLayoutWarning } from './FormLayoutWarning'; +import { BASE_CONTAINER_ID } from 'app-shared/constants'; +import { AddItemModal } from './AddItemModal/AddItemModal'; +import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; export interface FormLayoutProps { layout: IInternalLayout; @@ -20,6 +23,10 @@ export const FormLayout = ({ layout, isInvalid, duplicateComponents }: FormLayou <> {hasMultiPageGroup(layout) && } + {/** The following check and component are added as part of a live user test behind a feature flag. Can be removed if we decide not to use after user test. */} + {shouldDisplayFeature('addComponentModal') && ( + + )} ); }; diff --git a/frontend/packages/ux-editor/src/containers/FormDesigner.tsx b/frontend/packages/ux-editor/src/containers/FormDesigner.tsx index e1cbebd5b3c..28d7cd8e269 100644 --- a/frontend/packages/ux-editor/src/containers/FormDesigner.tsx +++ b/frontend/packages/ux-editor/src/containers/FormDesigner.tsx @@ -31,6 +31,7 @@ import { useAddItemToLayoutMutation } from '../hooks/mutations/useAddItemToLayou import { useFormLayoutMutation } from '../hooks/mutations/useFormLayoutMutation'; import { Preview } from '../components/Preview'; import { DragAndDropTree } from 'app-shared/components/DragAndDropTree'; +import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; export const FormDesigner = (): JSX.Element => { const { org, app } = useStudioEnvironmentParams(); @@ -159,19 +160,27 @@ export const FormDesigner = (): JSX.Element => { orientation='horizontal' localStorageContext={`form-designer-main:${user.id}:${org}`} > + {/** + * The following check is done for a live user test behind feature flag. It can be removed if this is not something + * that is going to be used in the future. + */} + {!shouldDisplayFeature('addComponentModal') && ( + + setElementsCollapsed(!elementsCollapsed)} + /> + + )} - setElementsCollapsed(!elementsCollapsed)} - /> - - { component: formItemConfigs[ComponentType.Payment], }, { - component: formItemConfigs[ComponentType.SubForm], + component: formItemConfigs[ComponentType.Subform], }, ])( 'should return false for unsupported subform component: $component.name', diff --git a/frontend/packages/ux-editor/src/data/FilterUtils.ts b/frontend/packages/ux-editor/src/data/FilterUtils.ts index 1bb49048431..e68022c4184 100644 --- a/frontend/packages/ux-editor/src/data/FilterUtils.ts +++ b/frontend/packages/ux-editor/src/data/FilterUtils.ts @@ -17,7 +17,7 @@ export class FilterUtils { formItemConfigs[ComponentType.FileUploadWithTag], formItemConfigs[ComponentType.InstantiationButton], formItemConfigs[ComponentType.Payment], - formItemConfigs[ComponentType.SubForm], + formItemConfigs[ComponentType.Subform], ]; return !unsupportedSubformComponents.includes(component); }; diff --git a/frontend/packages/ux-editor/src/data/formItemConfig.test.ts b/frontend/packages/ux-editor/src/data/formItemConfig.test.ts index cf71a7b3f9b..cc3481cc92a 100644 --- a/frontend/packages/ux-editor/src/data/formItemConfig.test.ts +++ b/frontend/packages/ux-editor/src/data/formItemConfig.test.ts @@ -14,9 +14,9 @@ describe('formItemConfig', () => { confOnScreenComponents, ]; const allAvailableComponents = allAvailableLists.flat(); - const excludedComponents = [ComponentType.Payment, ComponentType.SubForm, ComponentType.Summary2]; + const excludedComponents = [ComponentType.Payment, ComponentType.Subform, ComponentType.Summary2]; - /** Test that all components, except Payment, SubForm and Summary2 (since behind featureFlag), are available in one of the visible lists */ + /** Test that all components, except Payment, Subform and Summary2 (since behind featureFlag), are available in one of the visible lists */ it.each( Object.values(ComponentType).filter( (componentType) => !excludedComponents.includes(componentType), @@ -29,8 +29,8 @@ describe('formItemConfig', () => { expect(allAvailableComponents.map(({ name }) => name)).not.toContain(ComponentType.Payment); }); - test('that subForm component is not available in the visible lists', () => { - expect(allAvailableComponents.map(({ name }) => name)).not.toContain(ComponentType.SubForm); + test('that subform component is not available in the visible lists', () => { + expect(allAvailableComponents.map(({ name }) => name)).not.toContain(ComponentType.Subform); }); test('that Summary2 component is not available in the visible lists', () => { diff --git a/frontend/packages/ux-editor/src/data/formItemConfig.ts b/frontend/packages/ux-editor/src/data/formItemConfig.ts index 6950422ef02..124a85b58d3 100644 --- a/frontend/packages/ux-editor/src/data/formItemConfig.ts +++ b/frontend/packages/ux-editor/src/data/formItemConfig.ts @@ -37,6 +37,7 @@ import { import type { ContainerComponentType } from '../types/ContainerComponent'; import { LayoutItemType } from '../types/global'; import type { ComponentSpecificConfig } from 'app-shared/types/ComponentSpecificConfig'; +import type { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; import { FilterUtils } from './FilterUtils'; @@ -457,11 +458,11 @@ export const formItemConfigs: FormItemConfigs = { icon: RepeatingGroupIcon, validChildTypes: Object.values(ComponentType), }, - [ComponentType.SubForm]: { - name: ComponentType.SubForm, + [ComponentType.Subform]: { + name: ComponentType.Subform, itemType: LayoutItemType.Component, defaultProperties: {}, - propertyPath: 'definitions/subForm', + propertyPath: 'definitions/subform', icon: ClipboardIcon, }, [ComponentType.Summary]: { @@ -513,7 +514,7 @@ export const advancedItems: FormItemConfigs[ComponentType][] = [ formItemConfigs[ComponentType.Custom], formItemConfigs[ComponentType.RepeatingGroup], formItemConfigs[ComponentType.PaymentDetails], - shouldDisplayFeature('subform') && formItemConfigs[ComponentType.SubForm], + shouldDisplayFeature('subform') && formItemConfigs[ComponentType.Subform], ].filter(FilterUtils.filterOutDisabledFeatureItems); export const schemaComponents: FormItemConfigs[ComponentType][] = [ @@ -561,6 +562,56 @@ export const paymentLayoutComponents: FormItemConfigs[ComponentType][] = [ ...confOnScreenComponents, ]; +export type ComponentCategory = + | 'form' + | 'select' + | 'button' + | 'text' + | 'info' + | 'container' + | 'attachment' + | 'advanced'; + +export const allComponents: KeyValuePairs = { + form: [ComponentType.Input, ComponentType.TextArea, ComponentType.Datepicker], + select: [ + ComponentType.Checkboxes, + ComponentType.RadioButtons, + ComponentType.Dropdown, + ComponentType.MultipleSelect, + ComponentType.Likert, + ], + text: [ComponentType.Header, ComponentType.Paragraph, ComponentType.Panel, ComponentType.Alert], + info: [ + ComponentType.InstanceInformation, + ComponentType.Image, + ComponentType.Link, + ComponentType.IFrame, + ComponentType.Summary, + ], + button: [ + ComponentType.Button, + ComponentType.CustomButton, + ComponentType.NavigationButtons, + ComponentType.PrintButton, + ComponentType.InstantiationButton, + ComponentType.ActionButton, + ], + attachment: [ + ComponentType.AttachmentList, + ComponentType.FileUpload, + ComponentType.FileUploadWithTag, + ], + container: [ + ComponentType.Group, + ComponentType.Grid, + ComponentType.Accordion, + ComponentType.AccordionGroup, + ComponentType.List, + ComponentType.RepeatingGroup, + ], + advanced: [ComponentType.Address, ComponentType.Map, ComponentType.Custom], +}; export const subformLayoutComponents: Array = [ ...schemaComponents, ...textComponents, diff --git a/frontend/packages/ux-editor/src/hooks/useValidateComponent.ts b/frontend/packages/ux-editor/src/hooks/useValidateComponent.ts index 2951134c6f3..7846a18be71 100644 --- a/frontend/packages/ux-editor/src/hooks/useValidateComponent.ts +++ b/frontend/packages/ux-editor/src/hooks/useValidateComponent.ts @@ -1,4 +1,4 @@ -import { areItemsUnique } from 'app-shared/utils/arrayUtils'; +import { ArrayUtils } from '@studio/pure-functions'; import { ComponentType } from 'app-shared/types/ComponentType'; import type { FormCheckboxesComponent, @@ -36,7 +36,7 @@ const validateOptionGroup = ( isValid: false, error: ErrorCode.NoOptions, }; - } else if (!areItemsUnique(component.options.map((option) => option.value))) { + } else if (!ArrayUtils.areItemsUnique(component.options.map((option) => option.value))) { return { isValid: false, error: ErrorCode.DuplicateValues, diff --git a/frontend/packages/ux-editor/src/testing/componentMocks.ts b/frontend/packages/ux-editor/src/testing/componentMocks.ts index f997e3cbf5b..886371032d6 100644 --- a/frontend/packages/ux-editor/src/testing/componentMocks.ts +++ b/frontend/packages/ux-editor/src/testing/componentMocks.ts @@ -83,8 +83,8 @@ const textareaComponent: FormComponent = { ...commonProps(ComponentType.TextArea), dataModelBindings: { simpleBinding: '' }, }; -const subFormComponent: FormComponent = { - ...commonProps(ComponentType.SubForm), +const subformComponent: FormComponent = { + ...commonProps(ComponentType.Subform), tableColumns: [ { headerContent: 'header content', @@ -208,7 +208,7 @@ export const componentMocks = { [ComponentType.Paragraph]: paragraphComponent, [ComponentType.RadioButtons]: radiosComponent, [ComponentType.RepeatingGroup]: repeatingGroupContainer, - [ComponentType.SubForm]: subFormComponent, + [ComponentType.Subform]: subformComponent, [ComponentType.TextArea]: textareaComponent, [ComponentType.Custom]: thirdPartyComponent, [ComponentType.Summary2]: summary2Component, diff --git a/frontend/packages/ux-editor/src/testing/componentSchemaMocks.ts b/frontend/packages/ux-editor/src/testing/componentSchemaMocks.ts index 23e11d345a3..3fd45f481e1 100644 --- a/frontend/packages/ux-editor/src/testing/componentSchemaMocks.ts +++ b/frontend/packages/ux-editor/src/testing/componentSchemaMocks.ts @@ -36,7 +36,7 @@ import PaymentSchema from './schemas/json/component/Payment.schema.v1.json'; import PrintButtonSchema from './schemas/json/component/PrintButton.schema.v1.json'; import RadioButtonsSchema from './schemas/json/component/RadioButtons.schema.v1.json'; import RepeatingGroupSchema from './schemas/json/component/RepeatingGroup.schema.v1.json'; -import SubFormSchema from './schemas/json/component/Subform.schema.v1.json'; +import SubformSchema from './schemas/json/component/Subform.schema.v1.json'; import SummarySchema from './schemas/json/component/Summary.schema.v1.json'; import Summary2Schema from './schemas/json/component/Summary2.schema.v1.json'; import TextAreaSchema from './schemas/json/component/TextArea.schema.v1.json'; @@ -82,7 +82,7 @@ export const componentSchemaMocks: Record = { [ComponentType.PrintButton]: PrintButtonSchema, [ComponentType.RadioButtons]: RadioButtonsSchema, [ComponentType.RepeatingGroup]: RepeatingGroupSchema, - [ComponentType.SubForm]: SubFormSchema, + [ComponentType.Subform]: SubformSchema, [ComponentType.Summary]: SummarySchema, [ComponentType.Summary2]: Summary2Schema, [ComponentType.TextArea]: TextAreaSchema, diff --git a/frontend/packages/ux-editor/src/testing/layoutSetsMock.ts b/frontend/packages/ux-editor/src/testing/layoutSetsMock.ts index f6476fce917..03176a71552 100644 --- a/frontend/packages/ux-editor/src/testing/layoutSetsMock.ts +++ b/frontend/packages/ux-editor/src/testing/layoutSetsMock.ts @@ -3,7 +3,7 @@ import type { LayoutSets } from 'app-shared/types/api/LayoutSetsResponse'; export const dataModelNameMock = 'test-data-model'; export const layoutSet1NameMock = 'test-layout-set'; export const layoutSet2NameMock = 'test-layout-set-2'; -export const layoutSet3SubFormNameMock = 'test-layout-set-3'; +export const layoutSet3SubformNameMock = 'test-layout-set-3'; export const layoutSetsMock: LayoutSets = { sets: [ @@ -18,7 +18,7 @@ export const layoutSetsMock: LayoutSets = { tasks: ['Task_2'], }, { - id: layoutSet3SubFormNameMock, + id: layoutSet3SubformNameMock, dataType: 'data-model-3', type: 'subform', }, diff --git a/frontend/packages/ux-editor/src/testing/schemas/json/component/component-list.json b/frontend/packages/ux-editor/src/testing/schemas/json/component/component-list.json index e0f81003320..28bccd341ea 100644 --- a/frontend/packages/ux-editor/src/testing/schemas/json/component/component-list.json +++ b/frontend/packages/ux-editor/src/testing/schemas/json/component/component-list.json @@ -30,7 +30,7 @@ "Paragraph", "PrintButton", "RadioButtons", - "SubForm", + "Subform", "Summary", "TextArea" ] diff --git a/frontend/packages/ux-editor/src/utils/formLayoutUtils.ts b/frontend/packages/ux-editor/src/utils/formLayoutUtils.ts index 22635afe259..024494f261d 100644 --- a/frontend/packages/ux-editor/src/utils/formLayoutUtils.ts +++ b/frontend/packages/ux-editor/src/utils/formLayoutUtils.ts @@ -5,19 +5,19 @@ import type { IToolbarElement, } from '../types/global'; import { BASE_CONTAINER_ID, MAX_NESTED_GROUP_LEVEL } from 'app-shared/constants'; -import { insertArrayElementAtPos, areItemsUnique } from 'app-shared/utils/arrayUtils'; import { ArrayUtils, ObjectUtils } from '@studio/pure-functions'; import { ComponentType, type CustomComponentType } from 'app-shared/types/ComponentType'; import type { FormComponent } from '../types/FormComponent'; import { generateFormItem } from './component'; import type { FormItemConfigs } from '../data/formItemConfig'; -import { formItemConfigs } from '../data/formItemConfig'; +import { formItemConfigs, allComponents } from '../data/formItemConfig'; import type { FormContainer } from '../types/FormContainer'; import type { FormItem } from '../types/FormItem'; import * as formItemUtils from './formItemUtils'; import type { ContainerComponentType } from '../types/ContainerComponent'; import { flattenObjectValues } from 'app-shared/utils/objectUtils'; import type { FormLayoutPage } from '../types/FormLayoutPage'; +import type { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; export const mapComponentToToolbarElement = ( c: FormItemConfigs[T], @@ -291,7 +291,7 @@ export const moveLayoutItem = ( newLayout.order[oldContainerId], id, ); - newLayout.order[newContainerId] = insertArrayElementAtPos( + newLayout.order[newContainerId] = ArrayUtils.insertArrayElementAtPos( newLayout.order[newContainerId], id, newPosition, @@ -434,7 +434,7 @@ export const idExistsInLayout = (id: string, layout: IInternalLayout): boolean = export const duplicatedIdsExistsInLayout = (layout: IInternalLayout): boolean => { if (!layout?.order) return false; const idsInLayout = flattenObjectValues(layout.order); - return !areItemsUnique(idsInLayout); + return !ArrayUtils.areItemsUnique(idsInLayout); }; /** @@ -491,6 +491,26 @@ export const getDuplicatedIds = (layout: IInternalLayout): string[] => { export const getAllFormItemIds = (layout: IInternalLayout): string[] => flattenObjectValues(layout.order); +/** + * Gets all available componenent types to add for a given container + * @param layout + * @param containerId + * @returns + */ +export const getAvailableChildComponentsForContainer = ( + layout: IInternalLayout, + containerId: string, +): KeyValuePairs => { + if (containerId !== BASE_CONTAINER_ID) return {}; + const allComponentLists: KeyValuePairs = {}; + Object.keys(allComponents).forEach((key) => { + allComponentLists[key] = allComponents[key].map((element: ComponentType) => + mapComponentToToolbarElement(formItemConfigs[element]), + ); + }); + return allComponentLists; +}; + /** * Get all components in the given layout * @param layout The layout diff --git a/frontend/testing/playwright/tests/process-editor/process-editor.spec.ts b/frontend/testing/playwright/tests/process-editor/process-editor.spec.ts index 7e141402a01..ce653356886 100644 --- a/frontend/testing/playwright/tests/process-editor/process-editor.spec.ts +++ b/frontend/testing/playwright/tests/process-editor/process-editor.spec.ts @@ -215,6 +215,7 @@ const addNewSigningTaskToProcessEditor = async (page: Page): Promise => extraMovingDistanceX, extraMovingDistanceY, ); + await processEditorPage.skipRecommendedTask(); await processEditorPage.waitForTaskToBeVisibleInConfigPanel(signingTask); return signingTask; diff --git a/frontend/testing/testids.js b/frontend/testing/testids.js index 978d7565865..46621c9da3a 100644 --- a/frontend/testing/testids.js +++ b/frontend/testing/testids.js @@ -3,7 +3,6 @@ export const dataModellingContainerId = 'data-modelling-container'; export const deleteButtonId = (key) => `delete-button-${key}`; export const draggableToolbarItemId = 'draggableToolbarItem'; export const droppableListId = 'droppableList'; -export const fileSelectorInputId = 'file-selector-input'; export const orgMenuItemId = (orgUserName) => orgUserName ? `menu-org-${orgUserName}` : 'menu-org-no-org-user-name'; export const resetRepoContainerId = 'reset-repo-container'; diff --git a/src/Altinn.Platform/Altinn.Platform.PDF/pom.xml b/src/Altinn.Platform/Altinn.Platform.PDF/pom.xml index a4750b3d329..53eb93205fe 100644 --- a/src/Altinn.Platform/Altinn.Platform.PDF/pom.xml +++ b/src/Altinn.Platform/Altinn.Platform.PDF/pom.xml @@ -32,7 +32,7 @@ org.springframework.boot spring-boot-starter-parent - 3.3.4 + 3.3.5 diff --git a/yarn.lock b/yarn.lock index cb5956b6b22..44a03132739 100644 --- a/yarn.lock +++ b/yarn.lock @@ -128,7 +128,6 @@ __metadata: react-dnd: "npm:16.0.1" react-dnd-html5-backend: "npm:16.0.1" react-dom: "npm:18.3.1" - react-modal: "npm:3.16.1" react-redux: "npm:9.1.2" redux: "npm:5.0.1" redux-mock-store: "npm:1.5.4" @@ -150,7 +149,6 @@ __metadata: react-dnd: "npm:16.0.1" react-dnd-html5-backend: "npm:16.0.1" react-dom: "npm:18.3.1" - react-modal: "npm:3.16.1" typescript: "npm:5.6.2" uuid: "npm:10.0.0" peerDependencies: @@ -4368,7 +4366,7 @@ __metadata: languageName: unknown linkType: soft -"@studio/content-library@workspace:frontend/libs/studio-content-library": +"@studio/content-library@workspace:^, @studio/content-library@workspace:frontend/libs/studio-content-library": version: 0.0.0-use.local resolution: "@studio/content-library@workspace:frontend/libs/studio-content-library" dependencies: @@ -4392,6 +4390,8 @@ __metadata: ts-jest: "npm:^29.1.1" typescript: "npm:5.6.2" uuid: "npm:10.0.0" + peerDependencies: + react-router-dom: ">=6.0.0" languageName: unknown linkType: soft @@ -6562,6 +6562,7 @@ __metadata: version: 0.0.0-use.local resolution: "app-development@workspace:frontend/app-development" dependencies: + "@studio/content-library": "workspace:^" "@studio/hooks": "workspace:^" "@studio/icons": "workspace:^" "@studio/pure-functions": "workspace:^" @@ -10229,13 +10230,6 @@ __metadata: languageName: node linkType: hard -"exenv@npm:^1.2.0": - version: 1.2.2 - resolution: "exenv@npm:1.2.2" - checksum: 10/6840185e421394bcb143debb866d31d19c3e4a4bca87d2f319d68d61afff353b3c678f2eb389e3b98ab9aecbec19f6bebbdc4193984378af0a3366c498a7efc8 - languageName: node - linkType: hard - "exit@npm:^0.1.2": version: 0.1.2 resolution: "exit@npm:0.1.2" @@ -15912,28 +15906,6 @@ __metadata: languageName: node linkType: hard -"react-lifecycles-compat@npm:^3.0.0": - version: 3.0.4 - resolution: "react-lifecycles-compat@npm:3.0.4" - checksum: 10/c66b9c98c15cd6b0d0a4402df5f665e8cc7562fb7033c34508865bea51fd7b623f7139b5b7e708515d3cd665f264a6a9403e1fa7e6d61a05759066f5e9f07783 - languageName: node - linkType: hard - -"react-modal@npm:3.16.1": - version: 3.16.1 - resolution: "react-modal@npm:3.16.1" - dependencies: - exenv: "npm:^1.2.0" - prop-types: "npm:^15.7.2" - react-lifecycles-compat: "npm:^3.0.0" - warning: "npm:^4.0.3" - peerDependencies: - react: ^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 - react-dom: ^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 - checksum: 10/79787ed2754f65168fccefcef50b509fa1cbc2b44907f92dcfd78ea6f9702668c70604f192a4bb45badb664902fb100179d6d191e478310be94e656271963905 - languageName: node - linkType: hard - "react-number-format@npm:5.2.2": version: 5.2.2 resolution: "react-number-format@npm:5.2.2" @@ -18833,15 +18805,6 @@ __metadata: languageName: node linkType: hard -"warning@npm:^4.0.3": - version: 4.0.3 - resolution: "warning@npm:4.0.3" - dependencies: - loose-envify: "npm:^1.0.0" - checksum: 10/e7842aff036e2e07ce7a6cc3225e707775b969fe3d0577ad64bd24660e3a9ce3017f0b8c22a136566dcd3a151f37b8ed1ccee103b3bd82bd8a571bf80b247bc4 - languageName: node - linkType: hard - "watchpack@npm:^2.4.1": version: 2.4.1 resolution: "watchpack@npm:2.4.1"