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;
+}