diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index 7a345ae2b77..c3efac252ff 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -1546,6 +1546,7 @@ "ux_editor.component_properties.compact": "Bruk kompakt visning", "ux_editor.component_properties.componentRef": "ID på komponenten det gjelder (componentRef)", "ux_editor.component_properties.config_is_expression_message": "Denne egenskapen er konfigurert som et uttrykk, og kan foreløpig ikke redigeres her.", + "ux_editor.component_properties.current_task": "Kun gjeldende oppgave", "ux_editor.component_properties.dataListId": "Id på dataliste", "ux_editor.component_properties.dataTypeIds": "Liste med ID på datatyper som skal vises", "ux_editor.component_properties.dateSent": "Dato instansen ble sendt inn", @@ -1572,6 +1573,7 @@ "ux_editor.component_properties.largeGroup": "Stor gruppevisning/gruppe i gruppe", "ux_editor.component_properties.layers": "Kartlag", "ux_editor.component_properties.layout": "Visning", + "ux_editor.component_properties.loading": "Laster inn", "ux_editor.component_properties.mapping": "Mapping", "ux_editor.component_properties.maxCount": "Maks antall repetisjoner", "ux_editor.component_properties.maxDate": "Seneste dato", @@ -1602,6 +1604,9 @@ "ux_editor.component_properties.sandbox": "Sandbox", "ux_editor.component_properties.saveWhileTyping": "Overstyr tidsintervall for lagring når bruker skriver", "ux_editor.component_properties.secure": "Bruk sikret versjon av API for å hente valg (secure)", + "ux_editor.component_properties.select_all_attachments": "Alle vedlegg", + "ux_editor.component_properties.select_attachments": "Velg vedlegg", + "ux_editor.component_properties.select_pdf": "Inkluder skjemagenerert pdf", "ux_editor.component_properties.sender": "Hvem som har sendt inn skjema", "ux_editor.component_properties.severity": "Alvorlighetsgrad", "ux_editor.component_properties.showAsCard": "Vis som kort", @@ -1641,6 +1646,8 @@ "ux_editor.component_title.AddressComponent": "Adresse", "ux_editor.component_title.Alert": "Varsel", "ux_editor.component_title.AttachmentList": "Liste over vedlegg", + "ux_editor.component_title.AttachmentList_error": "Du må velge minst ett vedlegg eller PDF", + "ux_editor.component_title.AttachmentList_legend": "Vedleggsliste", "ux_editor.component_title.Button": "Knapp", "ux_editor.component_title.ButtonGroup": "Knappegruppe", "ux_editor.component_title.Checkboxes": "Avkrysningsbokser", diff --git a/frontend/libs/studio-pure-functions/src/ArrayUtils/ArrayUtils.test.ts b/frontend/libs/studio-pure-functions/src/ArrayUtils/ArrayUtils.test.ts index 37804996494..61026629dc0 100644 --- a/frontend/libs/studio-pure-functions/src/ArrayUtils/ArrayUtils.test.ts +++ b/frontend/libs/studio-pure-functions/src/ArrayUtils/ArrayUtils.test.ts @@ -36,6 +36,16 @@ describe('ArrayUtils', () => { }); }); + describe('removeItemByValue', () => { + it('Deletes item from array by value', () => { + expect(ArrayUtils.removeItemByValue([1, 2, 3], 2)).toEqual([1, 3]); + expect(ArrayUtils.removeItemByValue(['a', 'b', 'c'], 'b')).toEqual(['a', 'c']); + expect(ArrayUtils.removeItemByValue(['a', 'b', 'c'], 'd')).toEqual(['a', 'b', 'c']); + expect(ArrayUtils.removeItemByValue([], 'a')).toEqual([]); + expect(ArrayUtils.removeItemByValue(['a', 'b', 'c', 'b', 'a'], 'b')).toEqual(['a', 'c', 'a']); + }); + }); + describe('last', () => { it('Returns last item in array', () => { expect(ArrayUtils.last([1, 2, 3])).toEqual(3); @@ -47,6 +57,22 @@ describe('ArrayUtils', () => { }); }); + describe('ArrayUtils.intersection', () => { + it('Returns intersection of two arrays when included is true', () => { + expect(ArrayUtils.intersection([1, 2, 3], [3, '4', 5])).toStrictEqual([3]); + expect(ArrayUtils.intersection([1, 2, 3], [4, '4', 5])).toStrictEqual([]); + expect(ArrayUtils.intersection([1, 2, 3], [3, '4', 2])).toStrictEqual([2, 3]); + expect(ArrayUtils.intersection([1, 2, 3], [1, 2, 3])).toStrictEqual([1, 2, 3]); + }); + + it('Returns intersection of two arrays when included is false', () => { + expect(ArrayUtils.intersection([1, 2, 3], [3, '4', 5], false)).toStrictEqual([1, 2]); + expect(ArrayUtils.intersection([1, 2, 3], [4, '4', 5], false)).toStrictEqual([1, 2, 3]); + expect(ArrayUtils.intersection([1, 2, 3], [3, '4', 2], false)).toStrictEqual([1]); + expect(ArrayUtils.intersection([1, 2, 3], [1, 2, 3], false)).toStrictEqual([]); + }); + }); + describe('replaceByIndex', () => { it('Replaces element in array with new value', () => { const array1 = ['0', '1', '2']; diff --git a/frontend/libs/studio-pure-functions/src/ArrayUtils/ArrayUtils.ts b/frontend/libs/studio-pure-functions/src/ArrayUtils/ArrayUtils.ts index 126741c5c11..7340e2e704e 100644 --- a/frontend/libs/studio-pure-functions/src/ArrayUtils/ArrayUtils.ts +++ b/frontend/libs/studio-pure-functions/src/ArrayUtils/ArrayUtils.ts @@ -18,9 +18,30 @@ export class ArrayUtils { return givenIndex < 0 || givenIndex >= array.length ? array.length - 1 : givenIndex; } + /** + * Removes item from array by value. + * @param array Array to delete item from. + * @param value Value to delete. + * @returns Array without the given value. + */ + public static removeItemByValue(array: T[], value: T): T[] { + return array.filter((item) => item !== value); + } + /** Returns the last item of the given array */ public static last = (array: T[]): T => array[array.length - 1]; + /** + * Returns an array of which the element of arrA are either present or not present in arrB based on the include param. + * @param arrA The first array. + * @param arrB The second array. + * @param include Whether to include or exclude the elements of arrB from arrA. Defaults to true. + * @returns Array that contains the filtered elements based on the filtering condition. + */ + public static intersection = (arrA: T[], arrB: T[], include: boolean = true): T[] => { + return arrA.filter((x) => (include ? arrB.includes(x) : !arrB.includes(x))); + }; + /** Replaces an element in an array with a new value */ public static replaceByIndex = (array: T[], index: number, newValue: T): T[] => { if (index < 0 || index >= array.length) return array; diff --git a/frontend/packages/schema-model/src/lib/SchemaModel.ts b/frontend/packages/schema-model/src/lib/SchemaModel.ts index 34f21ba515c..33565447428 100644 --- a/frontend/packages/schema-model/src/lib/SchemaModel.ts +++ b/frontend/packages/schema-model/src/lib/SchemaModel.ts @@ -21,7 +21,6 @@ import { generateUniqueStringWithNumber, insertArrayElementAtPos, moveArrayItem, - removeItemByValue, replaceItemsByValue, } from 'app-shared/utils/arrayUtils'; import { ROOT_POINTER } from './constants'; @@ -356,7 +355,7 @@ export class SchemaModel { private removeNodeFromParent = (pointer: string): void => { const parent = this.getParentNode(pointer); - parent.children = removeItemByValue(parent.children, pointer); + parent.children = ArrayUtils.removeItemByValue(parent.children, pointer); }; public getParentNode(pointer: string): FieldNode | CombinationNode | undefined { diff --git a/frontend/packages/schema-model/src/lib/mappers/field-type.ts b/frontend/packages/schema-model/src/lib/mappers/field-type.ts index fcb78f5a908..316e220464d 100644 --- a/frontend/packages/schema-model/src/lib/mappers/field-type.ts +++ b/frontend/packages/schema-model/src/lib/mappers/field-type.ts @@ -8,7 +8,6 @@ import { StrRestrictionKey, } from '../../types'; import { getCombinationKind, getObjectKind, isField } from '../utils'; -import { arrayIntersection } from 'app-shared/utils/arrayUtils'; import type { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; import { ArrayUtils } from '@studio/pure-functions'; @@ -31,13 +30,13 @@ export const findUiFieldType = (schemaNode: KeyValuePairs) => { } else if (isCompundFieldType(schemaNode.type)) { // @see SeresNillable.json, we need to support types where stuff can be null. return schemaNode.type.filter((t: FieldType) => t !== FieldType.Null).pop(); - } else if (arrayIntersection(keys, Object.values(IntRestrictionKey)).length) { + } else if (ArrayUtils.intersection(keys, Object.values(IntRestrictionKey)).length) { return FieldType.Number; - } else if (arrayIntersection(keys, Object.values(ArrRestrictionKey)).length) { + } else if (ArrayUtils.intersection(keys, Object.values(ArrRestrictionKey)).length) { return FieldType.Boolean; - } else if (arrayIntersection(keys, Object.values(StrRestrictionKey)).length) { + } else if (ArrayUtils.intersection(keys, Object.values(StrRestrictionKey)).length) { return FieldType.String; - } else if (arrayIntersection(keys, Object.values(ObjRestrictionKey)).length) { + } else if (ArrayUtils.intersection(keys, Object.values(ObjRestrictionKey)).length) { return FieldType.Object; } else if (Array.isArray(schemaNode.enum) && schemaNode.enum.length) { return findEnumFieldType(schemaNode.enum); diff --git a/frontend/packages/schema-model/test/validateTestUiSchema.ts b/frontend/packages/schema-model/test/validateTestUiSchema.ts index deb8205a628..30ed370e51c 100644 --- a/frontend/packages/schema-model/test/validateTestUiSchema.ts +++ b/frontend/packages/schema-model/test/validateTestUiSchema.ts @@ -1,7 +1,7 @@ import type { UiSchemaNodes } from '../src'; import { FieldType, ObjectKind, ROOT_POINTER } from '../src'; import { getPointers } from '../src/lib/mappers/getPointers'; -import { areItemsUnique, mapByKey, removeItemByValue } from 'app-shared/utils/arrayUtils'; +import { areItemsUnique, mapByKey } from 'app-shared/utils/arrayUtils'; import { isField, isFieldOrCombination, @@ -12,6 +12,7 @@ import { isNotTheRootNode, isTheRootNode, } from '../src/lib/utils'; +import { ArrayUtils } from '@studio/pure-functions'; /** Verifies that there is a root node */ export const hasRootNode = (uiSchema: UiSchemaNodes) => @@ -33,7 +34,7 @@ export const allPointersExist = (uiSchema: UiSchemaNodes) => { /** Verifies that all nodes except the root node have a parent */ export const nodesHaveParent = (uiSchema: UiSchemaNodes) => { const allChildPointers = mapByKey(uiSchema.filter(isFieldOrCombination), 'children').flat(); - removeItemByValue(getPointers(uiSchema), ROOT_POINTER).forEach((pointer) => { + ArrayUtils.removeItemByValue(getPointers(uiSchema), ROOT_POINTER).forEach((pointer) => { expect(allChildPointers).toContain(pointer); }); }; diff --git a/frontend/packages/shared/src/utils/arrayUtils.test.ts b/frontend/packages/shared/src/utils/arrayUtils.test.ts index 917177ca74e..517c59630c3 100644 --- a/frontend/packages/shared/src/utils/arrayUtils.test.ts +++ b/frontend/packages/shared/src/utils/arrayUtils.test.ts @@ -1,13 +1,11 @@ import { areItemsUnique, - arrayIntersection, generateUniqueStringWithNumber, insertArrayElementAtPos, mapByKey, moveArrayItem, prepend, removeEmptyStrings, - removeItemByValue, replaceByPredicate, replaceItemsByValue, swapArrayElements, @@ -21,16 +19,6 @@ describe('arrayUtils', () => { }); }); - describe('removeItemByValue', () => { - it('Deletes item from array by value', () => { - expect(removeItemByValue([1, 2, 3], 2)).toEqual([1, 3]); - expect(removeItemByValue(['a', 'b', 'c'], 'b')).toEqual(['a', 'c']); - expect(removeItemByValue(['a', 'b', 'c'], 'd')).toEqual(['a', 'b', 'c']); - expect(removeItemByValue([], 'a')).toEqual([]); - expect(removeItemByValue(['a', 'b', 'c', 'b', 'a'], 'b')).toEqual(['a', 'c', 'a']); - }); - }); - describe('areItemsUnique', () => { it('Returns true if all items are unique', () => { expect(areItemsUnique([1, 2, 3])).toBe(true); @@ -81,15 +69,6 @@ describe('arrayUtils', () => { }); }); - describe('arrayIntersection', () => { - it('Returns intersection of two arrays', () => { - expect(arrayIntersection([1, 2, 3], [3, '4', 5])).toStrictEqual([3]); - expect(arrayIntersection([1, 2, 3], [4, '4', 5])).toStrictEqual([]); - expect(arrayIntersection([1, 2, 3], [3, '4', 2])).toStrictEqual([2, 3]); - expect(arrayIntersection([1, 2, 3], [1, 2, 3])).toStrictEqual([1, 2, 3]); - }); - }); - describe('mapByKey', () => { it('Returns an array of values mapped by the given key', () => { const array = [ diff --git a/frontend/packages/shared/src/utils/arrayUtils.ts b/frontend/packages/shared/src/utils/arrayUtils.ts index 6282c60590f..885059d4fb9 100644 --- a/frontend/packages/shared/src/utils/arrayUtils.ts +++ b/frontend/packages/shared/src/utils/arrayUtils.ts @@ -1,3 +1,5 @@ +import { ArrayUtils } from '@studio/pure-functions'; + /** * Adds an item to the beginning of an array.. * @param array The array of interest. @@ -17,15 +19,6 @@ export const replaceLastItem = (array: T[], replaceWith: T): T[] => { return array; }; -/** - * Removes item from array by value. - * @param array Array to delete item from. - * @param value Value to delete. - * @returns Array without the given value. - */ -export const removeItemByValue = (array: T[], value: T): T[] => - array.filter((item) => item !== value); - /** * Checks if all items in the given array are unique. * @param array The array of interest. @@ -63,14 +56,6 @@ export const insertArrayElementAtPos = (array: T[], item: T, targetPos: numbe return out; }; -/** - * Returns an array of which the elements are present in both given arrays. - * @param arrA First array. - * @param arrB Second array. - * @returns Array of which the elements are present in both given arrays. - */ -export const arrayIntersection = (arrA: T[], arrB: T[]) => arrA.filter((x) => arrB.includes(x)); - /** * Maps an array of objects by a given key. * @param array The array of objects. @@ -132,4 +117,5 @@ export const generateUniqueStringWithNumber = (array: string[], prefix: string = }; /** Removes empty strings from a string array */ -export const removeEmptyStrings = (array: string[]): string[] => removeItemByValue(array, ''); +export const removeEmptyStrings = (array: string[]): string[] => + ArrayUtils.removeItemByValue(array, ''); diff --git a/frontend/packages/text-editor/src/RightMenu.tsx b/frontend/packages/text-editor/src/RightMenu.tsx index 0c100e8ab91..ba015ac14ab 100644 --- a/frontend/packages/text-editor/src/RightMenu.tsx +++ b/frontend/packages/text-editor/src/RightMenu.tsx @@ -5,11 +5,11 @@ import { LangSelector } from './LangSelector'; import { getLangName, langOptions } from './utils'; import { Checkbox, Fieldset, Heading } from '@digdir/design-system-react'; import { defaultLangCode } from './constants'; -import { removeItemByValue } from 'app-shared/utils/arrayUtils'; import { useTranslation } from 'react-i18next'; import { AltinnConfirmDialog } from 'app-shared/components'; import * as testids from '../../../testing/testids'; import { StudioButton } from '@studio/components'; +import { ArrayUtils } from '@studio/pure-functions'; export interface RightMenuProps { addLanguage: (langCode: LangCode) => void; @@ -34,11 +34,11 @@ export const RightMenu = ({ const handleSelectChange = async ({ target }: React.ChangeEvent) => { target.checked ? setSelectedLanguages([...selectedLanguages, target.name]) - : setSelectedLanguages(removeItemByValue(selectedLanguages, target.name)); + : setSelectedLanguages(ArrayUtils.removeItemByValue(selectedLanguages, target.name)); }; const handleDeleteLanguage = (langCode: LangCode) => { - setSelectedLanguages(removeItemByValue(selectedLanguages, langCode)); + setSelectedLanguages(ArrayUtils.removeItemByValue(selectedLanguages, langCode)); deleteLanguage(langCode); }; diff --git a/frontend/packages/ux-editor-v3/src/utils/formLayoutUtils.ts b/frontend/packages/ux-editor-v3/src/utils/formLayoutUtils.ts index 2311581e14c..9515d411613 100644 --- a/frontend/packages/ux-editor-v3/src/utils/formLayoutUtils.ts +++ b/frontend/packages/ux-editor-v3/src/utils/formLayoutUtils.ts @@ -7,8 +7,8 @@ import type { IToolbarElement, } from '../types/global'; import { BASE_CONTAINER_ID, MAX_NESTED_GROUP_LEVEL } from 'app-shared/constants'; -import { ObjectUtils } from '@studio/pure-functions'; -import { insertArrayElementAtPos, removeItemByValue } from 'app-shared/utils/arrayUtils'; +import { insertArrayElementAtPos } from 'app-shared/utils/arrayUtils'; +import { ArrayUtils, ObjectUtils } from '@studio/pure-functions'; import { ComponentTypeV3 } from 'app-shared/types/ComponentTypeV3'; import type { FormComponent } from '../types/FormComponent'; import { generateFormItem } from './component'; @@ -202,7 +202,10 @@ export const removeComponent = (layout: IInternalLayout, componentId: string): I const newLayout = ObjectUtils.deepCopy(layout); const containerId = findParentId(layout, componentId); if (containerId) { - newLayout.order[containerId] = removeItemByValue(newLayout.order[containerId], componentId); + newLayout.order[containerId] = ArrayUtils.removeItemByValue( + newLayout.order[containerId], + componentId, + ); delete newLayout.components[componentId]; } return newLayout; @@ -294,7 +297,10 @@ export const moveLayoutItem = ( const item = findItem(newLayout, id); item.pageIndex = calculateNewPageIndex(newLayout, newContainerId, newPosition); if (oldContainerId) { - newLayout.order[oldContainerId] = removeItemByValue(newLayout.order[oldContainerId], id); + newLayout.order[oldContainerId] = ArrayUtils.removeItemByValue( + newLayout.order[oldContainerId], + id, + ); newLayout.order[newContainerId] = insertArrayElementAtPos( newLayout.order[newContainerId], id, diff --git a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/AttachmentListComponent.test.tsx b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/AttachmentListComponent.test.tsx new file mode 100644 index 00000000000..01fbde064e3 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/AttachmentListComponent.test.tsx @@ -0,0 +1,248 @@ +import { ComponentType } from 'app-shared/types/ComponentType'; +import type { FormAttachmentListComponent } from '../../../../types/FormComponent'; +import type { IGenericEditComponent } from '../../componentConfig'; +import { renderWithProviders } from '../../../../testing/mocks'; +import { AttachmentListComponent } from './AttachmentListComponent'; +import React from 'react'; +import { screen, act, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; +import { textMock } from '../../../../../../../testing/mocks/i18nMock'; +import type { LayoutSets } from 'app-shared/types/api/LayoutSetsResponse'; +import { QueryKey } from 'app-shared/types/QueryKey'; +import type { DataTypeElement } from 'app-shared/types/ApplicationMetadata'; +import { reservedDataTypes } from './attachmentListUtils'; + +const user = userEvent.setup(); +const org = 'org'; +const app = 'app'; + +const defaultLayoutSets: LayoutSets = { + sets: [ + { + id: 'layoutSetId1', + dataTypes: 'layoutSetId1', + tasks: ['Task_1'], + }, + { + id: 'layoutSetId2', + dataTypes: 'layoutSetId2', + tasks: ['Task_2'], + }, + { + id: 'layoutSetId3', + dataTypes: 'layoutSetId3', + tasks: ['Task_3'], + }, + ], +}; + +const defaultDataTypes: DataTypeElement[] = [ + { id: 'test1', taskId: 'Task_1' }, + { id: 'test2', taskId: 'Task_1', appLogic: {} }, + { id: 'test3', taskId: 'Task_2' }, + { id: 'test4', taskId: 'Task_3' }, + { id: 'test5', taskId: 'Task_3' }, + { id: reservedDataTypes.refDataAsPdf }, +]; + +const defaultComponent: FormAttachmentListComponent = { + id: '1', + type: ComponentType.AttachmentList, + itemType: 'COMPONENT', +}; + +const handleComponentChange = jest.fn(); + +const defaultProps: IGenericEditComponent = { + component: defaultComponent, + handleComponentChange, +}; + +const render = async ( + props: Partial> = {}, + selectedLayoutSet: string = 'layoutSetId2', + layoutSets: LayoutSets = defaultLayoutSets, + dataTypes: DataTypeElement[] = defaultDataTypes, + isDataFetched: boolean = true, +) => { + const client = createQueryClientMock(); + if (isDataFetched) { + client.setQueryData([QueryKey.LayoutSets, org, app], layoutSets); + client.setQueryData([QueryKey.AppMetadata, org, app], { dataTypes }); + } + return renderWithProviders(, { + queryClient: client, + appContextProps: { + selectedFormLayoutSetName: selectedLayoutSet, + }, + }); +}; + +describe('AttachmentListComponent', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render spinner when appMetadata is pending', () => { + render({}, undefined, defaultLayoutSets, defaultDataTypes, false); + + const spinnerText = screen.getByText(textMock('ux_editor.component_properties.loading')); + expect(spinnerText).toBeInTheDocument(); + }); + + it('should render AttachmentList component', async () => { + await render(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { + name: textMock('ux_editor.component_properties.current_task'), + }), + ).toBeInTheDocument(); + }); + + it('should display all attachments selected as default when dataTypeIds is undefined', async () => { + await render(); + const selectAllCheckbox = screen.getByRole('checkbox', { + name: textMock('ux_editor.component_properties.select_all_attachments'), + }); + expect(selectAllCheckbox).toBeChecked(); + }); + + it('should save to backend when toggle of pdf', async () => { + await render( + { + component: { + ...defaultComponent, + dataTypeIds: ['test3', 'test4'], + }, + }, + 'layoutSetId3', + ); + + // Todo: Combobox onChangeValue trigger on initial render, this can be fixed when we start to use >v0.55.0 of designsystem. Replace value prop with initialValue prop in combobox + expect(handleComponentChange).toHaveBeenCalledTimes(1); + handleComponentChange.mockClear(); + + const includePdfCheckbox = screen.getByRole('checkbox', { + name: textMock('ux_editor.component_properties.select_pdf'), + }); + + await act(() => user.click(includePdfCheckbox)); + expect(includePdfCheckbox).toBeChecked(); + expect(handleComponentChange).toHaveBeenCalledWith({ + ...defaultComponent, + dataTypeIds: ['test3', 'test4', reservedDataTypes.refDataAsPdf], + }); + expect(handleComponentChange).toHaveBeenCalledTimes(1); + + await act(() => user.click(includePdfCheckbox)); + expect(includePdfCheckbox).not.toBeChecked(); + expect(handleComponentChange).toHaveBeenCalledWith({ + ...defaultComponent, + dataTypeIds: ['test3', 'test4'], + }); + expect(handleComponentChange).toHaveBeenCalledTimes(2); + }); + + it('should save to backend when toggle of current task and output is valid', async () => { + await render( + { + component: { + ...defaultComponent, + dataTypeIds: ['test3', 'test4', reservedDataTypes.refDataAsPdf], + }, + }, + 'layoutSetId3', + ); + + // Todo: Combobox onChangeValue trigger on initial render, this can be fixed when we start to use >v0.55.0 of designsystem. Replace value prop with initialValue prop in combobox + expect(handleComponentChange).toHaveBeenCalledTimes(1); + handleComponentChange.mockClear(); + + const currentTaskCheckbox = screen.getByRole('checkbox', { + name: textMock('ux_editor.component_properties.current_task'), + }); + + await act(() => user.click(currentTaskCheckbox)); + expect(currentTaskCheckbox).toBeChecked(); + + expect(handleComponentChange).toHaveBeenCalledWith({ + ...defaultComponent, + dataTypeIds: ['test4', reservedDataTypes.refDataAsPdf, reservedDataTypes.currentTask], + }); + // Combobox is also triggered, because current task is set to true and makes the combobox to trigger onChangeValue because of filter update + expect(handleComponentChange).toHaveBeenCalledTimes(2); + + await act(() => user.click(currentTaskCheckbox)); + expect(currentTaskCheckbox).not.toBeChecked(); + expect(handleComponentChange).toHaveBeenCalledWith({ + ...defaultComponent, + dataTypeIds: ['test4', reservedDataTypes.refDataAsPdf], + }); + expect(handleComponentChange).toHaveBeenCalledTimes(3); + }); + + it('should not save to backend when current task is set to true and output is invalid (no selected attachments)', async () => { + await render( + { + component: { + ...defaultComponent, + dataTypeIds: ['test3'], + }, + }, + 'layoutSetId3', + ); + + // Todo: Combobox onChangeValue trigger on initial render, this can be fixed when we start to use >v0.55.0 of designsystem. Replace value prop with initialValue prop in combobox + expect(handleComponentChange).toHaveBeenCalledTimes(1); + handleComponentChange.mockClear(); + + const currentTaskCheckbox = screen.getByRole('checkbox', { + name: textMock('ux_editor.component_properties.current_task'), + }); + + await act(() => user.click(currentTaskCheckbox)); + expect(currentTaskCheckbox).toBeChecked(); + + expect(handleComponentChange).not.toHaveBeenCalled(); + }); + + it('should handle toggle of "Select All Attachments" checkbox correctly', async () => { + await render( + { + component: { + ...defaultComponent, + dataTypeIds: ['test1', 'test3'], + }, + }, + 'layoutSetId3', + ); + + // Todo: Combobox onChangeValue trigger on initial render, this can be fixed when we start to use >v0.55.0 of designsystem. Replace value prop with initialValue prop in combobox + expect(handleComponentChange).toHaveBeenCalledTimes(1); + handleComponentChange.mockClear(); + + const selectAllCheckbox = screen.getByRole('checkbox', { + name: textMock('ux_editor.component_properties.select_all_attachments'), + }); + await act(() => user.click(selectAllCheckbox)); + expect(selectAllCheckbox).toBeChecked(); + // Combobox is also triggered, because current task is set to true and makes the combobox to trigger onChangeValue because of filter update + expect(handleComponentChange).toHaveBeenCalledWith({ + ...defaultComponent, + dataTypeIds: [], + }); + expect(handleComponentChange).toHaveBeenCalledTimes(2); + + handleComponentChange.mockClear(); + await act(() => user.click(selectAllCheckbox)); + expect(selectAllCheckbox).not.toBeChecked(); + + expect(handleComponentChange).not.toHaveBeenCalled(); + const errorMessage = await waitFor(() => + screen.findByText(textMock('ux_editor.component_title.AttachmentList_error')), + ); + expect(errorMessage).toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/AttachmentListComponent.tsx b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/AttachmentListComponent.tsx new file mode 100644 index 00000000000..1ad639edc46 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/AttachmentListComponent.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import type { IGenericEditComponent } from '../../componentConfig'; +import { useAppMetadataQuery } from 'app-development/hooks/queries'; +import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; +import { useLayoutSetsQuery } from 'app-shared/hooks/queries/useLayoutSetsQuery'; +import { useAppContext } from '../../../../hooks/useAppContext'; +import type { ComponentType } from 'app-shared/types/ComponentType'; +import { useTranslation } from 'react-i18next'; +import { reservedDataTypes } from './attachmentListUtils'; +import { AttachmentListInternalFormat } from './AttachmentListInternalFormat'; +import { StudioSpinner } from '@studio/components'; +import type { ApplicationMetadata, DataTypeElement } from 'app-shared/types/ApplicationMetadata'; +import type { LayoutSets } from 'app-shared/types/api/LayoutSetsResponse'; +import type { AvailableAttachementLists, InternalDataTypesFormat } from './types'; +import { convertInternalToExternalFormat } from './convertFunctions/convertToExternalFormat'; +import { convertExternalToInternalFormat } from './convertFunctions/convertToInternalFormat'; + +export const AttachmentListComponent = ({ + component, + handleComponentChange, +}: IGenericEditComponent) => { + const { t } = useTranslation(); + const { org, app } = useStudioUrlParams(); + const { data: layoutSets } = useLayoutSetsQuery(org, app); + const { data: appMetadata, isPending: appMetadataPending } = useAppMetadataQuery(org, app); + const { selectedFormLayoutSetName } = useAppContext(); + + if (appMetadataPending) + return ; + + const availableAttachments: AvailableAttachementLists = getAvailableAttachments( + layoutSets, + selectedFormLayoutSetName, + appMetadata.dataTypes, + ); + + const handleChange = (internalDataFormat: InternalDataTypesFormat) => { + const externalDataFormat = convertInternalToExternalFormat( + availableAttachments, + internalDataFormat, + ); + + handleComponentChange({ + ...component, + dataTypeIds: externalDataFormat, + }); + }; + + const { dataTypeIds = [] } = component || {}; + const internalDataFormat = convertExternalToInternalFormat(availableAttachments, dataTypeIds); + + return ( + + ); +}; + +const getAvailableAttachments = ( + layoutSets: LayoutSets, + selectedFormLayoutSetName: string, + availableDataTypes: DataTypeElement[], +): AvailableAttachementLists => { + const attachmentsCurrentTasks = getAttachments( + currentTasks(layoutSets, selectedFormLayoutSetName), + availableDataTypes, + ); + const attachmentsAllTasks = getAttachments( + sampleTasks(layoutSets, selectedFormLayoutSetName), + availableDataTypes, + ); + + return { + attachmentsCurrentTasks, + attachmentsAllTasks, + }; +}; + +const getAttachments = ( + tasks: string[], + availableDataTypes: Partial, +): string[] => { + const filteredAttachments = filterAttachments(availableDataTypes, tasks); + const mappedAttachments = filteredAttachments?.map((dataType) => dataType.id); + const sortedAttachments = mappedAttachments.sort((a, b) => a.localeCompare(b)); + return sortedAttachments; +}; + +const filterAttachments = ( + availableDataTypes: Partial, + tasks: string[], +) => { + return availableDataTypes.filter((dataType) => { + const noReservedType = !Object.values(reservedDataTypes).includes(dataType.id); + const noAppLogic = !dataType.appLogic; + const hasMatchingTask = tasks.some((task) => dataType.taskId === task); + + return noReservedType && noAppLogic && hasMatchingTask; + }); +}; + +const currentTasks = (layoutSets: LayoutSets, selectedFormLayoutSetName: string): string[] => + layoutSets.sets.find((layoutSet) => layoutSet.id === selectedFormLayoutSetName).tasks; + +const sampleTasks = (layoutSets: LayoutSets, selectedFormLayoutSetName: string): string[] => { + const tasks = []; + for (const layoutSet of layoutSets.sets) { + tasks.push(...layoutSet.tasks); + if (layoutSet.id === selectedFormLayoutSetName) { + break; + } + } + return tasks; +}; diff --git a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/AttachmentListContent.module.css b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/AttachmentListContent.module.css new file mode 100644 index 00000000000..65f37cd45ae --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/AttachmentListContent.module.css @@ -0,0 +1,3 @@ +.comboboxLabel { + background-color: transparent; +} diff --git a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/AttachmentListContent.tsx b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/AttachmentListContent.tsx new file mode 100644 index 00000000000..9334485ab05 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/AttachmentListContent.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { Combobox, Label, Checkbox } from '@digdir/design-system-react'; +import { useTranslation } from 'react-i18next'; +import classes from './AttachmentListContent.module.css'; + +type IAttachmentListContent = { + currentAvailableAttachments: string[]; + selectedDataTypes: string[]; + onChange: (selectedDataTypes: string[]) => void; +}; + +export const AttachmentListContent = ({ + currentAvailableAttachments, + selectedDataTypes, + onChange, +}: IAttachmentListContent) => { + const { t } = useTranslation(); + const checkboxInIndeterminateState = + selectedDataTypes.length > 0 && selectedDataTypes.length < currentAvailableAttachments.length; + + return ( + <> + + onChange(e.target.checked ? currentAvailableAttachments : [])} + > + {t('ux_editor.component_properties.select_all_attachments')} + + + {currentAvailableAttachments?.map((attachment) => { + return ( + + ); + })} + + + ); +}; diff --git a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/AttachmentListInternalFormat.tsx b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/AttachmentListInternalFormat.tsx new file mode 100644 index 00000000000..7c173acf59d --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/AttachmentListInternalFormat.tsx @@ -0,0 +1,105 @@ +import React, { useState, useEffect } from 'react'; +import { Fieldset, Switch } from '@digdir/design-system-react'; +import { AttachmentListContent } from './AttachmentListContent'; +import { useTranslation } from 'react-i18next'; +import { extractCurrentAvailableAttachments, isSelectionValid } from './attachmentListUtils'; +import { ArrayUtils } from '@studio/pure-functions'; +import type { AvailableAttachementLists, InternalDataTypesFormat } from './types'; + +type AttachmentListInternalFormatProps = { + onChange: (selectedDataTypes: InternalDataTypesFormat) => void; + availableAttachments: AvailableAttachementLists; + internalDataFormat: InternalDataTypesFormat; +}; + +export const AttachmentListInternalFormat = ({ + onChange, + availableAttachments, + internalDataFormat, +}: AttachmentListInternalFormatProps) => { + const [dataTypesState, setDataTypesState] = useState(internalDataFormat); + const [isValid, setIsValid] = useState(true); + const { t } = useTranslation(); + + useEffect(() => { + setDataTypesState(internalDataFormat); + setIsValid(true); + }, [internalDataFormat]); + + const handleChange = (dataTypes: InternalDataTypesFormat) => { + setDataTypesState((prev) => ({ ...prev, ...dataTypes })); + if (isSelectionValid(dataTypes)) { + setIsValid(true); + onChange(dataTypes); + } else { + setIsValid(false); + } + }; + + const handleIncludePdfChange = (isChecked: boolean) => { + const updatedDataTypes: InternalDataTypesFormat = { + ...dataTypesState, + includePdf: isChecked, + }; + handleChange(updatedDataTypes); + }; + + const handleCurrentTaskChange = (isCurrentTask: boolean) => { + const dataTypesToBeSaved = isCurrentTask + ? getAllowedDataTypesOnCurrentTask( + dataTypesState.selectedDataTypes, + availableAttachments.attachmentsCurrentTasks, + ) + : dataTypesState.selectedDataTypes; + + handleChange({ + ...dataTypesState, + selectedDataTypes: dataTypesToBeSaved, + currentTask: isCurrentTask, + }); + }; + + const handleSelectedDataTypesChange = (selectedDataTypes: string[]) => { + const updatedDataTypes: InternalDataTypesFormat = { ...dataTypesState, selectedDataTypes }; + handleChange(updatedDataTypes); + }; + + const currentAvailableAttachments = extractCurrentAvailableAttachments( + dataTypesState.currentTask, + availableAttachments, + ); + const { includePdf, currentTask, selectedDataTypes } = dataTypesState; + return ( +
+ handleCurrentTaskChange(e.target.checked)} + size='small' + checked={currentTask} + > + {t('ux_editor.component_properties.current_task')} + + handleIncludePdfChange(e.target.checked)} + size='small' + checked={includePdf} + > + {t('ux_editor.component_properties.select_pdf')} + + +
+ ); +}; + +const getAllowedDataTypesOnCurrentTask = ( + selectedDataTypes: string[], + attachmentsCurrentTasks: string[], +): string[] => { + return ArrayUtils.intersection(selectedDataTypes, attachmentsCurrentTasks); +}; diff --git a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/attachmentListUtils.test.ts b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/attachmentListUtils.test.ts new file mode 100644 index 00000000000..8126e16f9b5 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/attachmentListUtils.test.ts @@ -0,0 +1,49 @@ +import { isSelectionValid } from './attachmentListUtils'; +import type { InternalDataTypesFormat } from './types'; + +describe('validateSelection', () => { + it('should return false when no selection', () => { + const output: InternalDataTypesFormat = { + currentTask: false, + includePdf: false, + selectedDataTypes: [], + }; + expect(isSelectionValid(output)).toBeFalsy(); + }); + + it('should return true when there is a selection', () => { + const output: InternalDataTypesFormat = { + currentTask: false, + includePdf: false, + selectedDataTypes: ['attachment1'], + }; + expect(isSelectionValid(output)).toBeTruthy(); + }); + + it('should return true when there is a selection and current task', () => { + const output: InternalDataTypesFormat = { + currentTask: true, + includePdf: false, + selectedDataTypes: ['attachment1'], + }; + expect(isSelectionValid(output)).toBeTruthy(); + }); + + it('should return false when there is only current task', () => { + const output: InternalDataTypesFormat = { + currentTask: true, + includePdf: false, + selectedDataTypes: [], + }; + expect(isSelectionValid(output)).toBeFalsy(); + }); + + it('should return true when there is only pdf', () => { + const output: InternalDataTypesFormat = { + currentTask: false, + includePdf: true, + selectedDataTypes: [], + }; + expect(isSelectionValid(output)).toBeTruthy; + }); +}); diff --git a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/attachmentListUtils.ts b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/attachmentListUtils.ts new file mode 100644 index 00000000000..aa5685927c4 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/attachmentListUtils.ts @@ -0,0 +1,20 @@ +import type { AvailableAttachementLists, InternalDataTypesFormat } from './types'; + +export const reservedDataTypes = { + includeAll: 'include-all', + currentTask: 'current-task', + refDataAsPdf: 'ref-data-as-pdf', +}; + +export const extractCurrentAvailableAttachments = ( + includeCurrentTask: boolean, + attachments: AvailableAttachementLists, +): string[] => + includeCurrentTask ? attachments.attachmentsCurrentTasks : attachments.attachmentsAllTasks; + +export const isSelectionValid = ({ + includePdf, + selectedDataTypes, +}: InternalDataTypesFormat): boolean => { + return includePdf || selectedDataTypes.length > 0; +}; diff --git a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/convertFunctions/convertToExternalFormat.test.ts b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/convertFunctions/convertToExternalFormat.test.ts new file mode 100644 index 00000000000..dc00e4f74df --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/convertFunctions/convertToExternalFormat.test.ts @@ -0,0 +1,100 @@ +import type { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; +import type { AvailableAttachementLists, InternalDataTypesFormat } from '../types'; +import { reservedDataTypes } from '../attachmentListUtils'; +import { convertInternalToExternalFormat } from './convertToExternalFormat'; + +describe('Convert to external format: convertInternalToExternalFormat', () => { + type TestCaseConvertToExternalFormat = { + availableAttachments: AvailableAttachementLists; + internalFormat: InternalDataTypesFormat; + expectedResult: string[]; + }; + + const testCasesDataTypes: KeyValuePairs = { + 'all attachments and current task and pdf': { + availableAttachments: { + attachmentsCurrentTasks: ['attachment2'], + attachmentsAllTasks: ['attachment1', 'attachment2'], + }, + internalFormat: { + currentTask: true, + includePdf: true, + selectedDataTypes: ['attachment2'], + }, + expectedResult: [reservedDataTypes.includeAll, reservedDataTypes.currentTask], + }, + 'all attachments and all tasks and pdf': { + availableAttachments: { + attachmentsCurrentTasks: ['attachment2'], + attachmentsAllTasks: ['attachment1', 'attachment2'], + }, + internalFormat: { + currentTask: false, + includePdf: true, + selectedDataTypes: ['attachment1', 'attachment2'], + }, + expectedResult: [reservedDataTypes.includeAll], + }, + 'all attachments and current task': { + availableAttachments: { + attachmentsCurrentTasks: ['attachment2'], + attachmentsAllTasks: ['attachment1', 'attachment2'], + }, + internalFormat: { + currentTask: true, + includePdf: false, + selectedDataTypes: ['attachment2'], + }, + expectedResult: [reservedDataTypes.currentTask], + }, + 'all attachments and all tasks': { + availableAttachments: { + attachmentsCurrentTasks: ['attachment1', 'attachment2'], + attachmentsAllTasks: ['attachment1', 'attachment2'], + }, + internalFormat: { + currentTask: false, + includePdf: false, + selectedDataTypes: ['attachment1', 'attachment2'], + }, + expectedResult: [], + }, + 'some attachments and current task and pdf': { + availableAttachments: { + attachmentsCurrentTasks: ['attachment2', 'attachment3'], + attachmentsAllTasks: ['attachment1', 'attachment2', 'attachment3'], + }, + internalFormat: { + currentTask: true, + includePdf: true, + selectedDataTypes: ['attachment2'], + }, + expectedResult: [ + 'attachment2', + reservedDataTypes.refDataAsPdf, + reservedDataTypes.currentTask, + ], + }, + 'some attachments and all tasks': { + availableAttachments: { + attachmentsCurrentTasks: ['attachment2'], + attachmentsAllTasks: ['attachment1', 'attachment2'], + }, + internalFormat: { + currentTask: false, + includePdf: false, + selectedDataTypes: ['attachment1'], + }, + expectedResult: ['attachment1'], + }, + }; + + const testCaseNames: (keyof typeof testCasesDataTypes)[] = Object.keys(testCasesDataTypes); + + it.each(testCaseNames)('should convert to external format with %s', (testCaseName) => { + const testCase = testCasesDataTypes[testCaseName]; + expect( + convertInternalToExternalFormat(testCase.availableAttachments, testCase.internalFormat), + ).toEqual(testCase.expectedResult); + }); +}); diff --git a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/convertFunctions/convertToExternalFormat.ts b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/convertFunctions/convertToExternalFormat.ts new file mode 100644 index 00000000000..72daaafaedb --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/convertFunctions/convertToExternalFormat.ts @@ -0,0 +1,43 @@ +import { extractCurrentAvailableAttachments, reservedDataTypes } from '../attachmentListUtils'; +import type { AvailableAttachementLists, InternalDataTypesFormat } from '../types'; + +export const convertInternalToExternalFormat = ( + availableAttachments: AvailableAttachementLists, + dataTypeIds: InternalDataTypesFormat, +): string[] => { + const { currentTask: includeCurrentTask } = dataTypeIds; + + const currentAttachments = extractCurrentAvailableAttachments( + includeCurrentTask, + availableAttachments, + ); + const selectedDataTypesExternalFormat = convertSelectedDataTypes(dataTypeIds, currentAttachments); + + if (includeCurrentTask) { + selectedDataTypesExternalFormat.push(reservedDataTypes.currentTask); + } + + return selectedDataTypesExternalFormat; +}; + +const convertSelectedDataTypes = ( + dataTypeIds: InternalDataTypesFormat, + currentAttachments: string[], +) => { + const { includePdf, selectedDataTypes } = dataTypeIds; + + const includeAllAttachments = selectedDataTypes.length === currentAttachments.length; + + return includeAllAttachments + ? convertAllAttachToExternalFormat(includePdf) + : convertSomeAttachToExternalFormat(selectedDataTypes, includePdf); +}; + +const convertAllAttachToExternalFormat = (includePdf: boolean): string[] => + includePdf ? [reservedDataTypes.includeAll] : []; + +const convertSomeAttachToExternalFormat = ( + selectedDataTypes: string[], + includePdf: boolean, +): string[] => + includePdf ? [...selectedDataTypes, reservedDataTypes.refDataAsPdf] : selectedDataTypes; diff --git a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/convertFunctions/convertToInternalFormat.test.ts b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/convertFunctions/convertToInternalFormat.test.ts new file mode 100644 index 00000000000..226a59bde8b --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/convertFunctions/convertToInternalFormat.test.ts @@ -0,0 +1,111 @@ +import { convertExternalToInternalFormat } from './convertToInternalFormat'; +import { reservedDataTypes } from '../attachmentListUtils'; +import type { AvailableAttachementLists, InternalDataTypesFormat } from '../types'; +import type { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; + +describe('Convert to internal format: convertExternalToInternalFormat', () => { + type TestCaseConvertInternalFormat = { + availableAttachments: AvailableAttachementLists; + dataTypeIds: string[]; + expectedResult: InternalDataTypesFormat; + }; + + describe('convert all data', () => { + const testCasesAllDataTypes: KeyValuePairs = { + 'all attachments and current task and pdf': { + availableAttachments: { + attachmentsCurrentTasks: ['attachment2'], + attachmentsAllTasks: ['attachment1', 'attachment2'], + }, + dataTypeIds: [reservedDataTypes.includeAll, reservedDataTypes.currentTask], + expectedResult: { + currentTask: true, + includePdf: true, + selectedDataTypes: ['attachment2'], + }, + }, + 'all attachments and all tasks and pdf': { + availableAttachments: { + attachmentsCurrentTasks: ['attachment2'], + attachmentsAllTasks: ['attachment1', 'attachment2'], + }, + dataTypeIds: [reservedDataTypes.includeAll], + expectedResult: { + currentTask: false, + includePdf: true, + selectedDataTypes: ['attachment1', 'attachment2'], + }, + }, + 'all attachments and current task': { + availableAttachments: { + attachmentsCurrentTasks: ['attachment2'], + attachmentsAllTasks: ['attachment1', 'attachment2'], + }, + dataTypeIds: [reservedDataTypes.currentTask], + expectedResult: { + currentTask: true, + includePdf: false, + selectedDataTypes: ['attachment2'], + }, + }, + 'all attachments and all tasks': { + availableAttachments: { + attachmentsCurrentTasks: ['attachment2'], + attachmentsAllTasks: ['attachment1', 'attachment2'], + }, + dataTypeIds: [], + expectedResult: { + currentTask: false, + includePdf: false, + selectedDataTypes: ['attachment1', 'attachment2'], + }, + }, + 'some attachments and current task and pdf': { + availableAttachments: { + attachmentsCurrentTasks: ['attachment2'], + attachmentsAllTasks: ['attachment1', 'attachment2'], + }, + dataTypeIds: ['attachment2', reservedDataTypes.refDataAsPdf, reservedDataTypes.currentTask], + expectedResult: { + currentTask: true, + includePdf: true, + selectedDataTypes: ['attachment2'], + }, + }, + 'all tasks and pdf': { + availableAttachments: { + attachmentsCurrentTasks: ['attachment1'], + attachmentsAllTasks: ['attachment1', 'attachment2'], + }, + dataTypeIds: ['attachment1', reservedDataTypes.refDataAsPdf], + expectedResult: { + currentTask: false, + includePdf: true, + selectedDataTypes: ['attachment1'], + }, + }, + 'only pdf': { + availableAttachments: { + attachmentsCurrentTasks: ['attachment1'], + attachmentsAllTasks: ['attachment1', 'attachment2'], + }, + dataTypeIds: [reservedDataTypes.refDataAsPdf], + expectedResult: { + currentTask: false, + includePdf: true, + selectedDataTypes: [], + }, + }, + }; + + const testCaseNames: (keyof typeof testCasesAllDataTypes)[] = + Object.keys(testCasesAllDataTypes); + + it.each(testCaseNames)('should convert to internal format with %s', (testCaseName) => { + const testCase = testCasesAllDataTypes[testCaseName]; + expect( + convertExternalToInternalFormat(testCase.availableAttachments, testCase.dataTypeIds), + ).toEqual(testCase.expectedResult); + }); + }); +}); diff --git a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/convertFunctions/convertToInternalFormat.ts b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/convertFunctions/convertToInternalFormat.ts new file mode 100644 index 00000000000..728965a9475 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/convertFunctions/convertToInternalFormat.ts @@ -0,0 +1,46 @@ +import { extractCurrentAvailableAttachments, reservedDataTypes } from '../attachmentListUtils'; +import type { AvailableAttachementLists, InternalDataTypesFormat } from '../types'; +import { ArrayUtils } from '@studio/pure-functions'; + +export const convertExternalToInternalFormat = ( + availableAttachments: AvailableAttachementLists, + dataTypeIds: string[], +): InternalDataTypesFormat => ({ + includePdf: isPdfSelected(dataTypeIds), + currentTask: isCurrentTaskSelected(dataTypeIds), + selectedDataTypes: getSelectedDataTypes(dataTypeIds, availableAttachments), +}); + +const isPdfSelected = (dataTypeIds: string[]): boolean => + dataTypeIds.includes(reservedDataTypes.refDataAsPdf) || + dataTypeIds.includes(reservedDataTypes.includeAll); + +const isCurrentTaskSelected = (dataTypeIds: string[]): boolean => + dataTypeIds.includes(reservedDataTypes.currentTask); + +const getSelectedDataTypes = ( + dataTypeIds: string[], + availableAttachments: AvailableAttachementLists, +): string[] => { + const availableDataTypes = extractCurrentAvailableAttachments( + isCurrentTaskSelected(dataTypeIds), + availableAttachments, + ); + const includeAllDataTypes = shouldIncludeAllDataTypes(dataTypeIds, availableDataTypes); + const selectedDataTypeIds = ArrayUtils.intersection(dataTypeIds, availableDataTypes); + + return includeAllDataTypes ? availableDataTypes : selectedDataTypeIds; +}; + +const shouldIncludeAllDataTypes = ( + dataTypeIds: string[], + availableDataTypes: string[], +): boolean => { + const selectedDataTypeIds = ArrayUtils.intersection(dataTypeIds, availableDataTypes); + const allDataTypesSelected = selectedDataTypeIds.length === availableDataTypes.length; + //In cases when reserved data types are the only ones selected or no attachments (except pdf), we should include all data types + const noAttachmentsAndNoPdf = + selectedDataTypeIds.length === 0 && !dataTypeIds.includes(reservedDataTypes.refDataAsPdf); + + return allDataTypesSelected || noAttachmentsAndNoPdf; +}; diff --git a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/index.ts b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/index.ts new file mode 100644 index 00000000000..19050b55d8c --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/index.ts @@ -0,0 +1 @@ +export { AttachmentListComponent } from './AttachmentListComponent'; diff --git a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/types.ts b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/types.ts new file mode 100644 index 00000000000..4fd69ced801 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/AttachmentList/types.ts @@ -0,0 +1,10 @@ +export type AvailableAttachementLists = { + attachmentsCurrentTasks: string[]; + attachmentsAllTasks: string[]; +}; + +export type InternalDataTypesFormat = { + currentTask: boolean; + includePdf: boolean; + selectedDataTypes: string[]; +}; diff --git a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/ComponentSpecificContent.tsx b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/ComponentSpecificContent.tsx index 880200b7068..f00c7588dce 100644 --- a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/ComponentSpecificContent.tsx +++ b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/ComponentSpecificContent.tsx @@ -3,6 +3,7 @@ import { ImageComponent } from './Image'; import type { IGenericEditComponent } from '../componentConfig'; import { ComponentType } from 'app-shared/types/ComponentType'; import { MapComponent } from './Map'; +import { AttachmentListComponent } from './AttachmentList'; export function ComponentSpecificContent({ component, @@ -22,6 +23,16 @@ export function ComponentSpecificContent({ case ComponentType.Map: { return ; } + + case ComponentType.AttachmentList: { + return ( + + ); + } + default: { return null; } diff --git a/frontend/packages/ux-editor/src/hooks/mutations/useDeleteAppAttachmentMetadataMutation.ts b/frontend/packages/ux-editor/src/hooks/mutations/useDeleteAppAttachmentMetadataMutation.ts index 0b9afc03be2..120af134f3b 100644 --- a/frontend/packages/ux-editor/src/hooks/mutations/useDeleteAppAttachmentMetadataMutation.ts +++ b/frontend/packages/ux-editor/src/hooks/mutations/useDeleteAppAttachmentMetadataMutation.ts @@ -1,12 +1,19 @@ -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useServicesContext } from 'app-shared/contexts/ServicesContext'; +import { QueryKey } from 'app-shared/types/QueryKey'; export const useDeleteAppAttachmentMetadataMutation = (org: string, app: string) => { + const queryClient = useQueryClient(); const { deleteAppAttachmentMetadata } = useServicesContext(); return useMutation({ mutationFn: async (id: string) => { await deleteAppAttachmentMetadata(org, app, id); return id; }, + onSuccess: () => { + // Issue: this is a workaround to reset the query to get the updated app metadata after deleting an attachment where combobox value is not updated immidiately + // Todo: this may be solved and can be removed when we start to use >v0.55.0 of designsystem. Replace value prop with initialValue prop in combobox + queryClient.resetQueries({ queryKey: [QueryKey.AppMetadata, org, app] }); + }, }); }; diff --git a/frontend/packages/ux-editor/src/testing/schemas/json/layout/layout.schema.v1.json b/frontend/packages/ux-editor/src/testing/schemas/json/layout/layout.schema.v1.json index bcf71fbaadd..5dbe973262f 100644 --- a/frontend/packages/ux-editor/src/testing/schemas/json/layout/layout.schema.v1.json +++ b/frontend/packages/ux-editor/src/testing/schemas/json/layout/layout.schema.v1.json @@ -1409,12 +1409,6 @@ "title": "Data type IDs", "description": "List of data type IDs for the attachment list to show.", "examples": [["SomeDataType", "SomeOtherDataType"]] - }, - "includePDF": { - "type": "boolean", - "title": "Include PDF as attachments", - "description": "Set the flag if the list of attachments should include PDF of answers.", - "default": false } } }, diff --git a/frontend/packages/ux-editor/src/types/FormComponent.ts b/frontend/packages/ux-editor/src/types/FormComponent.ts index be80cef285e..b30ba06e1d9 100644 --- a/frontend/packages/ux-editor/src/types/FormComponent.ts +++ b/frontend/packages/ux-editor/src/types/FormComponent.ts @@ -40,6 +40,7 @@ export type FormButtonComponent = FormComponent< ComponentType.Button | ComponentType.NavigationButtons >; export type FormAddressComponent = FormComponent; +export type FormAttachmentListComponent = FormComponent; export type FormComponent = { [componentType in ComponentType]: FormComponentBase & diff --git a/frontend/packages/ux-editor/src/utils/formLayoutUtils.ts b/frontend/packages/ux-editor/src/utils/formLayoutUtils.ts index b2eb26b49e2..8b9e8c0fdef 100644 --- a/frontend/packages/ux-editor/src/utils/formLayoutUtils.ts +++ b/frontend/packages/ux-editor/src/utils/formLayoutUtils.ts @@ -5,8 +5,8 @@ import type { IToolbarElement, } from '../types/global'; import { BASE_CONTAINER_ID, MAX_NESTED_GROUP_LEVEL } from 'app-shared/constants'; -import { ObjectUtils } from '@studio/pure-functions'; -import { insertArrayElementAtPos, removeItemByValue } from 'app-shared/utils/arrayUtils'; +import { insertArrayElementAtPos } from 'app-shared/utils/arrayUtils'; +import { ArrayUtils, ObjectUtils } from '@studio/pure-functions'; import { ComponentType } from 'app-shared/types/ComponentType'; import type { FormComponent } from '../types/FormComponent'; import { generateFormItem } from './component'; @@ -190,7 +190,10 @@ export const removeComponent = (layout: IInternalLayout, componentId: string): I const newLayout = ObjectUtils.deepCopy(layout); const containerId = findParentId(layout, componentId); if (containerId) { - newLayout.order[containerId] = removeItemByValue(newLayout.order[containerId], componentId); + newLayout.order[containerId] = ArrayUtils.removeItemByValue( + newLayout.order[containerId], + componentId, + ); delete newLayout.components[componentId]; } return newLayout; @@ -282,7 +285,10 @@ export const moveLayoutItem = ( const item = getItem(newLayout, id); item.pageIndex = calculateNewPageIndex(newLayout, newContainerId, newPosition); if (oldContainerId) { - newLayout.order[oldContainerId] = removeItemByValue(newLayout.order[oldContainerId], id); + newLayout.order[oldContainerId] = ArrayUtils.removeItemByValue( + newLayout.order[oldContainerId], + id, + ); newLayout.order[newContainerId] = insertArrayElementAtPos( newLayout.order[newContainerId], id,