Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
68 changes: 67 additions & 1 deletion src/layout/List/ListComponent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ describe('ListComponent', () => {
await waitFor(() => expect(screen.getAllByRole('radio')).toHaveLength(6));
expect(screen.queryByRole('radio', { checked: true })).not.toBeInTheDocument();

// Select the second row
// Select the second row - find by value since label is empty
const swedishRow = screen.getByRole('radio', { name: /sweden/i });
await user.click(swedishRow);

Expand All @@ -224,4 +224,70 @@ describe('ListComponent', () => {
],
});
});

describe('auto-readonly mode (no dataModelBindings)', () => {
it('should not render radio buttons when no dataModelBindings exist', async () => {
await render({ component: { dataModelBindings: undefined } });

// Wait for the data to load
await waitFor(() => expect(screen.getByText('Norway')).toBeInTheDocument());

// No radio buttons should be present
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
});

it('should not render radio buttons when dataModelBindings is empty object', async () => {
await render({ component: { dataModelBindings: {} } });

// Wait for the data to load
await waitFor(() => expect(screen.getByText('Norway')).toBeInTheDocument());

// No radio buttons should be present
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
});

it('should not allow row selection when no dataModelBindings exist', async () => {
const user = userEvent.setup({ delay: null });
const { formDataMethods } = await render({ component: { dataModelBindings: undefined } });

// Wait for the data to load
await waitFor(() => expect(screen.getByText('Norway')).toBeInTheDocument());

// Try to click a row
const norwegianRow = screen.getByRole('row', { name: /norway/i });
await user.click(norwegianRow);

// No data should be saved
expect(formDataMethods.setMultiLeafValues).not.toHaveBeenCalled();
});

it('should not render controls in mobile view when no dataModelBindings exist', async () => {
jest.spyOn(useDeviceWidths, 'useIsMobile').mockReturnValue(true);

await render({
component: {
dataModelBindings: undefined,
tableHeadersMobile: ['Name', 'FlagLink'],
},
});

// Wait for the data to load
await waitFor(() => expect(screen.getByText('Norway')).toBeInTheDocument());

// No radio buttons should be present
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
});

it('should still display table data when no dataModelBindings exist', async () => {
await render({ component: { dataModelBindings: undefined } });

// All data should still be visible
await waitFor(() => expect(screen.getByText('Norway')).toBeInTheDocument());
expect(screen.getByText('Sweden')).toBeInTheDocument();
expect(screen.getByText('Denmark')).toBeInTheDocument();
expect(screen.getByText('Germany')).toBeInTheDocument();
expect(screen.getByText('Spain')).toBeInTheDocument();
expect(screen.getByText('France')).toBeInTheDocument();
});
});
});
135 changes: 81 additions & 54 deletions src/layout/List/ListComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ import type { PropsFromGenericComponent } from 'src/layout';
import type { IDataModelBindingsForList } from 'src/layout/List/config.generated';

type Row = Record<string, string | number | boolean>;
type SelectionMode = 'readonly' | 'single' | 'multiple';

function getSelectionMode(bindings: IDataModelBindingsForList): SelectionMode {
const hasValidBindings = Object.keys(bindings).length > 0 && Object.values(bindings).some((b) => b !== undefined);

if (!hasValidBindings) {
return 'readonly';
}

return bindings.group ? 'multiple' : 'single';
}

