Skip to content
Closed
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
43 changes: 43 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,49 @@ The app has a complex provider tree with many nested contexts. When making chang

There are many usages of `any` or type casting (`as type`) in the codebase. This skips TypeScript completely and is, in most cases, not what we want. We would like to improve the typing by avoiding this moving forward whilst also refactoring and improving existing types by removing such casts and `any`s.

#### Control flow: prefer guard clauses

Handle each case as a flat guard clause that returns early (or `continue`/`return` inside loops) instead of nesting `if`/`else if`/`else`. Each case stays one short, independent block, so the code reads top-to-bottom like its step-by-step comment.

```ts
// Before: nested branches, the main case buried in the final `else`
function useMobileRows(rows: GridRow[] | undefined): MobileRow[] {
const hiddenInRows = useHiddenInRows(rows);

const result: MobileRow[] = [];
let headerCells: GridCell[] = [];
(rows ?? []).forEach((row, index) => {
if (row.header) {
headerCells = row.cells;
} else if (isGridRowHidden(row, hiddenInRows)) {
// skip
} else {
result.push({ row, headerCells, index });
}
});
return result;
}

// After: one guard per case, main case un-nested, code mirrors the comment
function useMobileRows(rows: GridRow[] | undefined): MobileRow[] {
const hiddenInRows = useHiddenInRows(rows);

const result: MobileRow[] = [];
let headerCells: GridCell[] = [];
(rows ?? []).forEach((row, index) => {
if (row.header) {
headerCells = row.cells;
return;
}
if (isGridRowHidden(row, hiddenInRows)) {
return;
}
result.push({ row, headerCells, index });
});
return result;
}
```

#### TanStack Query best practices

Use objects for managing query keys and functions and `queryOptions` for sharing these across the system and central management.
Expand Down
24 changes: 24 additions & 0 deletions src/layout/Grid/Grid.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,27 @@ Altinn, and making sure they are consistent. */
gap: 24px;
flex-direction: column;
}

.mobileRow {
display: flex;
flex-direction: column;
gap: 16px;
}

.mobileCell {
display: flex;
flex-direction: column;
gap: var(--ds-size-1, 4px);
margin: 0;
padding: 0;
}

.mobileCellLabel {
margin: 0;
padding: 0;
}

.mobileCellText {
margin: 0;
padding: 0;
}
153 changes: 152 additions & 1 deletion src/layout/Grid/GridComponent.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import React from 'react';

import { screen } from '@testing-library/react';
import { jest } from '@jest/globals';
import { screen, within } from '@testing-library/react';

import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock';
import * as useDeviceWidths from 'src/hooks/useDeviceWidths';
import { RenderGrid } from 'src/layout/Grid/GridComponent';
import { renderGenericComponentTest } from 'src/test/renderWithProviders';
import type { GridRow } from 'src/layout/common.generated';
import type { CompExternalExact } from 'src/layout/layout';

