Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add codeListEditor in config options #13953

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
2 changes: 1 addition & 1 deletion frontend/dashboard/components/RepoList/RepoList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export const RepoList = ({
},
{
accessor: 'description',
heading: t('dashboard.description'),
heading: t('general.description'),
sortable: true,
},
{
Expand Down
12 changes: 10 additions & 2 deletions frontend/language/src/nb.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,6 @@
"dashboard.created_by": "Opprettet av",
"dashboard.creating_your_service": "Oppretter appen din",
"dashboard.data_models": "Datamodeller",
"dashboard.description": "Beskrivelse",
"dashboard.edit_app": "Endre {{appName}} i Studio",
"dashboard.error_getting_organization_data.message": "Det oppsto en feil da vi skulle hente de organisasjonene som trengs for å kjøre appen.",
"dashboard.error_getting_organization_data.title": "Kunne ikke laste inn organisasjoner",
Expand Down Expand Up @@ -260,6 +259,7 @@
"general.date_time_format": "{{date}} kl. {{time}}",
"general.delete": "Slett",
"general.delete_item": "Slett {{item}}",
"general.description": "Beskrivelse",
"general.edit": "Endre",
"general.empty_string": "Tom tekst",
"general.error_message": "Det har oppstått en feil. Hvis problemet fortsetter, <a>ta kontakt med oss</a>.",
Expand All @@ -275,6 +275,7 @@
"general.loading": "Laster...",
"general.next": "Neste",
"general.no_options": "Ingen alternativer tilgjengelige",
"general.option": "Alternativ",
"general.options": "Alternativer",
"general.page": "Side",
"general.page_error_message": "Vi vet ikke helt hva, men <a>ta kontakt med oss</a>, så graver vi i det sammen.",
Expand Down Expand Up @@ -1456,8 +1457,15 @@
"ux_editor.modal_header_type_helper": "Velg titteltype",
"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_delete_item": "Slett alternativ {{number}}",
"ux_editor.modal_properties_code_list_empty": "Kodelisten er tom.",
"ux_editor.modal_properties_code_list_helper": "Velg kodeliste",
"ux_editor.modal_properties_code_list_id": "Kodeliste-ID",
"ux_editor.modal_properties_code_list_item_description": "Beskrivelse for alternativ {{number}}",
"ux_editor.modal_properties_code_list_item_helpText": "Hjelpetekst for alternativ {{number}}",
standeren marked this conversation as resolved.
Show resolved Hide resolved
"ux_editor.modal_properties_code_list_item_label": "Ledetekst for alternativ {{number}}",
"ux_editor.modal_properties_code_list_item_value": "Verdi for alternativ {{number}}",
"ux_editor.modal_properties_code_list_open_editor": "Åpne redigeringsverktøy",
"ux_editor.modal_properties_code_list_read_more": "<0 href=\"{{optionsDocs}}\" >Les mer om kodelister</0>",
"ux_editor.modal_properties_code_list_read_more_dynamic": "<0 href=\"{{optionsDocs}}\" >Les mer om dynamiske kodelister</0>",
"ux_editor.modal_properties_code_list_read_more_static": "<0 href=\"{{optionsDocs}}\" >Les mer om statiske kodelister</0>",
Expand Down Expand Up @@ -1612,7 +1620,7 @@
"ux_editor.options.codelist_create_info.step4": "Skriv inn kodelisten i tekstfeltet midt på siden. Kodelisten må være i JSON-format.",
"ux_editor.options.codelist_create_info.step5": "Velg \"Commit endringer\".",
"ux_editor.options.codelist_create_info.step6": "Du er nå ferdig i Gitea for denne gang. Gå tilbake til Altinn Studio-fanen, eller klikk på Altinn-logoen øverst til venstre i Gitea for å komme tilbake til Altinn Studio.",
"ux_editor.options.codelist_only": "Denne komponenten støtter kun oppsett med kodelister.",
"ux_editor.options.codelist_only": "Denne komponenten støtter kun oppsett med predefinerte kodelister.",
"ux_editor.options.codelist_referenceId.description": "Her kan du legge til en referanse-ID til en dynamisk kodeliste som er satt opp i koden.",
"ux_editor.options.codelist_referenceId.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.codelist_upload_info.heading": "Steg for å laste opp kodelister manuelt",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export function StudioCodeListEditorRow({
<StudioInputTable.Row>
<TextfieldCell
label={texts.itemValue(number)}
value={item.value}
value={item.value as string}
onChange={handleValueChange}
/>
<TextfieldCell
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export type CodeListItem = {
export type CodeListItem<T extends string | boolean | number = string | boolean | number> = {
description?: string;
helpText?: string;
label: string;
value: string;
value: T;
};
7 changes: 5 additions & 2 deletions frontend/packages/shared/src/api/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ import {
altinn2DelegationsMigrationPath,
imagePath,
addImagePath,
optionListPath,
optionListUploadPath,
optionListUpdatePath,
} from 'app-shared/api/paths';
import type { AddLanguagePayload } from 'app-shared/types/api/AddLanguagePayload';
import type { AddRepoParams } from 'app-shared/types/api';
Expand All @@ -65,6 +66,7 @@ import type { PipelineDeployment } from 'app-shared/types/api/PipelineDeployment
import type { AddLayoutSetResponse } from 'app-shared/types/api/AddLayoutSetResponse';
import type { DataTypesChange } from 'app-shared/types/api/DataTypesChange';
import type { FormLayoutRequest } from 'app-shared/types/api/FormLayoutRequest';
import type { Option } from 'app-shared/types/Option';

const headers = {
Accept: 'application/json',
Expand Down Expand Up @@ -114,7 +116,8 @@ export const updateAppPolicy = (org: string, app: string, payload: Policy) => pu
export const updateAppMetadata = (org: string, app: string, payload: ApplicationMetadata) => put(appMetadataPath(org, app), payload);
export const updateAppConfig = (org: string, app: string, payload: AppConfig) => post(serviceConfigPath(org, app), payload);
export const uploadDataModel = (org: string, app: string, form: FormData) => post<void, FormData>(dataModelsUploadPath(org, app), form, { headers: { 'Content-Type': 'multipart/form-data' } });
export const uploadOptionList = (org: string, app: string, payload: FormData) => post<void, FormData>(optionListPath(org, app), payload, { headers: { 'Content-Type': 'multipart/form-data' } });
export const uploadOptionList = (org: string, app: string, payload: FormData) => post<void, FormData>(optionListUploadPath(org, app), payload, { headers: { 'Content-Type': 'multipart/form-data' } });
export const updateOptionList = (org: string, app: string, optionsListId: string, payload: Option[]) => put(optionListUpdatePath(org, app, optionsListId), payload);
standeren marked this conversation as resolved.
Show resolved Hide resolved
export const upsertTextResources = (org: string, app: string, language: string, payload: ITextResourcesObjectFormat) => put<ITextResourcesObjectFormat>(textResourcesPath(org, app, language), payload);

// Resourceadm
Expand Down
3 changes: 2 additions & 1 deletion frontend/packages/shared/src/api/paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ export const dataModelAddXsdFromRepoPath = (org, app, filePath) => `${basePath}/
export const ruleHandlerPath = (org, app, layoutSetName) => `${basePath}/${org}/${app}/app-development/rule-handler?${s({ layoutSetName })}`; // Get, Post
export const widgetSettingsPath = (org, app) => `${basePath}/${org}/${app}/app-development/widget-settings`; // Get
export const optionListsPath = (org, app) => `${basePath}/${org}/${app}/options/option-lists`; // Get
export const optionListPath = (org, app) => `${basePath}/${org}/${app}/options/upload/`; // Post
export const optionListIdsPath = (org, app) => `${basePath}/${org}/${app}/app-development/option-list-ids`; // Get
export const optionListUpdatePath = (org, app, optionsListId) => `${basePath}/${org}/${app}/options/${optionsListId}`; // Put
export const optionListUploadPath = (org, app) => `${basePath}/${org}/${app}/options/upload`; // Post
export const ruleConfigPath = (org, app, layoutSetName) => `${basePath}/${org}/${app}/app-development/rule-config?${s({ layoutSetName })}`; // Get, Post
export const appMetadataModelIdsPath = (org, app, onlyUnReferenced) => `${basePath}/${org}/${app}/app-development/model-ids?${s({ onlyUnReferenced })}`; // Get
export const dataModelMetadataPath = (org, app, layoutSetName, dataModelName) => `${basePath}/${org}/${app}/app-development/model-metadata?${s({ layoutSetName })}&${s({ dataModelName })}`; // Get
Expand Down
3 changes: 2 additions & 1 deletion frontend/packages/shared/src/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import type { FormLayoutsResponseV3 } from 'app-shared/types/api/FormLayoutsResp
import type { Policy } from 'app-shared/types/Policy';
import type { RepoDiffResponse } from 'app-shared/types/api/RepoDiffResponse';
import type { ExternalImageUrlValidationResponse } from 'app-shared/types/api/ExternalImageUrlValidationResponse';
import type { Option } from 'app-shared/types/Option';

export const getAppMetadataModelIds = (org: string, app: string, onlyUnReferenced: boolean) => get<string[]>(appMetadataModelIdsPath(org, app, onlyUnReferenced));
export const getAppReleases = (owner: string, app: string) => get<AppReleasesResponse>(releasesPath(owner, app, 'Descending'));
Expand All @@ -102,7 +103,7 @@ export const getImageFileNames = (owner: string, app: string) => get<string[]>(g
export const getInstanceIdForPreview = (owner: string, app: string) => get<string>(instanceIdForPreviewPath(owner, app));
export const getLayoutNames = (owner: string, app: string) => get<string[]>(layoutNamesPath(owner, app));
export const getLayoutSets = (owner: string, app: string) => get<LayoutSets>(layoutSetsPath(owner, app));
export const getOptionLists = (owner: string, app: string) => get<string[]>(optionListsPath(owner, app));
export const getOptionLists = (owner: string, app: string) => get<Map<string, Option[]>>(optionListsPath(owner, app));
export const getOptionListIds = (owner: string, app: string) => get<string[]>(optionListIdsPath(owner, app));
export const getOrgList = () => get<OrgList>(orgListUrl());
export const getOrganizations = () => get<Organization[]>(orgsListPath());
Expand Down
9 changes: 9 additions & 0 deletions frontend/packages/shared/src/mocks/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type { Organization } from 'app-shared/types/Organization';
import type { KubernetesDeployment } from 'app-shared/types/api/KubernetesDeployment';
import type { DeploymentsResponse } from 'app-shared/types/api/DeploymentsResponse';
import type { AppRelease } from 'app-shared/types/AppRelease';
import type { Option } from 'app-shared/types/Option';

export const build: Build = {
id: '',
Expand Down Expand Up @@ -244,3 +245,11 @@ export const searchRepositoryResponse: SearchRepositoryResponse = {
totalCount: 0,
totalPages: 0,
};

export const optionListResponse: Map<string, Option[]> = new Map([
[
'key1',
[{ value: 'test', label: 'test label', description: 'description', helpText: 'help text' }],
],
['key2', [{ value: 'test2', label: 'test2 label' }]],
standeren marked this conversation as resolved.
Show resolved Hide resolved
]);
7 changes: 6 additions & 1 deletion frontend/packages/shared/src/mocks/queriesMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
createRepoCommitPayload,
dataModelMetadataResponse,
layoutSets,
optionListResponse,
orgList,
policy,
repoStatus,
Expand All @@ -68,6 +69,7 @@ import type { FormLayoutsResponseV3 } from 'app-shared/types/api/FormLayoutsResp
import type { DeploymentsResponse } from 'app-shared/types/api/DeploymentsResponse';
import type { RepoDiffResponse } from 'app-shared/types/api/RepoDiffResponse';
import type { ExternalImageUrlValidationResponse } from 'app-shared/types/api/ExternalImageUrlValidationResponse';
import type { Option } from 'app-shared/types/Option';

export const queriesMock: ServicesContextProps = {
// Queries
Expand Down Expand Up @@ -102,7 +104,9 @@ export const queriesMock: ServicesContextProps = {
getLayoutNames: jest.fn().mockImplementation(() => Promise.resolve<string[]>([])),
getLayoutSets: jest.fn().mockImplementation(() => Promise.resolve<LayoutSets>(layoutSets)),
getOptionListIds: jest.fn().mockImplementation(() => Promise.resolve<string[]>([])),
getOptionLists: jest.fn().mockImplementation(() => Promise.resolve<string[]>([])),
getOptionLists: jest
.fn()
.mockImplementation(() => Promise.resolve<Map<string, Option[]>>(optionListResponse)),
standeren marked this conversation as resolved.
Show resolved Hide resolved
getOrgList: jest.fn().mockImplementation(() => Promise.resolve<OrgList>(orgList)),
getOrganizations: jest.fn().mockImplementation(() => Promise.resolve<Organization[]>([])),
getRepoMetadata: jest.fn().mockImplementation(() => Promise.resolve<Repository>(repository)),
Expand Down Expand Up @@ -211,6 +215,7 @@ export const queriesMock: ServicesContextProps = {
updateAppPolicy: jest.fn().mockImplementation(() => Promise.resolve()),
updateAppMetadata: jest.fn().mockImplementation(() => Promise.resolve()),
updateAppConfig: jest.fn().mockImplementation(() => Promise.resolve()),
updateOptionList: jest.fn().mockImplementation(() => Promise.resolve()),
uploadDataModel: jest.fn().mockImplementation(() => Promise.resolve<JsonSchema>({})),
uploadOptionList: jest.fn().mockImplementation(() => Promise.resolve()),
upsertTextResources: jest
Expand Down
3 changes: 2 additions & 1 deletion frontend/packages/shared/src/utils/featureToggleUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export type SupportedFeatureFlags =
| 'multipleDataModelsPerTask'
| 'exportForm'
| 'subform'
| 'summary2';
| 'summary2'
| 'codeListEditor';

/*
* Please add all the features that you want to be toggle on by default here.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.manualTabModal[open] {
max-width: unset;
width: min(80vw, 64rem);
}

.modalTrigger {
margin-top: var(--fds-spacing-2);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { ComponentType } from 'app-shared/types/ComponentType';
import type { FormComponent } from '../../../../../types/FormComponent';
import { createQueryClientMock } from 'app-shared/mocks/queryClientMock';
import {
type ServicesContextProps,
ServicesContextProvider,
} from 'app-shared/contexts/ServicesContext';
import { CodeListTableEditor } from './CodeListTableEditor';
import { textMock } from '@studio/testing/mocks/i18nMock';
import { PreviewContext, type PreviewContextProps } from 'app-development/contexts/PreviewContext';
import { queriesMock } from 'app-shared/mocks/queriesMock';
import userEvent, { type UserEvent } from '@testing-library/user-event';

// Test data:
const mockComponent: FormComponent<ComponentType.Dropdown> = {
id: 'c24d0812-0c34-4582-8f31-ff4ce9795e96',
type: ComponentType.Dropdown,
itemType: 'COMPONENT',
dataModelBindings: { simpleBinding: 'some-path' },
optionsId: 'test',
};

const defaultPreviewContextProps: PreviewContextProps = {
shouldReloadPreview: false,
doReloadPreview: jest.fn(),
previewHasLoaded: jest.fn(),
};

const queryClientMock = createQueryClientMock();
describe('CodeListTableEditor', () => {
afterEach(() => {
queryClientMock.clear();
});

it('should render the component', async () => {
await renderCodeListTableEditor();

expect(
await screen.findByRole('button', {
standeren marked this conversation as resolved.
Show resolved Hide resolved
name: textMock('ux_editor.modal_properties_code_list_open_editor'),
}),
).toBeInTheDocument();
});

it('should open Dialog', async () => {
const user = userEvent.setup();
await renderCodeListTableEditor();
await openModal(user);

expect(screen.getByRole('dialog')).toBeInTheDocument();
});

it('should close Dialog', async () => {
const user = userEvent.setup();
await renderCodeListTableEditor();
await openModal(user);

await user.click(screen.getByRole('button', { name: 'close modal' })); // Todo: Replace "close modal" with defaultDialogProps.closeButtonTitle when https://github.com/digdir/designsystemet/issues/2195 is fixed

expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});

it('should call handClose when closing Dialog', async () => {
const user = userEvent.setup();
const doReloadPreview = jest.fn();
await renderCodeListTableEditor({ previewContextProps: { doReloadPreview } });
await openModal(user);

await user.click(screen.getByRole('button', { name: 'close modal' })); // Todo: Replace "close modal" with defaultDialogProps.closeButtonTitle when https://github.com/digdir/designsystemet/issues/2195 is fixed

expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(doReloadPreview).toHaveBeenCalledTimes(1);
});
});

const openModal = async (user: UserEvent) => {
const btnOpen = await screen.findByRole('button', {
name: textMock('ux_editor.modal_properties_code_list_open_editor'),
});
await user.click(btnOpen);
};

const renderCodeListTableEditor = async ({
standeren marked this conversation as resolved.
Show resolved Hide resolved
queries = {},
previewContextProps = {},
componentProps = {},
standeren marked this conversation as resolved.
Show resolved Hide resolved
} = {}) => {
const allQueries: ServicesContextProps = {
...queries,
...queriesMock,
};

return render(
standeren marked this conversation as resolved.
Show resolved Hide resolved
<ServicesContextProvider {...allQueries} client={createQueryClientMock()}>
<PreviewContext.Provider value={{ ...defaultPreviewContextProps, ...previewContextProps }}>
<CodeListTableEditor
component={{
...mockComponent,
...componentProps,
}}
/>
</PreviewContext.Provider>
</ServicesContextProvider>,
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React, { useEffect, useState } from 'react';
import type { IGenericEditComponent } from '@altinn/ux-editor/components/config/componentConfig';
import type { SelectionComponentType } from '@altinn/ux-editor/types/FormComponent';
import type { CodeListItem } from '@studio/components';
import type { Option } from 'app-shared/types/Option';
import { useTranslation } from 'react-i18next';
import { StudioCodeListEditor, StudioModal } from '@studio/components';
import { TableIcon } from '@studio/icons';
import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams';
import { useUpdateOptionListMutation } from '../../../../../hooks/mutations/useUpdateOptionListMutation';
import { useOptionListsQuery } from '../../../../../hooks/queries/useOptionListsQuery';
import {
convertCodeListItemListToOptionsList,
convertOptionsListToCodeListItemList,
} from './utils/conversionUtils';
import { useCodeListEditorTexts } from './hooks/useCodeListEditorTexts';
import { usePreviewContext } from 'app-development/contexts/PreviewContext';
import classes from './CodeListTableEditor.module.css';

type CodeListTableEditorProps = Pick<IGenericEditComponent<SelectionComponentType>, 'component'>;

export function CodeListTableEditor({ component }: CodeListTableEditorProps): React.ReactNode {
const { t } = useTranslation();
const { org, app } = useStudioEnvironmentParams();
const { doReloadPreview } = usePreviewContext();
const { data: optionsListMap, isFetching } = useOptionListsQuery(org, app);
const { mutate: uploadOptionList } = useUpdateOptionListMutation(org, app, {
hideDefaultError: true,
});
const [codeListItemList, setCodeListItemList] = useState<CodeListItem[]>([]);
standeren marked this conversation as resolved.
Show resolved Hide resolved
const editorTexts = useCodeListEditorTexts();

useEffect(() => {
if (isFetching) return;
handleOptionsChange(optionsListMap[component.optionsId]);
}, [optionsListMap, component.optionsId, isFetching]);
standeren marked this conversation as resolved.
Show resolved Hide resolved

const handleOptionsChange = (options: Option[]) => {
const convertedList = convertOptionsListToCodeListItemList(options);
setCodeListItemList(convertedList);
};

const handleClose = () => {
const optionsListLocal = convertCodeListItemListToOptionsList(codeListItemList);
uploadOptionList({ optionListId: component.optionsId, optionsList: optionsListLocal });
doReloadPreview();
};

if (component.optionsId === undefined || component.optionsId === '' || isFetching) return;
return (
<StudioModal.Root>
<StudioModal.Trigger
className={classes.modalTrigger}
variant='secondary'
icon={<TableIcon />}
>
{t('ux_editor.modal_properties_code_list_open_editor')}
</StudioModal.Trigger>
<StudioModal.Dialog
className={classes.manualTabModal}
closeButtonTitle={t('general.close')}
heading={t('ux_editor.modal_add_options_codelist')}
onBeforeClose={handleClose}
onInteractOutside={handleClose}
>
<StudioCodeListEditor
codeList={codeListItemList}
onChange={handleOptionsChange}
texts={editorTexts}
/>
</StudioModal.Dialog>
</StudioModal.Root>
);
}
Loading