export const ListComponent = ({ baseComponentId }: PropsFromGenericComponent<'List'>) => {
const isMobile = useIsMobile();
Expand Down Expand Up @@ -66,14 +77,19 @@ export const ListComponent = ({ baseComponentId }: PropsFromGenericComponent<'Li
const { data } = useDataListQuery(filter, dataListId, secure, mapping, queryParameters);
const bindings = item.dataModelBindings ?? ({} as IDataModelBindingsForList);

// Determine selection mode based on bindings
const selectionMode = getSelectionMode(bindings);
const readOnly = selectionMode === 'readonly';
const isMultipleSelection = selectionMode === 'multiple';

const { formData, setValues } = useDataModelBindings(bindings, DEFAULT_DEBOUNCE_TIMEOUT, 'raw');
const { toggle, isChecked, enabled } = useSaveObjectToGroup(bindings);
const { toggle, isChecked } = useSaveObjectToGroup(bindings);

const tableHeadersToShowInMobile = Object.keys(tableHeaders).filter(
(key) => !tableHeadersMobile || tableHeadersMobile.includes(key),
);

const selectedRow = !enabled
const selectedRow = !isMultipleSelection
? (data?.listItems.find((row) => Object.keys(formData).every((key) => row[key] === formData[key])) ?? '')
: '';

Expand All @@ -86,7 +102,7 @@ export const ListComponent = ({ baseComponentId }: PropsFromGenericComponent<'Li
}

function isRowSelected(row: Row): boolean {
if (enabled) {
if (isMultipleSelection) {
return isChecked(row);
}
return JSON.stringify(selectedRow) === JSON.stringify(row);
Expand All @@ -96,7 +112,11 @@ export const ListComponent = ({ baseComponentId }: PropsFromGenericComponent<'Li
const description = item.textResourceBindings?.description;

const handleRowClick = (row: Row) => {
if (enabled) {
if (readOnly) {
return;
}

if (isMultipleSelection) {
toggle(row);
} else {
handleSelectedRadioRow({ selectedValue: row });
Expand Down Expand Up @@ -151,10 +171,10 @@ export const ListComponent = ({ baseComponentId }: PropsFromGenericComponent<'Li
required,
});

if (isMobile) {
if (isMobile && !readOnly) {
return (
<ComponentStructureWrapper baseComponentId={baseComponentId}>
{enabled ? (
{isMultipleSelection ? (
<Fieldset>
<Fieldset.Legend>
{description && (
Expand All @@ -170,17 +190,19 @@ export const ListComponent = ({ baseComponentId }: PropsFromGenericComponent<'Li
<RequiredIndicator required={required} />
</Heading>
</Fieldset.Legend>
{data?.listItems.map((row) => (
<Checkbox
key={JSON.stringify(row)}
className={cn(classes.mobile)}
{...getCheckboxProps({ value: JSON.stringify(row) })}
onClick={() => handleRowClick(row)}
value={JSON.stringify(row)}
checked={isChecked(row)}
label={renderListItems(row, tableHeaders)}
/>
))}
<div>
{data?.listItems.map((row, idx) => (
<Checkbox
key={idx}
className={cn(classes.mobile)}
{...getCheckboxProps({ value: JSON.stringify(row) })}
onClick={() => handleRowClick(row)}
value={JSON.stringify(row)}
checked={isChecked(row)}
label={renderListItems(row, tableHeaders)}
/>
))}
</div>
</Fieldset>
) : (
<Fieldset className={classes.mobileGroup}>
Expand All @@ -199,9 +221,9 @@ export const ListComponent = ({ baseComponentId }: PropsFromGenericComponent<'Li
</Fieldset.Description>
)}

{data?.listItems.map((row) => (
{data?.listItems.map((row, idx) => (
<Radio
key={JSON.stringify(row)}
key={idx}
{...getRadioProps({ value: JSON.stringify(row) })}
value={JSON.stringify(row)}
className={cn(classes.mobile, { [classes.selectedRow]: isRowSelected(row) })}
Expand Down Expand Up @@ -246,11 +268,13 @@ export const ListComponent = ({ baseComponentId }: PropsFromGenericComponent<'Li
)}
<Table.Head>
<Table.Row>
<Table.HeaderCell>
<span className={utilClasses.visuallyHidden}>
<Lang id='list_component.controlsHeader' />
</span>
</Table.HeaderCell>
{!readOnly && (
<Table.HeaderCell>
<span className={utilClasses.visuallyHidden}>
<Lang id='list_component.controlsHeader' />
</span>
</Table.HeaderCell>
)}
{Object.entries(tableHeaders).map(([key, value]) => {
const isSortable = sortableColumns?.includes(key);
let sort: AriaAttributes['aria-sort'] = undefined;
Expand All @@ -273,41 +297,44 @@ export const ListComponent = ({ baseComponentId }: PropsFromGenericComponent<'Li
{data?.listItems.map((row) => (
<Table.Row
key={JSON.stringify(row)}
onClick={() => handleRowClick(row)}
onClick={!readOnly ? () => handleRowClick(row) : undefined}
className={cn({ [classes.readOnlyRow]: readOnly })}
>
<Table.Cell
className={cn({
[classes.selectedRowCell]: isRowSelected(row),
})}
>
{enabled ? (
<Checkbox
className={classes.toggleControl}
label={<span className='sr-only'>{getRowLabel(row)}</span>}
onChange={() => {}}
value={JSON.stringify(row)}
checked={isChecked(row)}
name={indexedId}
/>
) : (
<RadioButton
className={classes.toggleControl}
label={getRowLabel(row)}
hideLabel
onChange={() => {
handleSelectedRadioRow({ selectedValue: row });
}}
value={JSON.stringify(row)}
checked={isRowSelected(row)}
name={indexedId}
/>
)}
</Table.Cell>
{!readOnly && (
<Table.Cell
className={cn({
[classes.selectedRowCell]: isRowSelected(row) && !readOnly,
})}
>
{isMultipleSelection ? (
<Checkbox
className={classes.toggleControl}
label={<span className='sr-only'>{getRowLabel(row)}</span>}
onChange={() => toggle(row)}
onClick={(e) => e.stopPropagation()}
value={JSON.stringify(row)}
checked={isChecked(row)}
name={indexedId}
/>
) : (
<RadioButton
className={classes.toggleControl}
label={getRowLabel(row)}
hideLabel
onChange={() => handleSelectedRadioRow({ selectedValue: row })}
onClick={(e) => e.stopPropagation()}
value={JSON.stringify(row)}
checked={isRowSelected(row)}
name={indexedId}
/>
)}
</Table.Cell>
)}
{Object.keys(tableHeaders).map((key) => (
<Table.Cell
key={key}
className={cn({
[classes.selectedRowCell]: isRowSelected(row),
[classes.selectedRowCell]: isRowSelected(row) && !readOnly,
})}
>
{typeof row[key] === 'string' ? <Lang id={row[key]} /> : row[key]}
Expand Down