describe('GridComponent', () => {
Expand Down Expand Up @@ -149,4 +153,151 @@ describe('GridComponent', () => {
const firstCell = cells[0];
expect(firstCell).toHaveAttribute('colspan', '3');
});

describe('mobile layout', () => {
const renderMobile = async (rows: GridRow[]) => {
jest.spyOn(useDeviceWidths, 'useIsMobile').mockReturnValue(true);
return await renderGenericComponentTest({
type: 'Grid',
renderer: (props) => <RenderGrid {...props} />,
component: { rows } as CompExternalExact<'Grid'>,
queries: {
fetchLayouts: async () => ({
FormLayout: {
data: {
layout: [
{ id: 'my-test-component-id', type: 'Grid', rows },
{
id: 'answer-1',
type: 'TextArea',
textResourceBindings: { title: 'Svar' },
dataModelBindings: { simpleBinding: { dataType: defaultDataTypeMock, field: 'answer1' } },
},
{
id: 'answer-2',
type: 'TextArea',
textResourceBindings: { title: 'Svar' },
dataModelBindings: { simpleBinding: { dataType: defaultDataTypeMock, field: 'answer2' } },
},
],
},
},
}),
},
});
};

const kravSvarRows: GridRow[] = [
{
header: true,
cells: [{ text: 'Krav' }, { text: 'Svar' }],
},
{
cells: [{ text: 'Setter brukeren i sentrum' }, { component: 'answer-1' }],
},
{
cells: [{ text: 'Tilrettelegger for gjenbruk' }, { component: 'answer-2' }],
},
];

it('does not render the desktop table on mobile', async () => {
await renderMobile(kravSvarRows);
expect(screen.queryByRole('table')).not.toBeInTheDocument();
expect(screen.queryByRole('columnheader')).not.toBeInTheDocument();
});

it('renders the column title as a label and the row text as a description-list value', async () => {
await renderMobile(kravSvarRows);

// The category text from each body row must remain visible inside a <dd> value
const firstValue = screen.getByText('Setter brukeren i sentrum');
const secondValue = screen.getByText('Tilrettelegger for gjenbruk');
expect(firstValue.closest('dd')).toBeInTheDocument();
expect(secondValue.closest('dd')).toBeInTheDocument();

// The column title "Krav" is rendered as the term (<dt>) inside the same description list
const terms = screen.getAllByText('Krav');
expect(terms).toHaveLength(2);
terms.forEach((term) => expect(term.closest('dt')).toBeInTheDocument());
expect(firstValue.closest('dl')).toContainElement(terms[0]);
expect(secondValue.closest('dl')).toContainElement(terms[1]);
});

it('associates the read-only requirement text with its answer field via a labelled group', async () => {
await renderMobile(kravSvarRows);

// Each field is wrapped in a group whose accessible name carries the column title + requirement text,
// so a screen reader announces the category together with the field.
const firstGroup = screen.getByRole('group', { name: 'Krav Setter brukeren i sentrum' });
expect(within(firstGroup).getByRole('textbox')).toBeInTheDocument();

const secondGroup = screen.getByRole('group', { name: 'Krav Tilrettelegger for gjenbruk' });
expect(within(secondGroup).getByRole('textbox')).toBeInTheDocument();
});

it('does not render the header row as its own standalone stacked row', async () => {
await renderMobile(kravSvarRows);
// The header titles only appear as per-row labels, never as a separate group/row of bare column titles
expect(screen.queryByRole('group', { name: 'Krav' })).not.toBeInTheDocument();
expect(screen.queryByRole('group', { name: 'Svar' })).not.toBeInTheDocument();
});

it('renders category text without a column-title label when there is no header row', async () => {
await renderMobile([
{
cells: [{ text: 'Setter brukeren i sentrum' }, { component: 'answer-1' }],
},
]);

// No header row -> no <dt> label; the text is still associated with the field via the group
expect(screen.queryByText('Krav')).not.toBeInTheDocument();
const group = screen.getByRole('group', { name: 'Setter brukeren i sentrum' });
expect(within(group).getByRole('textbox')).toBeInTheDocument();
});

it('hides rows whose only component is hidden', async () => {
const rows: GridRow[] = [
{
cells: [{ text: 'Visible requirement' }, { component: 'answer-1' }],
},
{
cells: [{ text: 'Hidden requirement' }, { component: 'answer-2' }],
},
];

jest.spyOn(useDeviceWidths, 'useIsMobile').mockReturnValue(true);
await renderGenericComponentTest({
type: 'Grid',
renderer: (props) => <RenderGrid {...props} />,
component: { rows } as CompExternalExact<'Grid'>,
queries: {
fetchLayouts: async () => ({
FormLayout: {
data: {
layout: [
{ id: 'my-test-component-id', type: 'Grid', rows },
{
id: 'answer-1',
type: 'TextArea',
textResourceBindings: { title: 'Svar' },
dataModelBindings: { simpleBinding: { dataType: defaultDataTypeMock, field: 'answer1' } },
},
{
id: 'answer-2',
type: 'TextArea',
hidden: true,
textResourceBindings: { title: 'Svar' },
dataModelBindings: { simpleBinding: { dataType: defaultDataTypeMock, field: 'answer2' } },
},
],
},
},
}),
},
});

expect(screen.getByText('Visible requirement')).toBeInTheDocument();
expect(screen.queryByText('Hidden requirement')).not.toBeInTheDocument();
});
});
});
Loading
Loading