Skip to content

Commit

Permalink
feat: add codeListEditor in config options (#13953)
Browse files Browse the repository at this point in the history
Co-authored-by: Tomas Engebretsen <[email protected]>
  • Loading branch information
Konrad-Simso and TomasEng authored Nov 12, 2024
1 parent 002b6e4 commit d339dfc
Show file tree
Hide file tree
Showing 43 changed files with 568 additions and 227 deletions.
2 changes: 1 addition & 1 deletion backend/src/Designer/Controllers/OptionsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ public async Task<ActionResult<List<Option>>> GetOptionsList(string org, string
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[Route("{optionsListId}")]
public async Task<ActionResult> CreateOrOverwriteOptionsList(string org, string repo, [FromRoute] string optionsListId, [FromBody] List<Option> payload, CancellationToken cancellationToken = default)
public async Task<ActionResult<Dictionary<string, List<Option>>>> CreateOrOverwriteOptionsList(string org, string repo, [FromRoute] string optionsListId, [FromBody] List<Option> payload, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);
Expand Down
5 changes: 4 additions & 1 deletion frontend/language/src/nb.json
Original file line number Diff line number Diff line change
Expand Up @@ -1503,11 +1503,14 @@
"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}}",
"ux_editor.modal_properties_code_list_item_help_text": "Hjelpetekst for alternativ {{number}}",
"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>",
"ux_editor.modal_properties_code_list_spinner_title": "Laster inn kodelister",
"ux_editor.modal_properties_code_list_upload": "Last opp din egen kodeliste",
"ux_editor.modal_properties_code_list_upload_duplicate_error": "Opplastning feilet. Du prøvde å laste opp en fil som finnes fra før.",
"ux_editor.modal_properties_code_list_upload_generic_error": "Opplastning feilet. Filen du lastet opp er ikke satt opp riktig.",
Expand Down Expand Up @@ -1538,7 +1541,7 @@
"ux_editor.modal_properties_data_model_link": "Legg til en datamodellknytning",
"ux_editor.modal_properties_data_model_link_multiple_attachments": "Legg til knytning for flere vedlegg",
"ux_editor.modal_properties_data_model_restrictions_attachment_components": "Når vedlegg er en del av en repeterende gruppe, må det også være en en datamodellknytning",
"ux_editor.modal_properties_error_message": "Det oppsto en feil under lasting",
"ux_editor.modal_properties_error_message": "Kunne ikke hente innholdet i kodelisten. Redigering er ikke tilgjengelig.",
"ux_editor.modal_properties_file_upload_list": "Liste (flere vedlegg)",
"ux_editor.modal_properties_file_upload_simple": "Enkel (ett vedlegg)",
"ux_editor.modal_properties_grid": "Bredde for hele komponenten",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CodeListItem } from '../types/CodeListItem';
import type { CodeListItemValue } from '../types/CodeListItemValue';
import { StudioInputTable } from '../../StudioInputTable';
import { TrashIcon } from '../../../../../studio-icons';
import type { FocusEvent, HTMLInputAutoCompleteAttribute } from 'react';
Expand Down Expand Up @@ -90,7 +91,7 @@ type TextfieldCellProps = {
error?: string;
label: string;
onChange: (newString: string) => void;
value: string;
value: CodeListItemValue;
autoComplete?: HTMLInputAutoCompleteAttribute;
};

Expand Down Expand Up @@ -120,7 +121,7 @@ function TextfieldCell({ error, label, value, onChange, autoComplete }: Textfiel
onChange={handleChange}
onFocus={handleFocus}
ref={ref}
value={value}
value={(value as string) ?? ''}
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export type { StudioCodeListEditorProps } from './StudioCodeListEditor';
export { StudioCodeListEditor } from './StudioCodeListEditor';
export type { CodeListEditorTexts } from './types/CodeListEditorTexts';
export type { CodeListItemValue } from './types/CodeListItemValue';
export type { CodeListItem } from './types/CodeListItem';
export type { CodeList } from './types/CodeList';
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export type CodeListItem = {
import type { CodeListItemValue } from './CodeListItemValue';

export type CodeListItem<T extends CodeListItemValue = CodeListItemValue> = {
description?: string;
helpText?: string;
label: string;
value: string;
value: T;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type CodeListItemValue = string | boolean | number;
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CodeList } from '../types/CodeList';
import type { CodeListItemValue } from '../types/CodeListItemValue';
import type { ValueError } from '../types/ValueError';
import { ArrayUtils } from '@studio/pure-functions';
import type { ValueErrorMap } from '../types/ValueErrorMap';
Expand All @@ -9,15 +10,18 @@ export function isCodeListValid(codeList: CodeList): boolean {
}

export function findCodeListErrors(codeList: CodeList): ValueErrorMap {
const values = codeList.map((item) => item.value);
const values: CodeListItemValue[] = codeList.map((item) => item.value);
return mapValueErrors(values);
}

function mapValueErrors(values: string[]): ValueErrorMap {
function mapValueErrors(values: CodeListItemValue[]): ValueErrorMap {
return values.map((value) => findValueError(value, values));
}

function findValueError(value: string, allValues: string[]): ValueError | null {
function findValueError(
value: CodeListItemValue,
allValues: CodeListItemValue[],
): ValueError | null {
return ArrayUtils.isDuplicate(value, allValues) ? 'duplicateValue' : null;
}

Expand Down
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<Option[]>(optionListUpdatePath(org, app, optionsListId), payload);
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 { OptionsLists } from 'app-shared/types/api/OptionsLists';

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<OptionsLists>(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
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { app, org } from '@studio/testing/testids';
import { queriesMock } from 'app-shared/mocks/queriesMock';
import { renderHookWithProviders } from 'app-shared/mocks/renderHookWithProviders';
import {
type UpdateOptionListMutationArgs,
useUpdateOptionListMutation,
} from './useUpdateOptionListMutation';
import type { Option } from 'app-shared/types/Option';

// Test data:
const optionListId = 'test';
const optionsList: Option[] = [{ value: 'test', label: 'test' }];
const args: UpdateOptionListMutationArgs = { optionListId: optionListId, optionsList: optionsList };

describe('useUpdateOptionListMutation', () => {
test('Calls useUpdateOptionList with correct parameters', async () => {
const renderUpdateOptionListMutationResult = renderHookWithProviders(() =>
useUpdateOptionListMutation(org, app),
).result;
await renderUpdateOptionListMutationResult.current.mutateAsync(args);
expect(queriesMock.updateOptionList).toHaveBeenCalledTimes(1);
expect(queriesMock.updateOptionList).toHaveBeenCalledWith(org, app, optionListId, optionsList);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { MutationMeta } from '@tanstack/react-query';
import { QueryKey } from 'app-shared/types/QueryKey';
import type { Option } from 'app-shared/types/Option';
import type { OptionsLists } from 'app-shared/types/api/OptionsLists';
import { useQueryClient, useMutation } from '@tanstack/react-query';
import { useServicesContext } from 'app-shared/contexts/ServicesContext';

export interface UpdateOptionListMutationArgs {
optionListId: string;
optionsList: Option[];
}

export const useUpdateOptionListMutation = (org: string, app: string, meta?: MutationMeta) => {
const queryClient = useQueryClient();
const { updateOptionList } = useServicesContext();

return useMutation<Option[], Error, UpdateOptionListMutationArgs>({
mutationFn: ({ optionListId, optionsList }: UpdateOptionListMutationArgs) => {
return updateOptionList(org, app, optionListId, optionsList);
},
onSuccess: (updatedOptionList: Option[], { optionListId }) => {
const oldData: OptionsLists = queryClient.getQueryData([QueryKey.OptionLists, org, app]);
const newData = { ...oldData };
newData[optionListId] = updatedOptionList;
queryClient.setQueryData([QueryKey.OptionLists, org, app], newData);
},
meta,
});
};
15 changes: 4 additions & 11 deletions frontend/packages/shared/src/hooks/queries/useOptionListsQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,12 @@ import { useServicesContext } from 'app-shared/contexts/ServicesContext';
import { QueryKey } from 'app-shared/types/QueryKey';
import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import type { OptionsLists } from 'app-shared/types/api/OptionsLists';

export const useOptionListsQuery = (org: string, app: string): UseQueryResult<string[]> => {
export const useOptionListsQuery = (org: string, app: string): UseQueryResult<OptionsLists> => {
const { getOptionLists } = useServicesContext();

return useQuery<any>({
return useQuery<OptionsLists>({
queryKey: [QueryKey.OptionLists, org, app],
queryFn: () =>
getOptionLists(org, app).then((result) => {
const optionLists = {};
Object.keys(result).forEach((optionListId) => {
optionLists[optionListId] = result[optionListId];
});
return optionLists;
}),
queryFn: () => getOptionLists(org, app).then((result) => result),
});
};
5 changes: 5 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,7 @@ export const searchRepositoryResponse: SearchRepositoryResponse = {
totalCount: 0,
totalPages: 0,
};

export const updateOptionListResponse: Option[] = [
{ value: '', label: '', description: '', helpText: '' },
];
8 changes: 7 additions & 1 deletion frontend/packages/shared/src/mocks/queriesMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,14 @@ import {
textResourcesWithLanguage,
user,
validation,
updateOptionListResponse,
} from './mocks';
import type { FormLayoutsResponseV3 } from 'app-shared/types/api/FormLayoutsResponseV3';
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 { OptionsLists } from 'app-shared/types/api/OptionsLists';
import type { Option } from 'app-shared/types/Option';

export const queriesMock: ServicesContextProps = {
// Queries
Expand Down Expand Up @@ -102,7 +105,7 @@ 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<OptionsLists>({})),
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 +214,9 @@ 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<Option[]>(updateOptionListResponse)),
uploadDataModel: jest.fn().mockImplementation(() => Promise.resolve<JsonSchema>({})),
uploadOptionList: jest.fn().mockImplementation(() => Promise.resolve()),
upsertTextResources: jest
Expand Down
9 changes: 3 additions & 6 deletions frontend/packages/shared/src/types/Option.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
export type Option<T extends string | boolean | number = string | boolean | number> = {
label: string;
value: T;
description?: string;
helpText?: string;
};
import type { CodeListItem, CodeListItemValue } from '@studio/components';

export type Option<T extends CodeListItemValue = CodeListItemValue> = CodeListItem<T>;
3 changes: 3 additions & 0 deletions frontend/packages/shared/src/types/api/OptionsLists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import type { Option } from 'app-shared/types/Option';

export type OptionsLists = Record<string, Option[]>;
2 changes: 1 addition & 1 deletion frontend/packages/shared/src/utils/featureToggleUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export type SupportedFeatureFlags =
| 'addComponentModal'
| 'subform'
| 'summary2'
| 'codeListEditor';
| 'optionListEditor';

/*
* 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
Expand Up @@ -24,7 +24,7 @@ export const Text = () => {

const handleComponentChange = async (updatedComponent: FormContainer | FormComponent) => {
handleUpdate(updatedComponent);
debounceSave(formId, updatedComponent);
await debounceSave(formId, updatedComponent);
};

if (!schema) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,3 @@
.errorMessage {
margin: var(--fds-spacing-5) var(--fds-spacing-5) 0;
}

.codelistTabContent {
padding: var(--fds-spacing-5);
display: flex;
flex-direction: column;
gap: var(--fds-spacing-2);
}

.manualTabContent {
padding-block: var(--fds-spacing-5);
padding-inline: 0;
}

.manualTabAlert {
margin-inline: var(--fds-spacing-5);
}

.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);
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import React from 'react';
import { screen, waitFor } from '@testing-library/react';

import { EditOptions } from './EditOptions';
import { renderWithProviders } from '../../../../testing/mocks';
import { textMock } from '@studio/testing/mocks/i18nMock';
import { ComponentType } from 'app-shared/types/ComponentType';
import type { FormComponent } from '../../../../types/FormComponent';
import type { FormItem } from '../../../../types/FormItem';
import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext';
import { createQueryClientMock } from 'app-shared/mocks/queryClientMock';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../../../testing/mocks';

const mockComponent: FormComponent<ComponentType.RadioButtons> = {
id: 'c24d0812-0c34-4582-8f31-ff4ce9795e96',
Expand Down Expand Up @@ -37,14 +36,13 @@ const renderEditOptions = async <T extends ComponentType.Checkboxes | ComponentT
areLayoutOptionsSupported?: boolean;
};
} = {}) => {
const component = {
...mockComponent,
...componentProps,
};
renderWithProviders(
return renderWithProviders(
<EditOptions
handleComponentChange={handleComponentChange}
component={component}
component={{
...mockComponent,
...componentProps,
}}
renderOptions={renderOptions}
/>,
{
Expand Down
Loading

0 comments on commit d339dfc

Please sign in to comment.