diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh index 13d1b53b52..238d57b448 100755 --- a/.github/scripts/release.sh +++ b/.github/scripts/release.sh @@ -52,6 +52,7 @@ CURRENT_VERSION_PARTS=(${CURRENT_VERSION//./ }) APP_FULL=${CURRENT_VERSION:1} APP_MAJOR=${CURRENT_VERSION_PARTS[0]:1} APP_MAJOR_MINOR=${CURRENT_VERSION_PARTS[0]:1}.${CURRENT_VERSION_PARTS[1]} +APP_FULL=$(echo "$APP_FULL" | tr '[:upper:]' '[:lower:]') echo "Git tag: '$CURRENT_VERSION'" echo "Full version: '$APP_FULL'" diff --git a/src/codegen/Common.ts b/src/codegen/Common.ts index 2f5d53021d..531f84bab4 100644 --- a/src/codegen/Common.ts +++ b/src/codegen/Common.ts @@ -425,6 +425,28 @@ const common = { ), ).extends(CG.common('ISelectionComponent')), + IGridColumnProperties: () => + new CG.obj( + new CG.prop( + 'colSpan', + new CG.expr(ExprVal.Number) + .optional() + .setTitle('Column span') + .setDescription('Number of columns this cell should span. Defaults to 1 if not set.'), + ), + new CG.prop( + 'hidden', + new CG.expr(ExprVal.Boolean) + .optional() + .setTitle('Hidden column') + .setDescription( + 'Expression or boolean indicating whether this column should be hidden. Defaults to false if not set.', + ), + ), + ) + .setTitle('Grid column properties') + .setDescription('Additional properties for columns in the Grid component'), + // Table configuration: ITableColumnsAlignText: () => new CG.enum('left', 'center', 'right') @@ -588,7 +610,8 @@ const common = { new CG.obj( new CG.prop('component', new CG.str().optional().setTitle('Component ID').setDescription('ID of the component')), new CG.prop('columnOptions', CG.common('ITableColumnProperties').optional()), - ).extends(CG.common('ITableColumnProperties')), + new CG.prop('gridColumnOptions', CG.common('IGridColumnProperties').optional()), + ), GridCellLabelFrom: () => new CG.obj( new CG.prop( @@ -598,7 +621,8 @@ const common = { .setDescription('Set this to a component id to display the label from that component'), ), new CG.prop('columnOptions', CG.common('ITableColumnProperties').optional()), - ).extends(CG.common('ITableColumnProperties')), + new CG.prop('gridColumnOptions', CG.common('IGridColumnProperties').optional()), + ), GridCellText: () => new CG.obj( new CG.prop( @@ -607,7 +631,8 @@ const common = { ), new CG.prop('help', new CG.str().optional().setTitle('Help').setDescription('Help text to display')), new CG.prop('columnOptions', CG.common('ITableColumnProperties').optional()), - ).extends(CG.common('ITableColumnProperties')), + new CG.prop('gridColumnOptions', CG.common('IGridColumnProperties').optional()), + ), GridCell: () => new CG.union(CG.common('GridComponentRef'), CG.null, CG.common('GridCellText'), CG.common('GridCellLabelFrom')), GridRow: () => diff --git a/src/layout/Grid/GridComponent.test.tsx b/src/layout/Grid/GridComponent.test.tsx new file mode 100644 index 0000000000..f75748c4a0 --- /dev/null +++ b/src/layout/Grid/GridComponent.test.tsx @@ -0,0 +1,152 @@ +import React from 'react'; + +import { screen } from '@testing-library/react'; + +import { RenderGrid } from 'src/layout/Grid/GridComponent'; +import { renderGenericComponentTest } from 'src/test/renderWithProviders'; +import type { CompExternalExact } from 'src/layout/layout'; + +describe('GridComponent', () => { + const render = async (hiddenValue: unknown) => + await renderGenericComponentTest({ + type: 'Grid', + renderer: (props) => , + component: { + rows: [ + { + header: true, + readOnly: false, + cells: [ + { text: 'accordion.title' }, + { + text: 'FormLayout', + gridColumnOptions: { hidden: hiddenValue }, + }, + ], + }, + { + header: false, + readOnly: false, + cells: [{ text: 'accordion.title' }, { text: 'FormLayout' }], + }, + ], + } as CompExternalExact<'Grid'>, + }); + + it('hides a column when header cell hidden evaluates to true', async () => { + await render(true); + const headers = screen.getAllByRole('columnheader'); + expect(headers).toHaveLength(1); + + const titleOccurrences = screen.getAllByText('This is a title'); + expect(titleOccurrences).toHaveLength(2); + expect(screen.queryByText('This is a page title')).not.toBeInTheDocument(); + + const bodyCells = screen.getAllByRole('cell'); + expect(bodyCells).toHaveLength(1); + expect(screen.getAllByText('This is a title')[0]).toBeInTheDocument(); + }); + + it('does not hide a column when hidden expression is invalid for boolean', async () => { + await render(false); + + const headers = screen.getAllByRole('columnheader'); + expect(headers).toHaveLength(2); + + const titleOccurrences = screen.getAllByText('This is a title'); + expect(titleOccurrences.length).toBeGreaterThanOrEqual(1); + const pageTitleOccurrences = screen.getAllByText('This is a page title'); + expect(pageTitleOccurrences.length).toBeGreaterThanOrEqual(1); + }); + + it('applies colSpan from text cell settings', async () => { + await renderGenericComponentTest({ + type: 'Grid', + renderer: (props) => , + component: { + rows: [ + { + header: true, + readOnly: false, + cells: [ + { + text: 'accordion.title', + colSpan: 2, + }, + { text: 'FormLayout' }, + ], + }, + ], + } as CompExternalExact<'Grid'>, + }); + + const headers = screen.getAllByRole('columnheader'); + expect(headers.length).toBeGreaterThanOrEqual(1); + const firstHeaderCell = headers[0]; + expect(firstHeaderCell).toHaveAttribute('colspan', '2'); + }); + + it('applies colSpan for component cells', async () => { + await renderGenericComponentTest({ + type: 'Grid', + renderer: (props) => , + component: { + rows: [ + { + header: false, + readOnly: false, + cells: [ + { + component: 'grid-text', + gridColumnOptions: { + colSpan: 3, + }, + }, + ], + }, + ], + } as CompExternalExact<'Grid'>, + queries: { + fetchLayouts: async () => ({ + FormLayout: { + data: { + layout: [ + { + id: 'my-test-component-id', + type: 'Grid', + rows: [ + { + header: false, + readOnly: false, + cells: [ + { + component: 'grid-text', + gridColumnOptions: { + colSpan: 3, + }, + }, + ], + }, + ], + }, + { + id: 'grid-text', + type: 'Text', + value: '', + textResourceBindings: { + title: 'accordion.title', + }, + }, + ], + }, + }, + }), + }, + }); + + const cells = screen.getAllByRole('cell'); + expect(cells.length).toBeGreaterThanOrEqual(1); + const firstCell = cells[0]; + expect(firstCell).toHaveAttribute('colspan', '3'); + }); +}); diff --git a/src/layout/Grid/GridComponent.tsx b/src/layout/Grid/GridComponent.tsx index fcf18e3976..b2ec89c329 100644 --- a/src/layout/Grid/GridComponent.tsx +++ b/src/layout/Grid/GridComponent.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import type { PropsWithChildren } from 'react'; import { Table } from '@digdir/designsystemet-react'; @@ -10,6 +10,9 @@ import { Fieldset } from 'src/app-components/Label/Fieldset'; import { Caption } from 'src/components/form/caption/Caption'; import { HelpTextContainer } from 'src/components/form/HelpTextContainer'; import { LabelContent } from 'src/components/label/LabelContent'; +import { evalExpr } from 'src/features/expressions'; +import { ExprVal } from 'src/features/expressions/types'; +import { ExprValidation } from 'src/features/expressions/validation'; import { useLayoutLookups } from 'src/features/form/layout/LayoutsContext'; import { Lang } from 'src/features/language/Lang'; import { useLanguage } from 'src/features/language/useLanguage'; @@ -17,6 +20,7 @@ import { useIsMobile } from 'src/hooks/useDeviceWidths'; import { GenericComponent } from 'src/layout/GenericComponent'; import css from 'src/layout/Grid/Grid.module.css'; import { + getGridCellHiddenExpr, isGridCellLabelFrom, isGridCellNode, isGridCellText, @@ -25,11 +29,19 @@ import { } from 'src/layout/Grid/tools'; import { getColumnStyles } from 'src/utils/formComponentUtils'; import { useIndexedId } from 'src/utils/layout/DataModelLocation'; +import { useEvalExpression } from 'src/utils/layout/generator/useEvalExpression'; import { useIsHidden } from 'src/utils/layout/hidden'; +import { useExpressionDataSources } from 'src/utils/layout/useExpressionDataSources'; import { useLabel } from 'src/utils/layout/useLabel'; import { useItemFor, useItemWhenType } from 'src/utils/layout/useNodeItem'; import type { PropsFromGenericComponent } from 'src/layout'; -import type { GridCell, GridRow, ITableColumnFormatting, ITableColumnProperties } from 'src/layout/common.generated'; +import type { + GridCell, + GridRow, + IGridColumnProperties, + ITableColumnFormatting, + ITableColumnProperties, +} from 'src/layout/common.generated'; export function RenderGrid(props: PropsFromGenericComponent<'Grid'>) { const { baseComponentId } = props; @@ -44,6 +56,27 @@ export function RenderGrid(props: PropsFromGenericComponent<'Grid'>) { const accessibleTitle = elementAsString(title); const indexedId = useIndexedId(baseComponentId); + const columnHiddenExprs = useMemo(() => rows?.find((r) => r.header)?.cells?.map(getGridCellHiddenExpr) ?? [], [rows]); + const expressionDataSources = useExpressionDataSources(columnHiddenExprs); + const hiddenColumnIndices = useMemo( + () => + columnHiddenExprs.reduce((indices, hiddenExpr, cellIdx) => { + if (!ExprValidation.isValidOrScalar(hiddenExpr, ExprVal.Boolean)) { + return indices; + } + const hidden = evalExpr(hiddenExpr, expressionDataSources, { + returnType: ExprVal.Boolean, + defaultValue: false, + errorIntroText: `Invalid expression for hidden in Grid column ${cellIdx}`, + }); + if (hidden) { + indices.push(cellIdx); + } + return indices; + }, []), + [columnHiddenExprs, expressionDataSources], + ); + if (isMobile) { return ; } @@ -70,6 +103,7 @@ export function RenderGrid(props: PropsFromGenericComponent<'Grid'>) { rows={rows} isNested={isNested} mutableColumnSettings={columnSettings} + hiddenColumnIndices={hiddenColumnIndices} /> @@ -157,10 +191,21 @@ function GridRowRenderer({ row, isNested, mutableColumnSettings, hiddenColumnInd } if (isGridCellText(cell) || isGridCellLabelFrom(cell)) { - let textCellSettings: ITableColumnProperties = mutableColumnSettings[cellIdx] - ? structuredClone(mutableColumnSettings[cellIdx]) - : {}; - textCellSettings = { ...textCellSettings, ...cell }; + let textCellSettings: GridColumnOptions | undefined = + mutableColumnSettings[cellIdx] && structuredClone(mutableColumnSettings[cellIdx]); + + if (cell && 'gridColumnOptions' in cell && cell.gridColumnOptions) { + textCellSettings = textCellSettings + ? { ...textCellSettings, ...cell.gridColumnOptions } + : { ...cell.gridColumnOptions }; + } + + const cellWithColSpan = cell as { colSpan?: number } | null; + if (cellWithColSpan && cellWithColSpan.colSpan !== undefined) { + textCellSettings = textCellSettings + ? { ...textCellSettings, colSpan: cellWithColSpan.colSpan } + : { colSpan: cellWithColSpan.colSpan }; + } if (isGridCellText(cell)) { return ( @@ -198,6 +243,22 @@ function GridRowRenderer({ row, isNested, mutableColumnSettings, hiddenColumnInd ); } + let componentCellSettings: GridColumnOptions | undefined = + mutableColumnSettings[cellIdx] && structuredClone(mutableColumnSettings[cellIdx]); + + if (cell && 'gridColumnOptions' in cell && cell.gridColumnOptions) { + componentCellSettings = componentCellSettings + ? { ...componentCellSettings, ...cell.gridColumnOptions } + : { ...cell.gridColumnOptions }; + } + + const cellColSpan = (cell as { colSpan?: number } | null)?.colSpan; + if (cellColSpan !== undefined) { + componentCellSettings = componentCellSettings + ? { ...componentCellSettings, colSpan: cellColSpan } + : { colSpan: cellColSpan }; + } + return ( ); })} @@ -215,11 +276,13 @@ function GridRowRenderer({ row, isNested, mutableColumnSettings, hiddenColumnInd interface CellProps { className?: string; - columnStyleOptions?: ITableColumnProperties; + columnStyleOptions?: GridColumnOptions; isHeader?: boolean; rowReadOnly?: boolean; } +type GridColumnOptions = ITableColumnProperties & IGridColumnProperties; + interface CellWithComponentProps extends CellProps { baseComponentId: string; } @@ -241,6 +304,11 @@ function CellWithComponent({ }: CellWithComponentProps) { const isHidden = useIsHidden(baseComponentId); const CellComponent = isHeader ? Table.HeaderCell : Table.Cell; + const colSpanValue = useEvalExpression(columnStyleOptions?.colSpan, { + returnType: ExprVal.Number, + defaultValue: 1, + errorIntroText: `Invalid expression for colSpan in Grid cell with component "${baseComponentId}"`, + }); if (!isHidden) { const columnStyles = columnStyleOptions && getColumnStyles(columnStyleOptions); @@ -248,6 +316,7 @@ function CellWithComponent({ { + it('returns undefined for non-object or null cells', () => { + expect(getGridCellHiddenExpr(null as unknown as GridCell)).toBeUndefined(); + expect(getGridCellHiddenExpr(undefined as unknown as GridCell)).toBeUndefined(); + expect(getGridCellHiddenExpr('text' as unknown as GridCell)).toBeUndefined(); + }); + + it('reads hidden from columnOptions when present', () => { + const cell = { columnOptions: { hidden: true } } as GridCell; + expect(getGridCellHiddenExpr(cell)).toBe(true); + }); + + it('reads hidden from gridColumnOptions and prefers it over columnOptions', () => { + const cell = { + columnOptions: { hidden: false }, + gridColumnOptions: { hidden: true }, + } as GridCell; + expect(getGridCellHiddenExpr(cell)).toBe(true); + }); +}); diff --git a/src/layout/Grid/tools.ts b/src/layout/Grid/tools.ts index bb203aecea..fdf5c1b4e0 100644 --- a/src/layout/Grid/tools.ts +++ b/src/layout/Grid/tools.ts @@ -105,3 +105,14 @@ export function isGridCellEmpty(cell: GridCell): boolean { export function isGridCellNode(cell: GridCell): cell is GridComponentRef { return !!(cell && 'component' in cell && cell.component); } + +export function getGridCellHiddenExpr(cell: GridCell) { + if (!cell || typeof cell !== 'object') { + return undefined; + } + const options = + 'columnOptions' in cell ? (cell as { columnOptions?: { hidden?: unknown } }).columnOptions : undefined; + const gridOpts = + 'gridColumnOptions' in cell ? (cell as { gridColumnOptions?: { hidden?: unknown } }).gridColumnOptions : undefined; + return gridOpts?.hidden ?? options?.hidden; +}