Skip to content

Bind List/MultipleSelect/Checkboxes to a repeating group #3236

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

Merged
merged 70 commits into from
May 7, 2025
Merged
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
52ccc87
Feature/list component with checkboxes (#2926)
walldenfilippa Feb 6, 2025
d1e54d9
Merge branch 'refs/heads/main' into feat/list-checkboxes
paal2707 Feb 11, 2025
c0f99b4
Remove errors and bugs
paal2707 Feb 13, 2025
b23049f
Allowing 'integer' as a number type, fixing type issues in data model…
Feb 13, 2025
38ca4c8
Fix validation message and config
paal2707 Feb 13, 2025
87c5ece
Cypress test for list with checkboxes and changes to support only man…
paal2707 Feb 18, 2025
7771db9
Merge branch 'refs/heads/main' into feat/list-checkboxes
paal2707 Feb 19, 2025
a1b6cf7
Fix better tests
paal2707 Feb 25, 2025
12c0e86
Refactor and cleanup
paal2707 Feb 25, 2025
ba657ee
Refactor and cleanup
paal2707 Feb 25, 2025
26cd69f
Refactor and cleanup
paal2707 Feb 25, 2025
e725517
Fix caption on listSummary
paal2707 Feb 25, 2025
57a1f77
Fix caption on listSummary
paal2707 Feb 25, 2025
5ac9883
Refactor for readability
paal2707 Feb 25, 2025
1ff36ea
Merge branch 'refs/heads/main' into feat/list-checkboxes
paal2707 Feb 25, 2025
c4044d2
Fix error with tests after refactoring
paal2707 Feb 26, 2025
82908af
Fix error with tests after refactoring
paal2707 Feb 26, 2025
f6ae317
Fix error with tests after refactoring
paal2707 Feb 26, 2025
b945263
Merge branch 'refs/heads/main' into feat/multiselect-to-repeating-group
paal2707 Feb 27, 2025
6d3a617
WIP support list of objects in checkbox component
paal2707 Feb 28, 2025
be55981
checkbox saveToList WIP
Magnusrm Feb 28, 2025
918c889
WIP support list of objects in checkbox component
paal2707 Mar 3, 2025
6c3cb60
WIP support list of objects in checkbox component
paal2707 Mar 4, 2025
7cc27f9
WIP support list of objects in checkbox component
paal2707 Mar 5, 2025
68960e4
checkbox to repgroup rows wip
Magnusrm Mar 5, 2025
0e4b559
Støtte for saveToList i Checkbox komponent og refaktorere List komponent
paal2707 Mar 5, 2025
53c38ab
WIP soft deletion
paal2707 Mar 7, 2025
36c04ca
add layoutvalidation for savetoList
Magnusrm Mar 7, 2025
a5c9ddd
softDelete WIP
Magnusrm Mar 7, 2025
7671249
WIP soft deletion
paal2707 Mar 19, 2025
e49e482
Add functionality for toggling checkbox and hide row.
paal2707 Mar 21, 2025
a1c92da
Endringer fra main
paal2707 Mar 25, 2025
ade8324
Refaktorering og kode rydding
paal2707 Mar 26, 2025
1d9c839
Rename to group from saveToList
paal2707 Mar 27, 2025
622ecdb
Rename to checked from isDeleted
paal2707 Apr 1, 2025
82409e3
Rename to checked from isDeleted
paal2707 Apr 2, 2025
017dbf5
Support group and soft deletion in List component
paal2707 Apr 2, 2025
d0d471d
Support group and soft deletion in MultipleSelect component
paal2707 Apr 2, 2025
8f78575
Support group and soft deletion in MultipleSelect component
paal2707 Apr 4, 2025
b55fe68
Support group and soft deletion in MultipleSelect component
paal2707 Apr 4, 2025
da6630f
Support group and soft deletion in MultipleSelect component
paal2707 Apr 4, 2025
4046922
Support group and soft deletion in MultipleSelect component
paal2707 Apr 4, 2025
12f8589
Merge branch 'refs/heads/main' into feat/multiselect-to-repeating-group
paal2707 Apr 4, 2025
21a4352
Merge branch 'refs/heads/main' into feat/multiselect-to-repeating-group
paal2707 Apr 7, 2025
45d1bec
Fix test
paal2707 Apr 7, 2025
19b274f
Fix test
paal2707 Apr 7, 2025
ce0c8af
Merge branch 'refs/heads/main' into feat/multiselect-to-repeating-group
paal2707 Apr 7, 2025
b225c60
Fix last test
paal2707 Apr 7, 2025
13019ac
Test wip
paal2707 Apr 10, 2025
6eafde1
Fix cypress tests for Checkboxes and multipleSelect
paal2707 Apr 14, 2025
28b0607
Support for nested datamodel structure in group functionality for Li…
paal2707 Apr 25, 2025
609f6f2
Merge branch 'refs/heads/main' into feat/multiselect-to-repeating-group
paal2707 Apr 25, 2025
8121fd5
Support for nested datamodel structure in group functionality for Li…
paal2707 Apr 25, 2025
782bfdc
Fix wrongly removed branch check
paal2707 Apr 28, 2025
bdaec99
Summary test fix
paal2707 Apr 28, 2025
5b06170
Support deep data structure
paal2707 Apr 30, 2025
2cd44fb
Refactoring, doing a pass over the code to get to know it better, red…
Apr 30, 2025
aadb1ab
First pass. Trying to refactor this into two hooks (one for List, one…
Apr 30, 2025
602ba60
Fixing the mistake with value bindings as an array
Apr 30, 2025
dd58c97
This should fix the unselection bug in List
Apr 30, 2025
c0a54ee
Merge branch 'main' into feat/multiselect-to-repeating-group
May 2, 2025
a3a640d
Reverting updated snapshots. These snapshot tests should run without …
May 2, 2025
15ca603
Moving the layout validation to a central place, to avoid code duplic…
May 2, 2025
dd4d1c4
Adding test case to make sure an unchecked row is not visible in summ…
May 2, 2025
05e78a3
Making sure not to show unchecked rows in summaries for List
May 2, 2025
043e914
Move ObjectToGroupLayoutValidator to useSaveToGroup folder
paal2707 May 5, 2025
df4f3eb
Add support for Summary and Summary2 when using group
paal2707 May 5, 2025
50dc8b0
Fix error on tests with useDisplayData on Checkboxes and Multiselect
paal2707 May 6, 2025
1cd8c35
Merge branch 'refs/heads/main' into feat/multiselect-to-repeating-group
paal2707 May 6, 2025
53721a6
Add test for summary2 when using group and soft deletion
paal2707 May 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 19 additions & 15 deletions src/app-components/Label/Label.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { forwardRef } from 'react';
import type { JSX, PropsWithChildren, ReactElement } from 'react';

import { Label as DesignsystemetLabel } from '@digdir/designsystemet-react';
@@ -22,19 +22,22 @@ type LabelProps = {
style?: DesignsystemetLabelProps['style'];
};

export function Label({
label,
htmlFor,
required,
requiredIndicator,
optionalIndicator,
help,
description,
className,
grid,
style,
children,
}: PropsWithChildren<LabelProps>) {
export const Label = forwardRef<HTMLLabelElement, PropsWithChildren<LabelProps>>(function Label(
{
label,
htmlFor,
required,
requiredIndicator,
optionalIndicator,
help,
description,
className,
grid,
style,
children,
},
ref,
) {
if (!label) {
return children;
}
@@ -52,6 +55,7 @@ export function Label({
<span className={classes.labelAndDescWrapper}>
<span className={classes.labelAndHelpWrapper}>
<DesignsystemetLabel
ref={ref}
weight='medium'
size='md'
htmlFor={htmlFor}
@@ -72,4 +76,4 @@ export function Label({
{children}
</Flex>
);
}
});
23 changes: 10 additions & 13 deletions src/components/label/LabelContent.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { forwardRef } from 'react';

import cn from 'classnames';

@@ -23,16 +23,10 @@ export type LabelContentProps = Readonly<{
labelSettings?: ILabelSettings;
}> & { className?: string };

export function LabelContent({
componentId,
label,
description,
required,
readOnly,
help,
labelSettings,
className,
}: LabelContentProps) {
export const LabelContent = forwardRef<HTMLSpanElement, LabelContentProps>(function LabelContent(
{ componentId, label, description, required, readOnly, help, labelSettings, className },
ref,
) {
const { overrideDisplay } = useFormComponentCtx() ?? {};
const { elementAsString } = useLanguage();

@@ -41,7 +35,10 @@ export function LabelContent({
}

return (
<span className={cn(classes.labelWrapper, className)}>
<span
className={cn(classes.labelWrapper, className)}
ref={ref}
>
<span className={classes.labelContainer}>
<span className={classes.labelContent}>
{typeof label === 'string' ? <Lang id={label} /> : label}
@@ -70,4 +67,4 @@ export function LabelContent({
)}
</span>
);
}
});
3 changes: 3 additions & 0 deletions src/features/alertOnChange/DeleteWarningPopover.module.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
.popoverContent {
z-index: 1700;
}
.popoverButtonContainer {
display: flex;
flex-direction: row;
2 changes: 1 addition & 1 deletion src/features/alertOnChange/DeleteWarningPopover.tsx
Original file line number Diff line number Diff line change
@@ -35,7 +35,7 @@ export function DeleteWarningPopover({
onOpenChange={() => setOpen(!open)}
>
<Popover.Trigger asChild>{children}</Popover.Trigger>
<Popover.Content>
<Popover.Content className={classes.popoverContent}>
<div>{messageText}</div>
<div className={classes.popoverButtonContainer}>
<Button
4 changes: 2 additions & 2 deletions src/features/devtools/layoutValidation/types.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { lookupBindingInSchema } from 'src/features/datamodel/SimpleSchemaTraversal';
import type { IDataModelReference } from 'src/layout/common.generated';
import type { CompIntermediate, CompTypes } from 'src/layout/layout';
import type { CompIntermediateExact, CompTypes } from 'src/layout/layout';
import type { LayoutNode } from 'src/utils/layout/LayoutNode';
import type { NodeDataSelector } from 'src/utils/layout/NodesContext';

export interface LayoutValidationCtx<T extends CompTypes> {
node: LayoutNode<T>;
item: CompIntermediate<T>;
item: CompIntermediateExact<T>;
nodeDataSelector: NodeDataSelector;
lookupBinding(reference: IDataModelReference): ReturnType<typeof lookupBindingInSchema>;
}
42 changes: 42 additions & 0 deletions src/features/saveToGroup/ObjectToGroupLayoutValidator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useEffect } from 'react';

import { useLanguage } from 'src/features/language/useLanguage';
import { NodesInternal } from 'src/utils/layout/NodesContext';
import type { NodeValidationProps } from 'src/layout/layout';

export function ObjectToGroupLayoutValidator(props: NodeValidationProps<'List' | 'Checkboxes' | 'MultipleSelect'>) {
const { node, externalItem } = props;
const { langAsString } = useLanguage();
const group = externalItem.dataModelBindings?.group;
const deletionStrategy = externalItem.deletionStrategy;
const checkedBinding = externalItem.dataModelBindings?.checked;

const addError = NodesInternal.useAddError();

useEffect(() => {
let error: string | null = null;

if (!group) {
if (!!deletionStrategy || !!checkedBinding) {
error = langAsString('config_error.deletion_strategy_no_group');
}
} else if (group) {
if (!deletionStrategy) {
error = langAsString('config_error.group_no_deletion_strategy');
}
if (deletionStrategy === 'soft' && !checkedBinding) {
error = langAsString('config_error.soft_delete_no_checked');
}
if (deletionStrategy === 'hard' && !!checkedBinding) {
error = langAsString('config_error.hard_delete_with_checked');
}
}

if (error) {
addError(error, node);
window.logErrorOnce(`Validation error for '${node.id}': ${error}`);
}
}, [addError, node, deletionStrategy, checkedBinding, langAsString, group]);

return null;
}
40 changes: 40 additions & 0 deletions src/features/saveToGroup/layoutValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { lookupErrorAsText } from 'src/features/datamodel/lookupErrorAsText';
import type { LayoutValidationCtx } from 'src/features/devtools/layoutValidation/types';
import type { FormComponent } from 'src/layout/LayoutComponent';

export function validateSimpleBindingWithOptionalGroup<T extends 'Checkboxes' | 'MultipleSelect'>(
def: FormComponent<T>,
ctx: LayoutValidationCtx<T>,
) {
const errors: string[] = [];
const allowedLeafTypes = ['string', 'boolean', 'number', 'integer'];
const dataModelBindings = ctx.item.dataModelBindings ?? {};
const groupBinding = dataModelBindings?.group;
const simpleBinding = dataModelBindings?.simpleBinding;

if (groupBinding) {
const [groupErrors] = def.validateDataModelBindingsAny(ctx, 'group', ['array'], false);
errors.push(...(groupErrors || []));

if (!simpleBinding.field.startsWith(`${groupBinding.field}.`)) {
errors.push(`simpleBinding must start with the group binding field (must point to a property inside the group)`);
}
const simpleBindingsWithoutGroup = simpleBinding.field.replace(`${groupBinding.field}.`, '');
const fieldWithIndex = `${groupBinding.field}[0].${simpleBindingsWithoutGroup}`;
const [schema, err] = ctx.lookupBinding({
field: fieldWithIndex,
dataType: simpleBinding.dataType,
});

if (err) {
errors.push(lookupErrorAsText(err));
} else if (typeof schema?.type !== 'string' || !allowedLeafTypes.includes(schema.type)) {
errors.push(`Field ${simpleBinding} in group must be one of types ${allowedLeafTypes.join(', ')}`);
}
} else {
const [newErrors] = def.validateDataModelBindingsSimple(ctx);
errors.push(...(newErrors || []));
}

return errors;
}
162 changes: 162 additions & 0 deletions src/features/saveToGroup/useSaveToGroup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import dot from 'dot-object';
import { v4 as uuidv4 } from 'uuid';

import { FD } from 'src/features/formData/FormDataWrite';
import { ALTINN_ROW_ID } from 'src/features/formData/types';
import type { IDataModelBindingsForGroupCheckbox } from 'src/layout/Checkboxes/config.generated';
import type { IDataModelReference } from 'src/layout/common.generated';
import type { IDataModelBindingsForList } from 'src/layout/List/config.generated';
import type { IDataModelBindingsForGroupMultiselect } from 'src/layout/MultipleSelect/config.generated';

type Row = Record<string, unknown>;

interface Bindings {
group?: IDataModelReference;
checked?: IDataModelReference;
values: Record<string, IDataModelReference>;
}

function toRelativePath(group: IDataModelReference | undefined, binding: IDataModelReference | undefined) {
if (group && binding && binding.field.startsWith(`${group.field}.`)) {
return binding.field.substring(group.field.length + 1);
}
return undefined;
}

function isEqual({ group, values }: Bindings, source: Row, formDataRow: Row) {
for (const key in values) {
const path = toRelativePath(group, values[key]);
if (path && source[key] !== dot.pick(path, formDataRow)) {
return false;
}
}

return true;
}

function findRowInFormData(
bindings: Bindings,
row: Row,
formData: Row[] | undefined,
): [number | undefined, Row | undefined] {
for (const [index, formDataRow] of formData?.entries() ?? []) {
if (isEqual(bindings, row, formDataRow)) {
return [index, formDataRow];
}
}

return [undefined, undefined];
}

function useSaveToGroup(bindings: Bindings) {
const { group, checked, values } = bindings;
const formData = FD.useFreshBindings(group ? { group } : {}, 'raw').group as Row[] | undefined;
const setLeafValue = FD.useSetLeafValue();
const appendToList = FD.useAppendToList();
const removeFromList = FD.useRemoveIndexFromList();
const checkedPath = toRelativePath(group, checked);

function toggle(row: Row): void {
if (!group) {
return;
}

const [index, formDataRow] = findRowInFormData(bindings, row, formData);
const isChecked = !!(checkedPath ? dot.pick(checkedPath, formDataRow) : true);
if (isChecked) {
if (checked && checkedPath) {
const field = `${group.field}[${index}].${checkedPath}`;
setLeafValue({ reference: { ...checked, field }, newValue: false });
} else if (index !== undefined) {
removeFromList({ reference: group, index });
}
} else {
if (checked && checkedPath && index !== undefined) {
const field = `${group.field}[${index}].${checkedPath}`;
setLeafValue({ reference: { ...checked, field }, newValue: true });
} else {
const uuid = uuidv4();
const newRow: Row = { [ALTINN_ROW_ID]: uuid };
if (checkedPath) {
dot.str(checkedPath, true, newRow);
}
for (const key in values) {
const path = toRelativePath(group, values[key]);
if (path) {
dot.str(path, row[key], newRow);
}
}
appendToList({ reference: group, newValue: newRow });
}
}
}

return { toggle, formData, checkedPath, enabled: !!group };
}

/**
* Hook used to store List-component objects (rows from the DataList API) to a repeating group
* structure in the data model (aka object[])
*/
export function useSaveObjectToGroup(listBindings: IDataModelBindingsForList) {
const values: Record<string, IDataModelReference> = {};
for (const key in listBindings) {
const binding = listBindings[key];
if (key !== 'group' && key !== 'checked' && binding) {
values[key] = binding;
}
}
const bindings: Bindings = { group: listBindings.group, checked: listBindings.checked, values };
const { formData, enabled, toggle, checkedPath } = useSaveToGroup(bindings);

function isChecked(row: Row) {
const [, formDataObject] = findRowInFormData(bindings, row, formData);
if (checkedPath && formDataObject) {
return !!dot.pick(checkedPath, formDataObject);
}
return false;
}

return { toggle, isChecked, enabled };
}

/**
* Hook used to store simple values to a repeating group structure in the data model (aka. object[])
*/
export function useSaveValueToGroup(
bindings: IDataModelBindingsForGroupCheckbox | IDataModelBindingsForGroupMultiselect,
) {
const { formData, enabled, toggle, checkedPath } = useSaveToGroup({
group: bindings.group,
checked: bindings.checked,
values: bindings.simpleBinding ? { value: bindings.simpleBinding } : {},
});
const valuePath = toRelativePath(bindings.group, bindings.simpleBinding);

const selectedValues =
valuePath && enabled && formData
? formData
.filter((row) => (checkedPath ? dot.pick(checkedPath, row) : true))
.map((row) => `${dot.pick(valuePath, row)}`)
: [];

function toggleValue(value: string) {
enabled && toggle({ value });
}

function setCheckedValues(values: string[]) {
if (!enabled) {
return;
}

const valuesToSet = values.filter((value) => !selectedValues.includes(value));
const valuesToRemove = selectedValues.filter((value) => !values.includes(value));
const valuesToToggle = [...valuesToSet, ...valuesToRemove];

for (const value of valuesToToggle) {
toggle({ value });
}
}

return { selectedValues, toggleValue, setCheckedValues, enabled };
}
5 changes: 5 additions & 0 deletions src/language/texts/en.ts
Original file line number Diff line number Diff line change
@@ -374,6 +374,11 @@ export function en() {
"Data type '{0}' is marked as 'disallowUserCreate=true', but the subform component is configured with 'showAddButton=true'. This is a contradiction, as the user will never be permitted to perform the add-button operation.",
'config_error.file_upload_same_binding':
'There are multiple FileUpload components with the same data model binding. Each component must have a unique binding. Other components with the same binding: {0}',
'config_error.deletion_strategy_no_group':
'The fields deletionStrategy and checked can only be used together with group.',
'config_error.group_no_deletion_strategy': 'When you have set group, you must also set deletionStrategy.',
'config_error.soft_delete_no_checked': 'When you have set deletionStrategy to soft, you must also set "checked".',
'config_error.hard_delete_with_checked': 'When you have set deletionStrategy to hard, you cannot set "checked".',
'version_error.version_mismatch': 'Version mismatch',
'version_error.version_mismatch_message':
'This version of the app frontend is not compatible with the version of the backend libraries you are using. Update to the latest version of the packages and try again.',
Loading