From ffec39aa895f5774da9c0086e74ac0a1a390c054 Mon Sep 17 00:00:00 2001 From: Konrad-Simso Date: Thu, 9 Jan 2025 08:29:27 +0100 Subject: [PATCH] feat: update codelist config design (#14276) Co-authored-by: Erling Hauan --- frontend/language/src/nb.json | 9 +- .../AddManualOptionsModal.module.css | 16 - .../AddManualOptionsModal.test.tsx | 118 ------- .../AddManualOptionsModal.tsx | 64 ---- .../EditTab/AddManualOptionsModal/index.ts | 1 - .../OptionTabs/EditTab/EditTab.module.css | 27 +- .../OptionTabs/EditTab/EditTab.test.tsx | 94 ++++-- .../OptionTabs/EditTab/EditTab.tsx | 161 ++++----- .../LibraryOptionsEditor.tsx | 46 ++- .../ManualOptionsEditor.tsx | 110 +++--- .../OptionListButtons.module.css | 5 + .../OptionListButtons/OptionListButtons.tsx | 39 +++ .../OptionListButtons/index.ts | 1 + .../OptionListEditor.test.tsx | 178 ++++++---- .../OptionListEditor/OptionListEditor.tsx | 50 ++- .../OptionListLabels.module.css | 10 + .../OptionListLabels/OptionListLabels.tsx | 27 ++ .../OptionListLabels/index.ts | 1 + .../hooks/useConcatOptionsLabels.ts | 9 + .../OptionListSelector.module.css | 27 +- .../OptionListSelector.test.tsx | 35 +- .../OptionListSelector/OptionListSelector.tsx | 113 ++++--- .../OptionListUploader.test.tsx | 1 - .../OptionListUploader/OptionListUploader.tsx | 32 +- .../OptionTabs/OptionTabs.test.tsx | 35 +- .../OptionTabs/utils/optionsUtils.test.ts | 317 +++++++++++++----- .../OptionTabs/utils/optionsUtils.ts | 95 +++++- 27 files changed, 960 insertions(+), 661 deletions(-) delete mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/AddManualOptionsModal.module.css delete mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/AddManualOptionsModal.test.tsx delete mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/AddManualOptionsModal.tsx delete mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/index.ts create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListButtons/OptionListButtons.module.css create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListButtons/OptionListButtons.tsx create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListButtons/index.ts create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListLabels/OptionListLabels.module.css create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListLabels/OptionListLabels.tsx create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListLabels/index.ts create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/hooks/useConcatOptionsLabels.ts diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index 3eca000e224..ff4bda69562 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -1598,7 +1598,7 @@ "ux_editor.modal_new_option": "Legg til alternativ", "ux_editor.modal_properties_add_radio_button_options": "Hvordan vil du legge til radioknapper?", "ux_editor.modal_properties_code_list": "Velg fra biblioteket", - "ux_editor.modal_properties_code_list_alert_title": "Du redigerer nå en kodeliste i biblioteket.", + "ux_editor.modal_properties_code_list_alert_title": "Du er i ferd med å endre en kodeliste i biblioteket. Da endrer du også kodelisten alle andre steder der den blir brukt.", "ux_editor.modal_properties_code_list_button_title_library": "Kodeliste fra biblioteket", "ux_editor.modal_properties_code_list_button_title_manual": "Kodeliste på komponenten", "ux_editor.modal_properties_code_list_custom_list": "Egendefinert kodeliste", @@ -1756,13 +1756,17 @@ "ux_editor.options.code_list_only": "Denne komponenten støtter bare oppsett med forhåndsdefinerte kodelister.", "ux_editor.options.code_list_reference_id.description": "Her kan du legge til en referanse-ID til en dynamisk kodeliste som er satt opp i koden.", "ux_editor.options.code_list_reference_id.description_details": "Du bruker dynamiske kodelister for å tilpasse alternativer for brukerne. Det kan for eksempel være tilpasninger ut fra geografisk plassering, eller valg brukeren gjør tidligere i skjemaet.", + "ux_editor.options.modal_header_library_code_list": "Kodeliste fra biblioteket", + "ux_editor.options.modal_header_manual_code_list": "Kodeliste på komponenten", + "ux_editor.options.modal_header_select_library_code_list": "Biblioteket for kodelister", "ux_editor.options.multiple": "{{value}} alternativer", "ux_editor.options.option_edit_text": "Rediger kodeliste", - "ux_editor.options.option_remove_text": "Fjern valgt kodeliste", + "ux_editor.options.option_remove_text": "Fjern kobling til kodelisten", "ux_editor.options.section_heading": "Valg for kodelister", "ux_editor.options.single": "{{value}} alternativ", "ux_editor.options.tab_code_list": "Velg kodeliste", "ux_editor.options.tab_manual": "Sett opp egne alternativer", + "ux_editor.options.tab_option_list_alert_title": "Komponenten refererer allerede til en egendefinert ID. Legger du til en ny kodeliste her vil du overstyre den egendefinerte ID-en.", "ux_editor.options.tab_reference_id": "Angi referanse-ID", "ux_editor.options.tab_reference_id_alert_title": "Du har allerede referert til en kodeliste. Skriver du inn en ID, vil referansen bli slettet.", "ux_editor.options.upload_title": "Last opp egen kodeliste", @@ -1827,6 +1831,7 @@ "ux_editor.properties_panel.texts.no_properties": "Det er ingen tekster å konfigurere for denne komponenten.", "ux_editor.properties_panel.texts.sub_title_images": "Valg for bilde", "ux_editor.properties_panel.texts.sub_title_texts": "Tekster", + "ux_editor.radios_error_DuplicateValues": "Alle verdier må være unike.", "ux_editor.radios_error_NoOptions": "Det må være minst én radioknapp.", "ux_editor.radios_option": "Radioknapp {{optionNumber}}", "ux_editor.search_text_resources_close": "Lukk tekstsøk", diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/AddManualOptionsModal.module.css b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/AddManualOptionsModal.module.css deleted file mode 100644 index e366a282152..00000000000 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/AddManualOptionsModal.module.css +++ /dev/null @@ -1,16 +0,0 @@ -.manualTabDialog[open] { - --code-list-modal-min-width: min(80rem, 100%); - --code-list-modal-height: min(40rem, 100%); - - min-width: var(--code-list-modal-min-width); - height: var(--code-list-modal-height); -} - -.modalTrigger { - width: 50%; - min-width: 12rem; -} - -.content { - height: 100%; -} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/AddManualOptionsModal.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/AddManualOptionsModal.test.tsx deleted file mode 100644 index 92033d4e1c2..00000000000 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/AddManualOptionsModal.test.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import React from 'react'; -import { screen } from '@testing-library/react'; -import { AddManualOptionsModal } from './AddManualOptionsModal'; -import { renderWithProviders } from '../../../../../../../testing/mocks'; -import { textMock } from '@studio/testing/mocks/i18nMock'; -import { ComponentType } from 'app-shared/types/ComponentType'; -import type { FormItem } from '../../../../../../../types/FormItem'; -import userEvent from '@testing-library/user-event'; -import { componentMocks } from '../../../../../../../testing/componentMocks'; - -// Test data: -const mockComponent = componentMocks[ComponentType.Dropdown]; -mockComponent.optionsId = undefined; - -const handleComponentChange = jest.fn(); - -describe('AddManualOptionsModal', () => { - it('should display a button when no code list is defined in the layout', () => { - renderEditManualOptionsWithEditor(); - - expect( - screen.getByRole('button', { - name: textMock('general.create_new'), - }), - ).toBeInTheDocument(); - }); - - it('should open a modal when the trigger button is clicked', async () => { - const user = userEvent.setup(); - renderEditManualOptionsWithEditor(); - - await user.click(getOpenModalButton()); - - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - it('should call handleComponentChange when there has been a change in the editor', async () => { - const user = userEvent.setup(); - renderEditManualOptionsWithEditor(); - - await user.click(getOpenModalButton()); - await user.click(getAddNewOptionButton()); - - expect(handleComponentChange).toHaveBeenCalledTimes(1); - expect(handleComponentChange).toHaveBeenCalledWith({ - ...mockComponent, - options: [{ label: '', value: '' }], - }); - }); - - it('should delete optionsId from the layout when using the manual editor', async () => { - const user = userEvent.setup(); - renderEditManualOptionsWithEditor({ - componentProps: { - optionsId: 'somePredefinedOptionsList', - }, - }); - - await user.click(getOpenModalButton()); - await user.click(getAddNewOptionButton()); - - expect(handleComponentChange).toHaveBeenCalledWith({ - ...mockComponent, - options: [{ label: '', value: '' }], - }); - }); - - it('should call setChosenOption when closing modal', async () => { - const user = userEvent.setup(); - const mockSetComponentHasOptionList = jest.fn(); - const componentOptions = []; - renderEditManualOptionsWithEditor({ - setComponentHasOptionList: mockSetComponentHasOptionList, - componentProps: { options: componentOptions }, - }); - - await user.click(getOpenModalButton()); - - const closeButton = screen.getByRole('button', { - name: 'close modal', // Todo: Replace 'close modal' with textMock('settings_modal.close_button_label') when we upgrade to Designsystemet v1 - }); - await user.click(closeButton); - - expect(mockSetComponentHasOptionList).toHaveBeenCalledTimes(1); - expect(mockSetComponentHasOptionList).toHaveBeenCalledWith(true); - }); -}); - -function getOpenModalButton() { - return screen.getByRole('button', { - name: textMock('general.create_new'), - }); -} - -function getAddNewOptionButton() { - return screen.getByRole('button', { name: textMock('code_list_editor.add_option') }); -} - -type renderProps = { - componentProps?: Partial>; - setComponentHasOptionList?: () => void; -}; - -function renderEditManualOptionsWithEditor< - T extends ComponentType.Checkboxes | ComponentType.RadioButtons, ->({ componentProps = {}, setComponentHasOptionList = jest.fn() }: renderProps = {}) { - const component = { - ...mockComponent, - ...componentProps, - }; - renderWithProviders( - , - ); -} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/AddManualOptionsModal.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/AddManualOptionsModal.tsx deleted file mode 100644 index cc88f91efe6..00000000000 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/AddManualOptionsModal.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React, { useRef } from 'react'; -import { StudioCodeListEditor, StudioModal } from '@studio/components'; -import type { Option } from 'app-shared/types/Option'; -import { useTranslation } from 'react-i18next'; -import { useOptionListEditorTexts } from '../../hooks'; -import type { IGenericEditComponent } from '@altinn/ux-editor/components/config/componentConfig'; -import type { SelectionComponentType } from '@altinn/ux-editor/types/FormComponent'; -import classes from './AddManualOptionsModal.module.css'; - -export type EditManualOptionsWithEditorProps = { - setComponentHasOptionList: (value: boolean) => void; -} & Pick, 'component' | 'handleComponentChange'>; - -export function AddManualOptionsModal({ - setComponentHasOptionList, - component, - handleComponentChange, -}: EditManualOptionsWithEditorProps) { - const { t } = useTranslation(); - const manualOptionsModalRef = useRef(null); - const editorTexts = useOptionListEditorTexts(); - - const handleOptionsChange = (options: Option[]) => { - if (component.optionsId) { - delete component.optionsId; - } - - handleComponentChange({ - ...component, - options, - }); - }; - - const handleClose = () => { - if (component.options !== undefined) { - setComponentHasOptionList(true); - } - - manualOptionsModalRef.current?.close(); - }; - - return ( - - - {t('general.create_new')} - - - - - - ); -} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/index.ts deleted file mode 100644 index 19896f313e3..00000000000 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AddManualOptionsModal } from './AddManualOptionsModal'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/EditTab.module.css b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/EditTab.module.css index 176985c67ee..fdd77a14946 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/EditTab.module.css +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/EditTab.module.css @@ -1,25 +1,20 @@ -.chosenOptionContainer { - display: flex; - overflow: hidden; -} - -.deleteButtonContainer { - display: flex; - width: min-content; - margin-left: auto; +.container { + padding: 0 var(--fds-spacing-5); } -.deleteButton { - border-radius: 0; -} - -.optionButtons { +.addOptionListContainer { display: flex; + width: 60%; + min-width: 12rem; + white-space: nowrap; flex-direction: column; gap: var(--fds-spacing-2); - margin-left: var(--fds-spacing-5); +} + +.alert { + margin-top: var(--fds-spacing-2); } .errorMessage { - margin: var(--fds-spacing-5) var(--fds-spacing-5) 0; + margin-top: var(--fds-spacing-2); } diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/EditTab.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/EditTab.test.tsx index ddfe00e2236..bf5cc7ad10c 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/EditTab.test.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/EditTab.test.tsx @@ -2,12 +2,11 @@ import React from 'react'; import { EditTab } from './EditTab'; import { renderWithProviders } from '@altinn/ux-editor/testing/mocks'; import { ComponentType } from 'app-shared/types/ComponentType'; -import type { FormItem } from '@altinn/ux-editor/types/FormItem'; -import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; import { componentMocks } from '@altinn/ux-editor/testing/componentMocks'; import { textMock } from '@studio/testing/mocks/i18nMock'; import userEvent from '@testing-library/user-event'; -import { screen } from '@testing-library/react'; +import { screen, waitForElementToBeRemoved } from '@testing-library/react'; +import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; // Test data: const mockComponent = componentMocks[ComponentType.RadioButtons]; @@ -15,52 +14,98 @@ const mockComponent = componentMocks[ComponentType.RadioButtons]; describe('EditTab', () => { afterEach(() => jest.clearAllMocks()); - it('should render DisplayChosenOption', async () => { + it('should render spinner', () => { renderEditTab(); expect( - screen.getByRole('button', { name: textMock('ux_editor.options.option_remove_text') }), + screen.getByText(textMock('ux_editor.modal_properties_code_list_spinner_title')), ).toBeInTheDocument(); }); - it('should render EditOptionList', async () => { + it('should render error message when query fails', async () => { + renderEditTab({ + queries: { getOptionListIds: jest.fn().mockImplementation(() => Promise.reject()) }, + }); + + await waitForSpinnerToBeRemoved(); + expect( + screen.getByText(textMock('ux_editor.modal_properties_fetch_option_list_ids_error_message')), + ).toBeInTheDocument(); + }); + + it('should render preview of a custom code list when component has manual options set', async () => { + renderEditTab({ componentProps: { optionsId: undefined } }); + + await waitForSpinnerToBeRemoved(); + expect( + screen.getByText(textMock('ux_editor.modal_properties_code_list_custom_list')), + ).toBeInTheDocument(); + }); + + it('should render upload option list button when option list is not defined on component', async () => { renderEditTab({ componentProps: { options: undefined, + optionsId: undefined, }, }); + await waitForSpinnerToBeRemoved(); expect( screen.getByRole('button', { name: textMock('ux_editor.options.upload_title') }), ).toBeInTheDocument(); }); - it('should set optionsId to blank when removing choice', async () => { + it('should call handleComponentChange with empty options array when clicking create new options', async () => { const user = userEvent.setup(); - const handleOptionsIdChange = jest.fn(); - renderEditTab({ handleComponentChange: handleOptionsIdChange }); - const expectedArgs = mockComponent; - expectedArgs.optionsId = ''; - delete expectedArgs.options; - - const button = await screen.findByRole('button', { - name: textMock('ux_editor.options.option_remove_text'), + const handleComponentChange = jest.fn(); + renderEditTab({ + componentProps: { + options: undefined, + optionsId: undefined, + }, + handleComponentChange, + }); + + await waitForSpinnerToBeRemoved(); + const addManualOptionsButton = screen.getByRole('button', { + name: textMock('general.create_new'), + }); + await user.click(addManualOptionsButton); + + expect(handleComponentChange).toHaveBeenCalledTimes(1); + expect(handleComponentChange).toHaveBeenCalledWith({ + ...mockComponent, + options: [], + optionsId: undefined, + }); + }); + + it('should render alert when options ID is a reference ID', async () => { + renderEditTab({ + componentProps: { + options: undefined, + optionsId: 'option-id-that-does-not-exist-in-app', + }, }); - await user.click(button); - expect(handleOptionsIdChange).toHaveBeenCalledTimes(1); - expect(handleOptionsIdChange).toHaveBeenCalledWith(expectedArgs); + await waitForSpinnerToBeRemoved(); + expect( + screen.getByText(textMock('ux_editor.options.tab_option_list_alert_title')), + ).toBeInTheDocument(); }); }); -type renderProps = { - componentProps?: Partial>; - handleComponentChange?: () => void; -}; +async function waitForSpinnerToBeRemoved() { + await waitForElementToBeRemoved(() => + screen.queryByText(textMock('ux_editor.modal_properties_code_list_spinner_title')), + ); +} -function renderEditTab({ +function renderEditTab({ componentProps = {}, handleComponentChange = jest.fn(), -}: renderProps = {}) { + queries = {}, +} = {}) { return renderWithProviders( , { + queries, queryClient: createQueryClientMock(), }, ); diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/EditTab.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/EditTab.tsx index 0ca7a543443..58982445283 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/EditTab.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/EditTab.tsx @@ -1,102 +1,107 @@ -import React, { useState } from 'react'; +import React, { createRef } from 'react'; +import { + StudioAlert, + StudioButton, + StudioErrorMessage, + StudioSpinner, + usePrevious, +} from '@studio/components'; import { useTranslation } from 'react-i18next'; -import { StudioDeleteButton, StudioErrorMessage } from '@studio/components'; -import { AddManualOptionsModal } from './AddManualOptionsModal'; -import { OptionListSelector } from './OptionListSelector'; -import { OptionListUploader } from './OptionListUploader'; -import { OptionListEditor } from './/OptionListEditor'; +import { useUpdate } from 'app-shared/hooks/useUpdate'; import { useComponentErrorMessage } from '../../../../../../hooks'; -import type { IGenericEditComponent } from '../../../../componentConfig'; +import { + handleOptionsChange, + updateComponentOptions, + isOptionsModifiable, + isOptionsIdReferenceId, + isInitialOptionsSet, +} from '../utils/optionsUtils'; +import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; +import { useOptionListIdsQuery } from '../../../../../../hooks/queries/useOptionListIdsQuery'; import type { SelectionComponentType } from '../../../../../../types/FormComponent'; +import type { IGenericEditComponent } from '../../../../componentConfig'; +import { OptionListSelector } from './OptionListSelector'; +import { OptionListUploader } from './OptionListUploader'; +import { OptionListEditor } from './OptionListEditor'; import classes from './EditTab.module.css'; -type EditOptionChoiceProps = Pick< +type EditTabProps = Pick< IGenericEditComponent, 'component' | 'handleComponentChange' >; -export function EditTab({ - component, - handleComponentChange, -}: EditOptionChoiceProps): React.ReactElement { - const initialComponentHasOptionList: boolean = !!component.optionsId || !!component.options; - const [componentHasOptionList, setComponentHasOptionList] = useState( - initialComponentHasOptionList, - ); +export function EditTab({ component, handleComponentChange }: EditTabProps): React.ReactElement { + const { t } = useTranslation(); + const { org, app } = useStudioEnvironmentParams(); + const { data: optionListIds, status } = useOptionListIdsQuery(org, app); + const previousComponent = usePrevious(component); + const dialogRef = createRef(); const errorMessage = useComponentErrorMessage(component); - return ( - <> - {componentHasOptionList ? ( - { + if (isInitialOptionsSet(previousComponent.options, component.options)) { + dialogRef.current.showModal(); + } + }, [component, previousComponent]); + + switch (status) { + case 'pending': + return ( + - ) : ( -
- - - -
- )} - {errorMessage && ( - - {errorMessage} + ); + case 'error': + return ( + + {t('ux_editor.modal_properties_fetch_option_list_ids_error_message')} - )} - - ); + ); + case 'success': + return ( +
+ {isOptionsModifiable(optionListIds, component.optionsId, component.options) ? ( + + ) : ( + + )} + {errorMessage && ( + + {errorMessage} + + )} + {isOptionsIdReferenceId(optionListIds, component.optionsId) && ( + + {t('ux_editor.options.tab_option_list_alert_title')} + + )} +
+ ); + } } -type SelectedOptionListProps = { - setComponentHasOptionList: (value: boolean) => void; -} & Pick, 'component' | 'handleComponentChange'>; +type AddOptionListProps = EditTabProps; -function SelectedOptionList({ - setComponentHasOptionList, - component, - handleComponentChange, -}: SelectedOptionListProps) { +function AddOptionList({ component, handleComponentChange }: AddOptionListProps) { const { t } = useTranslation(); - const handleDelete = () => { - if (component.options) { - delete component.options; - } - - const emptyOptionsId = ''; - handleComponentChange({ - ...component, - optionsId: emptyOptionsId, - }); - - setComponentHasOptionList(false); + const handleInitialManualOptionsChange = () => { + const updatedComponent = updateComponentOptions(component, []); + handleOptionsChange(updatedComponent, handleComponentChange); }; return ( -
- -
- -
+
+ + {t('general.create_new')} + + +
); } diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/LibraryOptionsEditor/LibraryOptionsEditor.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/LibraryOptionsEditor/LibraryOptionsEditor.tsx index 500963caf0c..d58c171c147 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/LibraryOptionsEditor/LibraryOptionsEditor.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/LibraryOptionsEditor/LibraryOptionsEditor.tsx @@ -1,62 +1,56 @@ -import React, { createRef, useState } from 'react'; import type { Option } from 'app-shared/types/Option'; -import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; -import { useUpdateOptionListMutation } from 'app-shared/hooks/mutations'; -import { useOptionListEditorTexts } from '../../../../../EditOptions/OptionTabs/hooks'; -import classes from './LibraryOptionsEditor.module.css'; +import React, { createRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { StudioCodeListEditor, StudioModal, StudioAlert } from '@studio/components'; import type { CodeListEditorTexts } from '@studio/components'; -import { StudioAlert, StudioCodeListEditor, StudioModal, StudioProperty } from '@studio/components'; import { usePreviewContext } from 'app-development/contexts/PreviewContext'; +import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; +import { useUpdateOptionListMutation } from 'app-shared/hooks/mutations'; +import { useOptionListEditorTexts } from '../../../hooks'; +import { OptionListButtons } from '../OptionListButtons'; +import { OptionListLabels } from '../OptionListLabels'; +import { hasOptionListChanged } from '../../../utils/optionsUtils'; +import { useOptionListQuery } from 'app-shared/hooks/queries'; +import classes from './LibraryOptionsEditor.module.css'; type LibraryOptionsEditorProps = { + handleDelete: () => void; optionsId: string; - optionsList: Option[]; }; export function LibraryOptionsEditor({ + handleDelete, optionsId, - optionsList, }: LibraryOptionsEditorProps): React.ReactNode { const { t } = useTranslation(); const { org, app } = useStudioEnvironmentParams(); + const { data: optionsList } = useOptionListQuery(org, app, optionsId); const { doReloadPreview } = usePreviewContext(); const { mutate: updateOptionList } = useUpdateOptionListMutation(org, app); - const [localOptionList, setLocalOptionList] = useState(optionsList); const editorTexts: CodeListEditorTexts = useOptionListEditorTexts(); const modalRef = createRef(); - const optionListHasChanged = (options: Option[]): boolean => - JSON.stringify(options) !== JSON.stringify(localOptionList); - const handleBlurAny = (options: Option[]) => { - if (optionListHasChanged(options)) { + if (hasOptionListChanged(optionsList, options)) { updateOptionList({ optionListId: optionsId, optionsList: options }); - setLocalOptionList(options); doReloadPreview(); } }; - const handleClose = () => { - modalRef.current?.close(); + const handleClick = () => { + modalRef.current?.showModal(); }; return ( <> - modalRef.current.showModal()} - /> + + {t('ux_editor.modal_properties_code_list_alert_title')} @@ -64,7 +58,7 @@ export function LibraryOptionsEditor({ } > diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/ManualOptionsEditor/ManualOptionsEditor.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/ManualOptionsEditor/ManualOptionsEditor.tsx index e69ccd54298..8f37340eb52 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/ManualOptionsEditor/ManualOptionsEditor.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/ManualOptionsEditor/ManualOptionsEditor.tsx @@ -1,57 +1,67 @@ -import { useOptionListEditorTexts } from '@altinn/ux-editor/components/config/editModal/EditOptions/OptionTabs/hooks'; +import type { IGenericEditComponent } from '../../../../../../componentConfig'; +import type { SelectionComponentType } from '../../../../../../../../types/FormComponent'; +import React, { forwardRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StudioCodeListEditor, StudioModal } from '@studio/components'; +import { useForwardedRef } from '@studio/hooks'; +import { useOptionListEditorTexts } from '../../../hooks'; +import { + handleOptionsChange, + resetComponentOptions, + updateComponentOptions, +} from '../../../utils/optionsUtils'; +import { OptionListLabels } from '../OptionListLabels'; +import { OptionListButtons } from '../OptionListButtons'; import type { Option } from 'app-shared/types/Option'; import classes from './ManualOptionsEditor.module.css'; -import type { IGenericEditComponent } from '@altinn/ux-editor/components/config/componentConfig'; -import type { SelectionComponentType } from '@altinn/ux-editor/types/FormComponent'; -import { useTranslation } from 'react-i18next'; -import React, { useRef } from 'react'; -import { StudioCodeListEditor, StudioModal, StudioProperty } from '@studio/components'; -type ManualOptionsEditorProps = Pick< - IGenericEditComponent, - 'component' | 'handleComponentChange' ->; +type ManualOptionsEditorProps = { + handleDelete: () => void; +} & Pick, 'component' | 'handleComponentChange'>; + +export const ManualOptionsEditor = forwardRef( + ({ component, handleComponentChange, handleDelete }, ref): React.ReactNode => { + const { t } = useTranslation(); + const modalRef = useForwardedRef(ref); + const editorTexts = useOptionListEditorTexts(); + + const handleBlurAny = (options: Option[]) => { + const updatedComponent = updateComponentOptions(component, options); + handleOptionsChange(updatedComponent, handleComponentChange); + }; -export function ManualOptionsEditor({ - component, - handleComponentChange, -}: ManualOptionsEditorProps): React.ReactNode { - const { t } = useTranslation(); - const modalRef = useRef(null); - const editorTexts = useOptionListEditorTexts(); + const handleClick = () => { + modalRef.current?.showModal(); + }; - const handleBlurAny = (options: Option[]) => { - if (component.optionsId) { - delete component.optionsId; - } + const handleClose = () => { + if (component.options?.length === 0) { + const updatedComponent = resetComponentOptions(component); + handleOptionsChange(updatedComponent, handleComponentChange); + } + }; - handleComponentChange({ - ...component, - options, - }); - }; + return ( + <> + + + + + + + ); + }, +); - return ( - <> - modalRef.current.showModal()} - /> - - - - - ); -} +ManualOptionsEditor.displayName = 'ManualOptionsEditor'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListButtons/OptionListButtons.module.css b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListButtons/OptionListButtons.module.css new file mode 100644 index 00000000000..d853047f3bc --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListButtons/OptionListButtons.module.css @@ -0,0 +1,5 @@ +.buttonContainer { + display: flex; + gap: var(--fds-spacing-2); + margin-bottom: var(--fds-spacing-1); +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListButtons/OptionListButtons.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListButtons/OptionListButtons.tsx new file mode 100644 index 00000000000..edd23b69dfc --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListButtons/OptionListButtons.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { StudioButton } from '@studio/components'; +import { PencilIcon, TrashIcon } from '@studio/icons'; +import classes from './OptionListButtons.module.css'; + +type OptionListButtonsProps = { + handleDelete: () => void; + handleClick: () => void; +}; + +export function OptionListButtons({ + handleDelete, + handleClick, +}: OptionListButtonsProps): React.ReactNode { + const { t } = useTranslation(); + + return ( +
+ } + variant='secondary' + onClick={handleClick} + title={t('ux_editor.modal_properties_code_list_open_editor')} + > + {t('general.edit')} + + } + variant='secondary' + onClick={handleDelete} + title={t('ux_editor.options.option_remove_text')} + > + {t('general.delete')} + +
+ ); +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListButtons/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListButtons/index.ts new file mode 100644 index 00000000000..a69dc65396c --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListButtons/index.ts @@ -0,0 +1 @@ +export { OptionListButtons } from './OptionListButtons'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListEditor.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListEditor.test.tsx index 298cdee0755..2a222cc89bb 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListEditor.test.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListEditor.test.tsx @@ -2,7 +2,9 @@ import React from 'react'; import { screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; import type { OptionsList } from 'app-shared/types/api/OptionsLists'; import type { Option } from 'app-shared/types/Option'; +import { ComponentType } from 'app-shared/types/ComponentType'; import type { OptionListEditorProps } from './OptionListEditor'; +import { ObjectUtils } from '@studio/pure-functions'; import { OptionListEditor } from './OptionListEditor'; import { textMock } from '@studio/testing/mocks/i18nMock'; import { renderWithProviders } from '../../../../../../../testing/mocks'; @@ -11,7 +13,6 @@ import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; import { queriesMock } from 'app-shared/mocks/queriesMock'; import { app, org } from '@studio/testing/testids'; import { componentMocks } from '../../../../../../../testing/componentMocks'; -import { ComponentType } from 'app-shared/types/ComponentType'; // Test data: const mockComponent = componentMocks[ComponentType.RadioButtons]; @@ -24,74 +25,107 @@ const apiResult: OptionsList = [ const getOptionListMock = jest .fn() .mockImplementation(() => Promise.resolve(apiResult)); +const componentWithOptionsId = { ...mockComponent, options: undefined, optionsId: 'some-id' }; describe('OptionListEditor', () => { afterEach(() => jest.clearAllMocks()); - describe('ManualOptionListEditorModal', () => { - it('should render the open Dialog button', async () => { + describe('ManualOptionEditor', () => { + it('should render the open Dialog button', () => { renderOptionListEditor(); - expect(getManualModalButton()).toBeInTheDocument(); + expect(getOptionModalButton()).toBeInTheDocument(); }); it('should open Dialog', async () => { const user = userEvent.setup(); renderOptionListEditor(); - await user.click(getManualModalButton()); + + await user.click(getOptionModalButton()); expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect( + screen.getByText(textMock('ux_editor.options.modal_header_manual_code_list')), + ).toBeInTheDocument(); }); it('should close Dialog', async () => { const user = userEvent.setup(); renderOptionListEditor(); - await user.click(getManualModalButton()); - await user.click(screen.getByRole('button', { name: 'close modal' })); // Todo: Replace "close modal" with defaultDialogProps.closeButtonTitle when we upgrade to Designsystemet v1 + await user.click(getOptionModalButton()); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'close modal' })); // Todo: Replace "close modal" with defaultDialogProps.closeButtonTitle when we upgrade to Designsystemet v1 expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); - it('should call doReloadPreview when editing', async () => { + it('should call handleComponentChange with correct parameters when closing Dialog and options is empty', async () => { + const user = userEvent.setup(); + renderOptionListEditor({ + props: { component: { ...mockComponent, options: [], optionsId: undefined } }, + }); + const expectedArgs = ObjectUtils.deepCopy(mockComponent); + expectedArgs.options = undefined; + expectedArgs.optionsId = undefined; + + await user.click(getOptionModalButton()); + await user.click(screen.getByRole('button', { name: 'close modal' })); // Todo: Replace "close modal" with defaultDialogProps.closeButtonTitle when we upgrade to Designsystemet v1 + + expect(handleComponentChange).toHaveBeenCalledTimes(1); + expect(handleComponentChange).toHaveBeenCalledWith(expectedArgs); + }); + + it('should call handleComponentChange with correct parameters when editing', async () => { const user = userEvent.setup(); renderOptionListEditor(); const text = 'test'; - await user.click(getManualModalButton()); - const textBox = screen.getByRole('textbox', { - name: textMock('code_list_editor.description_item', { number: 2 }), - }); + const expectedArgs = ObjectUtils.deepCopy(mockComponent); + expectedArgs.optionsId = undefined; + expectedArgs.options[0].description = text; + + await user.click(getOptionModalButton()); + const textBox = getTextBoxInput(1); await user.type(textBox, text); await user.tab(); expect(handleComponentChange).toHaveBeenCalledTimes(1); + expect(handleComponentChange).toHaveBeenCalledWith(expectedArgs); }); - it('should delete optionsId field from component if it was set when manual options editor is blurred', async () => { - const user = userEvent.setup(); + it('should show placeholder for option label when option list label is empty', () => { renderOptionListEditor({ - props: { component: { ...mockComponent, optionsId: 'optionsId' } }, - }); - const text = 'test'; - await user.click(getManualModalButton()); - const textBox = screen.getByRole('textbox', { - name: textMock('code_list_editor.description_item', { number: 2 }), + props: { + component: { + ...mockComponent, + options: [{ value: 2, label: '', description: 'test', helpText: null }], + }, + }, }); - await user.type(textBox, text); - await user.tab(); + + expect(screen.getByText(textMock('general.empty_string'))).toBeInTheDocument(); + }); + + it('should call handleComponentChange with correct parameters when removing chosen options', async () => { + const user = userEvent.setup(); + renderOptionListEditor(); + const expectedResult = ObjectUtils.deepCopy(mockComponent); + expectedResult.options = undefined; + expectedResult.optionsId = undefined; + + const deleteButton = screen.getByRole('button', { name: textMock('general.delete') }); + await user.click(deleteButton); expect(handleComponentChange).toHaveBeenCalledTimes(1); - expect(handleComponentChange).toHaveBeenCalledWith( - expect.not.objectContaining({ optionsId: expect.anything() }), - ); + expect(handleComponentChange).toHaveBeenCalledWith(expectedResult); }); }); - describe('LibraryOptionListEditorModal', () => { + describe('LibraryOptionEditor', () => { it('should render a spinner when there is no data', () => { renderOptionListEditor({ queries: { getOptionList: jest.fn().mockImplementation(() => Promise.resolve([])), }, - props: { component: { ...mockComponent, options: undefined } }, + props: { component: componentWithOptionsId }, }); expect( @@ -102,9 +136,8 @@ describe('OptionListEditor', () => { it('should render an error message when getOptionLists throws an error', async () => { await renderOptionListEditorAndWaitForSpinnerToBeRemoved({ queries: { - getOptionList: jest.fn().mockRejectedValueOnce(new Error('Error')), + getOptionList: jest.fn().mockImplementation(() => Promise.reject()), }, - props: { component: { ...mockComponent, options: undefined } }, }); expect( @@ -113,17 +146,13 @@ describe('OptionListEditor', () => { }); it('should render the open Dialog button', async () => { - await renderOptionListEditorAndWaitForSpinnerToBeRemoved({ - props: { component: { ...mockComponent, options: undefined } }, - }); + await renderOptionListEditorAndWaitForSpinnerToBeRemoved(); expect(getOptionModalButton()).toBeInTheDocument(); }); it('should open Dialog', async () => { const user = userEvent.setup(); - await renderOptionListEditorAndWaitForSpinnerToBeRemoved({ - props: { component: { ...mockComponent, options: undefined } }, - }); + await renderOptionListEditorAndWaitForSpinnerToBeRemoved(); await user.click(getOptionModalButton()); @@ -132,13 +161,11 @@ describe('OptionListEditor', () => { it('should close Dialog', async () => { const user = userEvent.setup(); - await renderOptionListEditorAndWaitForSpinnerToBeRemoved({ - props: { component: { ...mockComponent, options: undefined } }, - }); - + await renderOptionListEditorAndWaitForSpinnerToBeRemoved(); await user.click(getOptionModalButton()); - await user.click(screen.getByRole('button', { name: 'close modal' })); // Todo: Replace "close modal" with defaultDialogProps.closeButtonTitle when we upgrade to Designsystemet v1 + expect(screen.getByRole('dialog')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'close modal' })); // Todo: Replace "close modal" with defaultDialogProps.closeButtonTitle when we upgrade to Designsystemet v1 expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); @@ -147,13 +174,10 @@ describe('OptionListEditor', () => { const doReloadPreview = jest.fn(); await renderOptionListEditorAndWaitForSpinnerToBeRemoved({ previewContextProps: { doReloadPreview }, - props: { component: { ...mockComponent, options: undefined } }, }); await user.click(getOptionModalButton()); - const textBox = screen.getByRole('textbox', { - name: textMock('code_list_editor.description_item', { number: 2 }), - }); + const textBox = getTextBoxInput(2); await user.type(textBox, 'test'); await user.tab(); @@ -162,9 +186,7 @@ describe('OptionListEditor', () => { it('should call updateOptionList with correct parameters when closing Dialog', async () => { const user = userEvent.setup(); - await renderOptionListEditorAndWaitForSpinnerToBeRemoved({ - props: { component: { ...mockComponent, options: undefined } }, - }); + await renderOptionListEditorAndWaitForSpinnerToBeRemoved(); const expectedResultAfterEdit: Option[] = [ { value: 'test', label: 'label text', description: 'description', helpText: 'help text' }, { value: 2, label: 'label number', description: 'test', helpText: null }, @@ -172,9 +194,7 @@ describe('OptionListEditor', () => { ]; await user.click(getOptionModalButton()); - const textBox = screen.getByRole('textbox', { - name: textMock('code_list_editor.description_item', { number: 2 }), - }); + const textBox = getTextBoxInput(2); await user.type(textBox, 'test'); await user.tab(); @@ -182,31 +202,61 @@ describe('OptionListEditor', () => { expect(queriesMock.updateOptionList).toHaveBeenCalledWith( org, app, - mockComponent.optionsId, + componentWithOptionsId.optionsId, expectedResultAfterEdit, ); }); + + it('should show placeholder for option label when option list label is empty', async () => { + const apiResultWithEmptyLabel: OptionsList = [ + { value: true, label: '', description: null, helpText: null }, + ]; + await renderOptionListEditorAndWaitForSpinnerToBeRemoved({ + queries: { + getOptionList: jest + .fn() + .mockImplementation(() => Promise.resolve(apiResultWithEmptyLabel)), + }, + }); + + expect(screen.getByText(textMock('general.empty_string'))).toBeInTheDocument(); + }); + + it('should call handleComponentChange with correct parameters when removing chosen options', async () => { + const user = userEvent.setup(); + await renderOptionListEditorAndWaitForSpinnerToBeRemoved(); + + const deleteButton = screen.getByRole('button', { name: textMock('general.delete') }); + await user.click(deleteButton); + + expect(handleComponentChange).toHaveBeenCalledTimes(1); + expect(handleComponentChange).toHaveBeenCalledWith({ + ...mockComponent, + options: undefined, + optionsId: undefined, + }); + }); }); }); function getOptionModalButton() { return screen.getByRole('button', { - name: textMock('ux_editor.modal_properties_code_list_button_title_library'), + name: textMock('general.edit'), }); } -function getManualModalButton() { - return screen.getByRole('button', { - name: textMock('ux_editor.modal_properties_code_list_button_title_manual'), +function getTextBoxInput(number: number) { + return screen.getByRole('textbox', { + name: textMock('code_list_editor.description_item', { number }), }); } -const defaultProps: OptionListEditorProps = { - component: mockComponent, - handleComponentChange, -}; - const renderOptionListEditor = ({ previewContextProps = {}, queries = {}, props = {} } = {}) => { + const defaultProps: OptionListEditorProps = { + component: mockComponent, + handleComponentChange, + }; + return renderWithProviders(, { queries: { getOptionList: getOptionListMock, ...queries }, queryClient: createQueryClientMock(), @@ -217,15 +267,15 @@ const renderOptionListEditor = ({ previewContextProps = {}, queries = {}, props const renderOptionListEditorAndWaitForSpinnerToBeRemoved = async ({ previewContextProps = {}, queries = {}, - props = {}, + props = { component: componentWithOptionsId }, } = {}) => { const view = renderOptionListEditor({ previewContextProps, queries, props, }); - await waitForElementToBeRemoved(() => { - return screen.queryByText(textMock('ux_editor.modal_properties_code_list_spinner_title')); - }); + await waitForElementToBeRemoved(() => + screen.queryByText(textMock('ux_editor.modal_properties_code_list_spinner_title')), + ); return view; }; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListEditor.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListEditor.tsx index 9b6a58dc430..1ac15d0f3a3 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListEditor.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListEditor.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { forwardRef } from 'react'; import { useTranslation } from 'react-i18next'; import { StudioSpinner, StudioErrorMessage } from '@studio/components'; import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; @@ -7,25 +7,47 @@ import type { SelectionComponentType } from '../../../../../../../types/FormComp import { useOptionListQuery } from 'app-shared/hooks/queries'; import { LibraryOptionsEditor } from './LibraryOptionsEditor'; import { ManualOptionsEditor } from './ManualOptionsEditor'; +import { handleOptionsChange, resetComponentOptions } from '../../utils/optionsUtils'; export type OptionListEditorProps = Pick< IGenericEditComponent, 'component' | 'handleComponentChange' >; -export function OptionListEditor({ - component, - handleComponentChange, -}: OptionListEditorProps): React.ReactNode { +export const OptionListEditor = forwardRef( + ({ component, handleComponentChange }: OptionListEditorProps, dialogRef): React.ReactNode => { + const handleDelete = () => { + const updatedComponent = resetComponentOptions(component); + handleOptionsChange(updatedComponent, handleComponentChange); + }; + + if (component.options !== undefined) { + return ( + + ); + } + + return ; + }, +); + +type OptionsListResolverProps = { + handleDelete: () => void; + optionsId: string; +}; + +function OptionListResolver({ + handleDelete, + optionsId, +}: OptionsListResolverProps): React.ReactNode { const { t } = useTranslation(); const { org, app } = useStudioEnvironmentParams(); - const { data: optionsList, status } = useOptionListQuery(org, app, component.optionsId); - - if (component.options !== undefined) { - return ( - - ); - } + const { status } = useOptionListQuery(org, app, optionsId); switch (status) { case 'pending': @@ -39,7 +61,9 @@ export function OptionListEditor({ ); case 'success': { - return ; + return ; } } } + +OptionListEditor.displayName = 'OptionListEditor'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListLabels/OptionListLabels.module.css b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListLabels/OptionListLabels.module.css new file mode 100644 index 00000000000..91f2c614c2e --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListLabels/OptionListLabels.module.css @@ -0,0 +1,10 @@ +.label { + font-weight: bold; +} + +.codeListLabels { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: var(--fds-spacing-3); +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListLabels/OptionListLabels.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListLabels/OptionListLabels.tsx new file mode 100644 index 00000000000..dfe940d1210 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListLabels/OptionListLabels.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { StudioParagraph } from '@studio/components'; +import { useConcatOptionsLabels } from '../hooks/useConcatOptionsLabels'; +import type { OptionsList } from 'app-shared/types/api/OptionsLists'; +import classes from './OptionListLabels.module.css'; + +type OptionListLabelsProps = { optionsList: OptionsList; optionsId: string }; + +export function OptionListLabels({ + optionsList, + optionsId, +}: OptionListLabelsProps): React.ReactNode { + const { t } = useTranslation(); + const codeListLabels: string = useConcatOptionsLabels(optionsList); + + return ( + <> + + {optionsId ?? t('ux_editor.modal_properties_code_list_custom_list')} + + + {codeListLabels} + + + ); +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListLabels/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListLabels/index.ts new file mode 100644 index 00000000000..df3c9dfe1ad --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListLabels/index.ts @@ -0,0 +1 @@ +export { OptionListLabels } from './OptionListLabels'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/hooks/useConcatOptionsLabels.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/hooks/useConcatOptionsLabels.ts new file mode 100644 index 00000000000..a2e7b87cbda --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/hooks/useConcatOptionsLabels.ts @@ -0,0 +1,9 @@ +import type { Option } from 'app-shared/types/Option'; +import { useTranslation } from 'react-i18next'; + +export function useConcatOptionsLabels(optionsList: Option[]): string { + const { t } = useTranslation(); + return optionsList + .map((option: Option) => `${option.label || t('general.empty_string')}`) + .join(' | '); +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/OptionListSelector.module.css b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/OptionListSelector.module.css index a46035a926a..e9ddaa4abe3 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/OptionListSelector.module.css +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/OptionListSelector.module.css @@ -1,4 +1,25 @@ -.modalTrigger { - width: 50%; - min-width: 12rem; +.modal[open] { + --code-list-modal-min-width: min(40rem, 100%); + --code-list-modal-height: min(30rem, 100%); + + min-width: var(--code-list-modal-min-width); + height: var(--code-list-modal-height); + border-radius: var(--fds-border_radius-xxlarge); +} + +.content { + height: 100%; + display: flex; + flex-direction: column; + gap: var(--fds-spacing-2); +} + +.card:hover { + background-color: var(--fds-semantic-surface-neutral-subtle); + cursor: pointer; +} + +.header { + padding-inline: var(--fds-spacing-4); + padding-block: var(--fds-spacing-2); } diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/OptionListSelector.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/OptionListSelector.test.tsx index 12efb70d51f..5c5eb237339 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/OptionListSelector.test.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/OptionListSelector.test.tsx @@ -15,7 +15,7 @@ const optionsIdMock = optionListIdsMock[0]; mockComponent.optionsId = optionsIdMock; const handleComponentChangeMock = jest.fn(); -const getOptionListIds = jest +const getOptionListIdsMock = jest .fn() .mockImplementation(() => Promise.resolve(optionListIdsMock)); @@ -29,6 +29,19 @@ describe('OptionListSelector', () => { expect(screen.getByText(textMock('ux_editor.modal_properties_code_list'))).toBeInTheDocument(); }); + it('should not render if the list is empty', async () => { + renderOptionListSelector({ + getOptionListIds: jest.fn().mockImplementation(() => Promise.resolve([])), + }); + await waitForElementToBeRemoved( + screen.queryByText(textMock('ux_editor.modal_properties_loading')), + ); + + expect( + screen.queryByText(textMock('ux_editor.modal_properties_code_list')), + ).not.toBeInTheDocument(); + }); + it('should call onChange when option list changes', async () => { const user = userEvent.setup(); renderOptionListSelector(); @@ -65,9 +78,7 @@ describe('OptionListSelector', () => { it('should render returned error message if option list endpoint returns an error', async () => { renderOptionListSelector({ - queries: { - getOptionListIds: jest.fn().mockImplementation(() => Promise.reject(new Error('Error'))), - }, + getOptionListIds: jest.fn().mockImplementation(() => Promise.reject(new Error('Error'))), }); expect(await screen.findByText('Error')).toBeInTheDocument(); @@ -75,13 +86,13 @@ describe('OptionListSelector', () => { it('should render standard error message if option list endpoint throws an error without specified error message', async () => { renderOptionListSelector({ - queries: { - getOptionListIds: jest.fn().mockImplementation(() => Promise.reject()), - }, + getOptionListIds: jest.fn().mockImplementation(() => Promise.reject()), }); expect( - await screen.findByText(textMock('ux_editor.modal_properties_error_message')), + await screen.findByText( + textMock('ux_editor.modal_properties_fetch_option_list_ids_error_message'), + ), ).toBeInTheDocument(); }); }); @@ -94,10 +105,12 @@ function getDropdownOption(): HTMLElement { return screen.getByText(optionListIdsMock[0]); } -function renderOptionListSelector({ queries = {}, componentProps = {} } = {}) { +function renderOptionListSelector({ + getOptionListIds = getOptionListIdsMock, + componentProps = {}, +} = {}) { return renderWithProviders( , { - queries: { getOptionListIds, ...queries }, + queries: { getOptionListIds }, queryClient: createQueryClientMock(), }, ); diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/OptionListSelector.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/OptionListSelector.tsx index dcc1657473d..1aeb91dc15d 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/OptionListSelector.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/OptionListSelector.tsx @@ -1,40 +1,34 @@ -import React from 'react'; +import React, { createRef } from 'react'; import { ErrorMessage } from '@digdir/designsystemet-react'; import type { IGenericEditComponent } from '../../../../../componentConfig'; import type { SelectionComponentType } from '../../../../../../../types/FormComponent'; import { useOptionListIdsQuery } from '../../../../../../../hooks/queries/useOptionListIdsQuery'; import { useTranslation } from 'react-i18next'; -import { StudioDropdownMenu, StudioSpinner } from '@studio/components'; +import { + StudioButton, + StudioCard, + StudioModal, + StudioParagraph, + StudioSpinner, +} from '@studio/components'; import { BookIcon } from '@studio/icons'; import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; +import { handleOptionsChange, updateComponentOptionsId } from '../../utils/optionsUtils'; import classes from './OptionListSelector.module.css'; -type OptionListSelectorProps = { - setComponentHasOptionList: (value: boolean) => void; -} & Pick, 'component' | 'handleComponentChange'>; +type OptionListSelectorProps = Pick< + IGenericEditComponent, + 'component' | 'handleComponentChange' +>; -export function OptionListSelector({ - setComponentHasOptionList, +export function OptionListSelector({ component, handleComponentChange, -}: OptionListSelectorProps): React.ReactNode { +}: OptionListSelectorProps): React.ReactNode { const { t } = useTranslation(); const { org, app } = useStudioEnvironmentParams(); const { data: optionListIds, status, error } = useOptionListIdsQuery(org, app); - const handleOptionsIdChange = (optionsId: string) => { - if (component.options) { - delete component.options; - } - - handleComponentChange({ - ...component, - optionsId, - }); - - setComponentHasOptionList(true); - }; - switch (status) { case 'pending': return ( @@ -46,14 +40,17 @@ export function OptionListSelector({ case 'error': return ( - {error instanceof Error ? error.message : t('ux_editor.modal_properties_error_message')} + {error instanceof Error + ? error.message + : t('ux_editor.modal_properties_fetch_option_list_ids_error_message')} ); case 'success': return ( ); } @@ -61,35 +58,65 @@ export function OptionListSelector({ type OptionListSelectorWithDataProps = { optionListIds: string[]; - handleOptionsIdChange: (optionsId: string) => void; -}; +} & Pick, 'component' | 'handleComponentChange'>; function OptionListSelectorWithData({ + component, + handleComponentChange, optionListIds, - handleOptionsIdChange, }: OptionListSelectorWithDataProps): React.ReactNode { const { t } = useTranslation(); + const modalRef = createRef(); + + const handleClick = () => { + modalRef.current?.showModal(); + }; if (!optionListIds.length) return null; return ( - + + {t('ux_editor.modal_properties_code_list')} + + } + > + + + + ); +} + +type modalContentProps = OptionListSelectorWithDataProps; - variant: 'secondary', - children: t('ux_editor.modal_properties_code_list'), - }} - > - {optionListIds.map((optionListId: string) => ( - } - onClick={() => handleOptionsIdChange(optionListId)} - > - {optionListId} - +function ModalContent({ + optionListIds, + component, + handleComponentChange, +}: modalContentProps): React.ReactNode { + const handleClick = (optionsId: string) => { + const updatedComponent = updateComponentOptionsId(component, optionsId); + handleOptionsChange(updatedComponent, handleComponentChange); + }; + + return ( + <> + {optionListIds.map((optionsId: string) => ( + handleClick(optionsId)}> + + {optionsId} + + ))} - + ); } diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListUploader/OptionListUploader.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListUploader/OptionListUploader.test.tsx index 39785642cd8..9b137684382 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListUploader/OptionListUploader.test.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListUploader/OptionListUploader.test.tsx @@ -80,7 +80,6 @@ function getFileInput() { function renderEditOptionList({ queries = {}, componentProps = {} } = {}) { return renderWithProviders( = { - setComponentHasOptionList: (value: boolean) => void; -} & Pick, 'component' | 'handleComponentChange'>; +type EditOptionListProps = Pick< + IGenericEditComponent, + 'component' | 'handleComponentChange' +>; -export function OptionListUploader({ - setComponentHasOptionList, - component, - handleComponentChange, -}: EditOptionListProps) { +export function OptionListUploader({ component, handleComponentChange }: EditOptionListProps) { const { t } = useTranslation(); const { org, app } = useStudioEnvironmentParams(); const { data: optionListIds } = useOptionListIdsQuery(org, app); @@ -28,19 +26,6 @@ export function OptionListUploader({ hideDefaultError: (error: AxiosError) => isErrorUnknown(error), }); - const handleOptionsIdChange = (optionsId: string) => { - if (component.options) { - delete component.options; - } - - handleComponentChange({ - ...component, - optionsId, - }); - - setComponentHasOptionList(true); - }; - const onSubmit = (file: File) => { const fileNameError = FileNameUtils.findFileNameError( FileNameUtils.removeExtension(file.name), @@ -56,9 +41,12 @@ export function OptionListUploader({ const handleUpload = (file: File) => { uploadOptionList(file, { onSuccess: () => { - handleOptionsIdChange(FileNameUtils.removeExtension(file.name)); + const optionsId = FileNameUtils.removeExtension(file.name); + const updatedComponent = updateComponentOptionsId(component, optionsId); + handleOptionsChange(updatedComponent, handleComponentChange); toast.success(t('ux_editor.modal_properties_code_list_upload_success')); }, + onError: (error: AxiosError) => { if (isErrorUnknown(error)) { toast.error(t('ux_editor.modal_properties_code_list_upload_generic_error')); diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/OptionTabs.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/OptionTabs.test.tsx index b6598095074..abd21af8abf 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/OptionTabs.test.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/OptionTabs.test.tsx @@ -14,16 +14,16 @@ import { addFeatureFlagToLocalStorage, FeatureFlag } from 'app-shared/utils/feat // Test data: const mockComponent = componentMocks[ComponentType.RadioButtons]; -describe('EditOptions', () => { +describe('OptionTabs', () => { afterEach(() => jest.clearAllMocks()); it('should render component', () => { - renderEditOptions(); + renderOptionTabs(); expect(screen.getByText(textMock('ux_editor.options.tab_code_list'))).toBeInTheDocument(); }); it('should show code list input by default when neither options nor optionId are set', () => { - renderEditOptions({ + renderOptionTabs({ componentProps: { options: undefined, optionsId: undefined }, }); expect( @@ -35,7 +35,7 @@ describe('EditOptions', () => { it('should show code list tab when component has optionsId defined matching an optionId in optionsID-list', () => { const optionsId = 'optionsId'; - renderEditOptions({ + renderOptionTabs({ componentProps: { optionsId, options: undefined, @@ -52,7 +52,7 @@ describe('EditOptions', () => { it('should show referenceId tab when component has optionsId defined not matching an optionId in optionsId-list', () => { const optionsId = 'optionsId'; - renderEditOptions({ + renderOptionTabs({ componentProps: { optionsId, options: undefined, @@ -70,7 +70,7 @@ describe('EditOptions', () => { it('should switch to code list tab when clicking code list tab', async () => { const user = userEvent.setup(); const optionsId = 'optionsId'; - renderEditOptions({ + renderOptionTabs({ componentProps: { optionsId, }, @@ -98,7 +98,7 @@ describe('EditOptions', () => { it('should switch to referenceId input clicking referenceId tab', async () => { const user = userEvent.setup(); - renderEditOptions({ + renderOptionTabs({ componentProps: { options: [] }, }); @@ -114,26 +114,25 @@ describe('EditOptions', () => { ).toBeInTheDocument(); }); - it('should render EditOptionChoice when featureFlag is enabled', async () => { + it('should render the preview title for manual options when manual options are set and featureFlag is enabled', async () => { addFeatureFlagToLocalStorage(FeatureFlag.OptionListEditor); - const optionsId = 'optionsId'; - renderEditOptions({ + const options = [{ value: '1', label: 'label 1' }]; + renderOptionTabs({ componentProps: { - optionsId, - options: undefined, + optionsId: undefined, + options, }, - optionListIds: [optionsId], }); expect( - await screen.findByRole('button', { name: textMock('ux_editor.options.option_remove_text') }), + await screen.findByText(textMock('ux_editor.modal_properties_code_list_custom_list')), ).toBeInTheDocument(); }); it('should switch to referenceId input clicking referenceId tab', async () => { addFeatureFlagToLocalStorage(FeatureFlag.OptionListEditor); const user = userEvent.setup(); - renderEditOptions({ + renderOptionTabs({ componentProps: { options: [] }, }); @@ -150,19 +149,19 @@ describe('EditOptions', () => { }); }); -type renderEditOptionsProps = { +type renderOptionTabsProps = { componentProps?: Partial>; handleComponentChange?: () => void; queries?: Partial; optionListIds?: string[]; }; -function renderEditOptions({ +function renderOptionTabs({ componentProps = {}, handleComponentChange = jest.fn(), queries = {}, optionListIds = [], -}: renderEditOptionsProps = {}) { +}: renderOptionTabsProps = {}) { return renderWithProviders( { - it('should return SelectedOptionsType.Unknown if both options and optionsId are set', () => { - const codeListId = 'codeListId'; - const options: IOption[] = [{ label: 'label1', value: 'value1' }]; - const optionListIds = ['codeListId']; - const result = getSelectedOptionsType(codeListId, options, optionListIds); - expect(result).toEqual(SelectedOptionsType.Unknown); - }); +// Test data: +const mockedComponent: FormItem = + componentMocks[ComponentType.RadioButtons]; - it('should return SelectedOptionsType.CodeList if options is not set and codeListId is in optionListIds', () => { - const codeListId = 'codeListId'; - const options = undefined; - const optionListIds = ['codeListId']; - const result = getSelectedOptionsType(codeListId, options, optionListIds); - expect(result).toEqual('codelist'); - }); +describe('optionsUtils', () => { + describe('getSelectedOptionsType', () => { + it('should return SelectedOptionsType.Unknown if both options and optionsId are set', () => { + const codeListId = 'codeListId'; + const options: OptionsList = [{ label: 'label1', value: 'value1' }]; + const optionListIds = ['codeListId']; + const result = getSelectedOptionsType(codeListId, options, optionListIds); + expect(result).toEqual(SelectedOptionsType.Unknown); + }); - it('should return SelectedOptionsType.ReferenceId if options is not set and codeListId is not in optionListIds', () => { - const codeListId = 'codeListId'; - const options = undefined; - const optionListIds = ['anotherCodeListId']; - const result = getSelectedOptionsType(codeListId, options, optionListIds); - expect(result).toEqual(SelectedOptionsType.ReferenceId); - }); + it('should return SelectedOptionsType.CodeList if options is not set and codeListId is in optionListIds', () => { + const codeListId = 'codeListId'; + const options = undefined; + const optionListIds = [codeListId]; + const result = getSelectedOptionsType(codeListId, options, optionListIds); + expect(result).toEqual(SelectedOptionsType.CodeList); + }); - it('should use default value for optionListIds if it is not provided', () => { - const codeListId = ''; - const options = undefined; - const result = getSelectedOptionsType(codeListId, options); - expect(result).toEqual(SelectedOptionsType.CodeList); + it('should return SelectedOptionsType.ReferenceId if options is not set and codeListId is not in optionListIds', () => { + const codeListId = 'codeListId'; + const options = undefined; + const optionListIds = ['anotherCodeListId']; + const result = getSelectedOptionsType(codeListId, options, optionListIds); + expect(result).toEqual(SelectedOptionsType.ReferenceId); + }); + + it('should use default value for optionListIds if it is not provided', () => { + const codeListId = ''; + const options = undefined; + const result = getSelectedOptionsType(codeListId, options); + expect(result).toEqual(SelectedOptionsType.CodeList); + }); + + it('should return SelectedOptionsType.CodeList if options is set and codeListId is not set', () => { + const codeListId = undefined; + const options = [{ label: 'label1', value: 'value1' }]; + const optionListIds = ['codeListId']; + const result = getSelectedOptionsType(codeListId, options, optionListIds); + expect(result).toEqual(SelectedOptionsType.CodeList); + }); }); - it('should return SelectedOptionsType.CodeList if options is set and codeListId is not set', () => { - const codeListId = undefined; - const options = [{ label: 'label1', value: 'value1' }]; - const optionListIds = ['anotherCodeListId']; - const result = getSelectedOptionsType(codeListId, options, optionListIds); - expect(result).toEqual(SelectedOptionsType.CodeList); + describe('getSelectedOptionsTypeWithManualSupport', () => { + it('should return SelectedOptionsType.Unknown if both options and optionsId are set', () => { + const codeListId = 'codeListId'; + const options: OptionsList = [{ label: 'label1', value: 'value1' }]; + const optionListIds = ['codeListId']; + const result = getSelectedOptionsTypeWithManualSupport(codeListId, options, optionListIds); + expect(result).toEqual(SelectedOptionsType.Unknown); + }); + + it('should return SelectedOptionsType.CodeList if options is not set and codeListId is in optionListIds', () => { + const codeListId = 'codeListId'; + const options = undefined; + const optionListIds = [codeListId]; + const result = getSelectedOptionsTypeWithManualSupport(codeListId, options, optionListIds); + expect(result).toEqual(SelectedOptionsType.CodeList); + }); + + it('should return SelectedOptionsType.ReferenceId if options is not set and codeListId is not in optionListIds', () => { + const codeListId = 'codeListId'; + const options = undefined; + const optionListIds = ['anotherCodeListId']; + const result = getSelectedOptionsTypeWithManualSupport(codeListId, options, optionListIds); + expect(result).toEqual(SelectedOptionsType.ReferenceId); + }); + + it('should return SelectedOptionsType.Manual if options is set and codeListId is not set', () => { + const codeListId = undefined; + const options = [{ label: 'label1', value: 'value1' }]; + const optionListIds = ['anotherCodeListId']; + const result = getSelectedOptionsTypeWithManualSupport(codeListId, options, optionListIds); + expect(result).toEqual(SelectedOptionsType.Manual); + }); + + it('should return SelectedOptionsType.Manual if options is set and codeListId is not set, even if options has length 0', () => { + const codeListId = undefined; + const options = []; + const optionListIds = ['codeListId']; + const result = getSelectedOptionsTypeWithManualSupport(codeListId, options, optionListIds); + expect(result).toEqual(SelectedOptionsType.Manual); + }); + + it('should use default value for optionListIds if it is not provided', () => { + const codeListId = ''; + const options = undefined; + const result = getSelectedOptionsTypeWithManualSupport(codeListId, options); + expect(result).toEqual(SelectedOptionsType.CodeList); + }); }); -}); -describe('getSelectedOptionsTypeV1', () => { - it('should return SelectedOptionsType.Unknown if both options and optionsId are set', () => { - const codeListId = 'codeListId'; - const options: IOption[] = [{ label: 'label1', value: 'value1' }]; - const optionListIds = ['codeListId']; - const result = getSelectedOptionsTypeWithManualSupport(codeListId, options, optionListIds); - expect(result).toEqual(SelectedOptionsType.Unknown); + describe('componentUsesDynamicCodeList', () => { + it('should return false if codeListId is set to an empty string', () => { + const codeListId = ''; + const optionListIds = ['codeListId']; + expect(componentUsesDynamicCodeList(codeListId, optionListIds)).toEqual(false); + }); + + it('should return false if codeListId is in optionListIds', () => { + const codeListId = 'codeListId'; + const optionListIds = [codeListId]; + expect(componentUsesDynamicCodeList(codeListId, optionListIds)).toEqual(false); + }); + + it('should return true if codeListId is not in optionListIds', () => { + const codeListId = 'codeListId'; + const optionListIds = ['anotherCodeListId']; + expect(componentUsesDynamicCodeList(codeListId, optionListIds)).toEqual(true); + }); }); - it('should return SelectedOptionsType.CodeList if options is not set and codeListId is in optionListIds', () => { - const codeListId = 'codeListId'; - const options = undefined; - const optionListIds = ['codeListId']; - const result = getSelectedOptionsTypeWithManualSupport(codeListId, options, optionListIds); - expect(result).toEqual('codelist'); + describe('hasOptionListChanged', () => { + it('should return false if the optionList has not changed', () => { + const oldOptions: OptionsList = [{ label: 'label1', value: 'value1' }]; + const newOptions: OptionsList = [{ label: 'label1', value: 'value1' }]; + expect(hasOptionListChanged(oldOptions, newOptions)).toEqual(false); + }); + + it('should return true if the optionList has changed', () => { + const oldOptions: OptionsList = [{ label: 'label1', value: 'value1' }]; + const newOptions: OptionsList = [{ label: 'new label', value: 'new value' }]; + expect(hasOptionListChanged(oldOptions, newOptions)).toEqual(true); + }); }); - it('should return SelectedOptionsType.ReferenceId if options is not set and codeListId is not in optionListIds', () => { - const codeListId = 'codeListId'; - const options = undefined; - const optionListIds = ['anotherCodeListId']; - const result = getSelectedOptionsTypeWithManualSupport(codeListId, options, optionListIds); - expect(result).toEqual(SelectedOptionsType.ReferenceId); + describe('handleOptionsChange', () => { + it('should call handleComponentChange with the updated component', () => { + const handleComponentChange = jest.fn(); + handleOptionsChange({ ...mockedComponent }, handleComponentChange); + expect(handleComponentChange).toHaveBeenCalledTimes(1); + expect(handleComponentChange).toHaveBeenCalledWith(mockedComponent); + }); }); - it('should return SelectedOptionsType.Manual if options is set and codeListId is not set', () => { - const codeListId = undefined; - const options = [{ label: 'label1', value: 'value1' }]; - const optionListIds = ['anotherCodeListId']; - const result = getSelectedOptionsTypeWithManualSupport(codeListId, options, optionListIds); - expect(result).toEqual(SelectedOptionsType.Manual); + describe('resetComponentOptions', () => { + it('should set options ID and options on the returned object to undefined', () => { + expect(resetComponentOptions({ ...mockedComponent })).toStrictEqual({ + ...mockedComponent, + options: undefined, + optionsId: undefined, + }); + }); }); - it('should return SelectedOptionsType.Manual if options is set and codeListId is not set, even if options has length 0', () => { - const codeListId = undefined; - const options = []; - const optionListIds = ['anotherCodeListId']; - const result = getSelectedOptionsTypeWithManualSupport(codeListId, options, optionListIds); - expect(result).toEqual(SelectedOptionsType.Manual); + describe('updateComponentOptionsId', () => { + it('should update options ID on the returned object', () => { + const optionsId: string = 'new-id'; + expect(updateComponentOptionsId(mockedComponent, optionsId)).toStrictEqual({ + ...mockedComponent, + optionsId, + options: undefined, + }); + }); }); - it('should use default value for optionListIds if it is not provided', () => { - const codeListId = ''; - const options = undefined; - const result = getSelectedOptionsTypeWithManualSupport(codeListId, options); - expect(result).toEqual(SelectedOptionsType.CodeList); + describe('updateComponentOptions', () => { + it('should update options on the returned object', () => { + const options: OptionsList = [{ label: 'new-label', value: 'new-value' }]; + expect(updateComponentOptions(mockedComponent, options)).toStrictEqual({ + ...mockedComponent, + optionsId: undefined, + options, + }); + }); }); -}); -describe('componentUsesDynamicCodeList', () => { - it('should return false if codeListId is not set', () => { - const codeListId = ''; - const optionListIds = ['codeListId']; - const result = componentUsesDynamicCodeList(codeListId, optionListIds); - expect(result).toEqual(false); + describe('IsOptionsIdReferenceId', () => { + it('should return true if options ID is a string and options ID is not from library', () => { + const optionListIds: string[] = ['test1', 'test2']; + const optionsId = 'another-id'; + expect(isOptionsIdReferenceId(optionListIds, optionsId)).toEqual(true); + }); + + it('should return false if options is undefined', () => { + const optionListIds: string[] = ['test1', 'test2']; + const optionsId = undefined; + expect(isOptionsIdReferenceId(optionListIds, optionsId)).toEqual(false); + }); + + it('should return false if options ID is from library', () => { + const optionListIds: string[] = ['test1', 'test2']; + const optionsId: string = 'test1'; + expect(isOptionsIdReferenceId(optionListIds, optionsId)).toEqual(false); + }); }); - it('should return false if codeListId is in optionListIds', () => { - const codeListId = 'codeListId'; - const optionListIds = ['codeListId']; - const result = componentUsesDynamicCodeList(codeListId, optionListIds); - expect(result).toEqual(false); + describe('isOptionsModifiable', () => { + it('should return true if options ID is a string and options ID is from library', () => { + const optionListIds: string[] = ['test1', 'test2']; + const optionsId: string = 'test1'; + const options: OptionsList = [{ value: 'value', label: 'label' }]; + expect(isOptionsModifiable(optionListIds, optionsId, options)).toEqual(true); + }); + + it('should return true if options is set on the component', () => { + const optionListIds: string[] = []; + const optionsId = ''; + const options: OptionsList = []; + expect(isOptionsModifiable(optionListIds, optionsId, options)).toEqual(true); + }); + + it('should return false if options ID and options are undefined', () => { + const optionListIds: string[] = ['test1', 'test2']; + const optionsId = undefined; + const options: OptionsList = undefined; + expect(isOptionsModifiable(optionListIds, optionsId, options)).toEqual(false); + }); + + it('should return false if options ID is not from library', () => { + const optionListIds: string[] = ['test1', 'test2']; + const optionsId = 'another-id'; + const options: OptionsList = undefined; + expect(isOptionsModifiable(optionListIds, optionsId, options)).toEqual(false); + }); }); - it('should return true if codeListId is not in optionListIds', () => { - const codeListId = 'codeListId'; - const optionListIds = ['anotherCodeListId']; - const result = componentUsesDynamicCodeList(codeListId, optionListIds); - expect(result).toEqual(true); + describe('isInitialOptionsSet', () => { + it('should return true if previousOptions is false and currentOptions is truthy', () => { + const previousOptions = undefined; + const currentOptions: OptionsList = []; + expect(isInitialOptionsSet(previousOptions, currentOptions)).toEqual(true); + }); + + it('should return false if previousOptions is truthy', () => { + const previousOptions = []; + const currentOptions: OptionsList = [{ value: 'value', label: 'label' }]; + expect(isInitialOptionsSet(previousOptions, currentOptions)).toEqual(false); + }); + + it('should return false if currentOptions is undefined', () => { + const previousOptions = []; + const currentOptions = undefined; + + expect(isInitialOptionsSet(previousOptions, currentOptions)).toEqual(false); + }); }); }); diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/utils/optionsUtils.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/utils/optionsUtils.ts index 8f4e7e9249a..e196aa08233 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/utils/optionsUtils.ts +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/utils/optionsUtils.ts @@ -1,5 +1,9 @@ import { SelectedOptionsType } from '../../../../../../components/config/editModal/EditOptions/EditOptions'; -import type { IOption } from '../../../../../../types/global'; +import type { OptionsList } from 'app-shared/types/api/OptionsLists'; +import type { FormItem } from '../../../../../../types/FormItem'; +import type { FormComponent, SelectionComponentType } from '../../../../../../types/FormComponent'; +import type { FormContainer } from '../../../../../../types/FormContainer'; +import { ObjectUtils } from '@studio/pure-functions'; export const componentUsesDynamicCodeList = ( codeListId: string, @@ -14,7 +18,7 @@ export const componentUsesDynamicCodeList = ( export function getSelectedOptionsType( codeListId: string | undefined, - options: IOption[] | undefined, + options: OptionsList | undefined, optionListIds: string[] = [], ): SelectedOptionsType { /** It is not permitted for a component to have both options and optionsId set on the same component. */ @@ -30,7 +34,7 @@ export function getSelectedOptionsType( // Todo: Remove once featureFlag "optionListEditor" is removed. export function getSelectedOptionsTypeWithManualSupport( codeListId: string | undefined, - options: IOption[] | undefined, + options: OptionsList | undefined, optionListIds: string[] = [], ): SelectedOptionsType { /** It is not permitted for a component to have both options and optionsId set on the same component. */ @@ -46,3 +50,88 @@ export function getSelectedOptionsTypeWithManualSupport( ? SelectedOptionsType.ReferenceId : SelectedOptionsType.CodeList; } + +export function hasOptionListChanged(oldOptions: OptionsList, newOptions: OptionsList): boolean { + return JSON.stringify(oldOptions) !== JSON.stringify(newOptions); +} + +export function handleOptionsChange( + updatedComponent: FormItem, + handleComponentChange: (item: FormContainer | FormComponent) => void, +): void { + handleComponentChange(updatedComponent); +} + +export function resetComponentOptions( + component: FormItem, +): FormItem { + const newComponent: FormItem = ObjectUtils.deepCopy(component); + + newComponent.optionsId = undefined; + newComponent.options = undefined; + + return newComponent; +} + +export function updateComponentOptionsId( + component: FormItem, + optionsId: string, +): FormItem { + let newComponent: FormItem = ObjectUtils.deepCopy(component); + + newComponent = clearOppositeOptionSetting(newComponent, 'optionsId'); + newComponent.optionsId = optionsId; + + return newComponent; +} + +export function updateComponentOptions( + component: FormItem, + options: OptionsList, +): FormItem { + let newComponent: FormItem = ObjectUtils.deepCopy(component); + + newComponent = clearOppositeOptionSetting(newComponent, 'options'); + newComponent.options = options; + + return newComponent; +} + +function clearOppositeOptionSetting( + component: FormItem, + optionToKeep: 'options' | 'optionsId', +) { + if (optionToKeep === 'optionsId') { + component.options = undefined; + } else if (optionToKeep === 'options') { + component.optionsId = undefined; + } + + return component; +} + +export function isOptionsIdReferenceId( + optionListIds: string[], + optionsId: undefined | string, +): boolean { + return !!optionsId && !isOptionsIdFromLibrary(optionListIds, optionsId); +} + +export function isOptionsModifiable( + optionListIds: string[], + optionsId: undefined | string, + options: undefined | OptionsList, +): boolean { + return (!!optionsId && isOptionsIdFromLibrary(optionListIds, optionsId)) || !!options; +} + +function isOptionsIdFromLibrary(optionListIds: string[], optionsId: undefined | string): boolean { + return optionListIds.some((id: string) => id === optionsId); +} + +export function isInitialOptionsSet( + previousOptions: OptionsList, + currentOptions: OptionsList, +): boolean { + return !previousOptions && !!currentOptions; +}