diff --git a/adr/001-component-library.md b/adr/001-component-library.md new file mode 100644 index 0000000000..8d0d35048a --- /dev/null +++ b/adr/001-component-library.md @@ -0,0 +1,65 @@ +# Introduce component library + +- Status: Accepted +- Deciders: Team +- Date: 17.10.2024 + +## Result + +A1: Component library is introduced, firstly as just a folder in our current setup, adding yarn workspaces or similar requires more research and testing + +## Problem context + +Today our UI components are tightly coupled to the app in which they are rendered. + +This leads to several issues: + +- Makes it hard to do component testing outside a fully rendered app. + - See tests in `test/e2e/integration` for examples of how we have most of our tests in integration tests because components need to be running inside an app to work. + - See `src/test/renderWithProviders.tsx` for examples of how much scaffolding we need to do to run component tests currently. This also leads to very slow component tests. +- Makes refactoring the app framework a lot harder. + - If we search for `useNodeItem` in src/layout we get over 100 hits. If we make changes or remove hooks like this, every single UI component must be updated. +- Leads to unclear interfaces between UI components and the app framework. + - See: `src/layout/Button/ButtonComponent.tsx`. This component references `node.parent` which confuses the role and responsibility of the button component. +- Makes developing UI components complex without deep understanding of the application. +- Enables sharing of pure components (docs, Studio, storybook) + +## Decision drivers + +A list of decision drivers. These are points which can differ in importance. If a point is "nice to have" rather than +"need to have", then prefix the description. + +- B1: UI components should only receive data to display, and notify the app when data is changed. +- B2: UI components should live in a separate folder from the app itself, and have no dependencies to the app. +- B3: UI components should live in a lib separately to the src folder to have stricter control of dependencies. + +## Alternatives considered + +List the alternatives that were considered as a solution to the problem context. + +- A1: Simply put a new folder inside the src folder. +- A2: Use yarn workspaces to manage the library separately from the src folder. +- A3: Set up NX.js to manage our app and libraries. + +## Pros and cons + +List the pros and cons with the alternatives. This should be in regards to the decision drivers. + +### A1 + +- Good because B1 and B2 is covered +- Allows us to really quickly get started with a component library +- Bad, because it does not fulfill B3. If we simply use a new folder, it will be up to developers to enforce the rules of the UI components, like the avoiding dependencies to the app. + +### A2 + +- Good, because this alternative adheres to B1, B2 and B3. +- This way our libs would live separately to the app, and it would be obvious that it is a lib. +- The con is that it takes more setup. + +### A3 + +- Good, because this alternative adheres to B1, B2 and B3. +- This way our libs would live separately to the app, and it would be obvious that it is a lib. +- Also gives us powerful monorepo tooling. +- Bad because it takes a lot more time to set up, and might be overkill before we have decided to integrate frontend and backend into monorepo. diff --git a/src/app-components/readme.md b/src/app-components/readme.md new file mode 100644 index 0000000000..0d356a96ef --- /dev/null +++ b/src/app-components/readme.md @@ -0,0 +1,11 @@ +## IMPORTANT + +Components in this folder should be 'dumb', meaning the should have no knowledge about the containing app. + +They should implement three things: + +1. Receive and display data in the form of props. +2. Report data changes in a callback. +3. Receive and display the validity of the data. + +These components will form the basis of our future component library, and will enable refactoring of the frontend app. diff --git a/src/app-components/table/Table.module.css b/src/app-components/table/Table.module.css new file mode 100644 index 0000000000..8a3555816d --- /dev/null +++ b/src/app-components/table/Table.module.css @@ -0,0 +1,88 @@ +.table { + width: 100%; +} + +.buttonContainer { + display: flex; + justify-content: end; + gap: var(--fds-spacing-2); +} + +.mobileTable { + display: block; +} + +.mobileTable caption { + display: block; +} + +.mobileTable thead { + display: none; +} + +.mobileTable th { + display: block; + border: none; +} + +.mobileTable tbody, +.mobileTable tr { + display: block; +} + +.mobileTable td { + display: flex; + flex-direction: column; + border: none; + padding: var(--fds-spacing-2) 0; +} + +.mobileTable td .contentWrapper { + display: flex; + flex-direction: row; + align-items: center; + width: 100%; +} + +.mobileTable tbody tr:last-child { + border-bottom: 2px solid var(--fds-semantic-border-neutral-default); +} + +.mobileTable tbody tr:first-child { + border-top: 2px solid var(--fds-semantic-border-neutral-default); +} + +.mobileTable tr { + border-bottom: 1px solid var(--fds-semantic-border-neutral-default); + padding-top: var(--fds-spacing-3); + padding-bottom: var(--fds-spacing-3); +} + +.mobileTable tr:hover td { + background-color: unset; +} + +.mobileTable td[data-header-title]:not([data-header-title=''])::before, +.mobileTable th[data-header-title]:not([data-header-title=''])::before { + content: attr(data-header-title); + display: block; + text-align: left; + font-weight: 500; +} + +.mobileTable td .buttonContainer { + justify-content: start; +} + +.visuallyHidden { + border: none; + padding: 0; + margin: 0; + position: absolute; + height: 1px; + width: 1px; + overflow: hidden; + clip: rect(1px 1px 1px 1px); + clip-path: inset(50%); + white-space: nowrap; +} diff --git a/src/app-components/table/Table.test.tsx b/src/app-components/table/Table.test.tsx new file mode 100644 index 0000000000..038fe32da0 --- /dev/null +++ b/src/app-components/table/Table.test.tsx @@ -0,0 +1,180 @@ +// AppTable.test.tsx +import React from 'react'; + +import { fireEvent, render, screen } from '@testing-library/react'; + +import { AppTable } from 'src/app-components/table/Table'; + +// Sample data +const data = [ + { id: 1, name: 'Alice', date: '2023-10-05', amount: 100 }, + { id: 2, name: 'Bob', date: '2023-10-06', amount: 200 }, +]; + +// Columns configuration +const columns = [ + { header: 'Name', accessors: ['name'] }, + { header: 'Date', accessors: ['date'] }, + { header: 'Amount', accessors: ['amount'] }, +]; + +// Action buttons configuration +const actionButtons = [ + { + onClick: jest.fn(), + buttonText: 'Edit', + icon: null, + }, + { + onClick: jest.fn(), + buttonText: 'Delete', + icon: null, + }, +]; + +describe('AppTable Component', () => { + test('renders table with correct headers', () => { + render( + , + ); + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Date')).toBeInTheDocument(); + expect(screen.getByText('Amount')).toBeInTheDocument(); + }); + + test('renders correct number of rows', () => { + render( + , + ); + const rows = screen.getAllByRole('row'); + expect(rows.length).toBe(data.length + 1); // +1 for header row + }); + + test('renders action buttons when provided', () => { + render( + , + ); + expect(screen.getAllByText('Edit').length).toBe(data.length); + expect(screen.getAllByText('Delete').length).toBe(data.length); + }); + + test('correctly formats dates in cells', () => { + render( + , + ); + expect(screen.getByText('05.10.2023')).toBeInTheDocument(); + expect(screen.getByText('06.10.2023')).toBeInTheDocument(); + }); + + test('uses renderCell function when provided', () => { + const columnsWithRenderCell = [ + ...columns, + { + header: 'Custom', + accessors: ['name', 'amount'], + renderCell: (values) => `Name: ${values[0]}, Amount: ${values[1]}`, + }, + ]; + render( + , + ); + expect(screen.getByText('Name: Alice, Amount: 100')).toBeInTheDocument(); + expect(screen.getByText('Name: Bob, Amount: 200')).toBeInTheDocument(); + }); + + test('calls action button onClick when clicked', () => { + const onClickMock = jest.fn(); + const actionButtonsMock = [ + { + onClick: onClickMock, + buttonText: 'Edit', + icon: null, + }, + ]; + + render( + , + ); + + const editButtons = screen.getAllByText('Edit'); + fireEvent.click(editButtons[0]); + expect(onClickMock).toHaveBeenCalledWith(0, data[0]); + + fireEvent.click(editButtons[1]); + expect(onClickMock).toHaveBeenCalledWith(1, data[1]); + + expect(onClickMock).toHaveBeenCalledTimes(2); + }); + + test('renders "-" when cell values are null or undefined', () => { + const dataWithNull = [ + { id: 1, name: 'Alice', date: null, amount: 100 }, + { id: 2, name: 'Bob', date: '2023-10-06', amount: 200 }, + ]; + render( + , + ); + expect(screen.getAllByText('-').length).toBe(1); + }); + + test('does not render action buttons column when actionButtons is not provided', () => { + render( + , + ); + const headerCells = screen.getAllByRole('columnheader'); + expect(headerCells.length).toBe(columns.length); + }); + + test('renders extra header cell when actionButtons are provided', () => { + render( + , + ); + const headerCells = screen.getAllByRole('columnheader'); + expect(headerCells.length).toBe(columns.length + 1); + }); + + test('non-date values are not changed by formatIfDate', () => { + const dataWithNonDate = [ + { id: 1, name: 'Alice', date: 'Not a date', amount: 100 }, + { id: 2, name: 'Bob', date: 'Also not a date', amount: 200 }, + ]; + render( + , + ); + expect(screen.getByText('Not a date')).toBeInTheDocument(); + expect(screen.getByText('Also not a date')).toBeInTheDocument(); + }); +}); diff --git a/src/app-components/table/Table.tsx b/src/app-components/table/Table.tsx new file mode 100644 index 0000000000..e90513fa7f --- /dev/null +++ b/src/app-components/table/Table.tsx @@ -0,0 +1,161 @@ +import React from 'react'; + +import { Button, Table } from '@digdir/designsystemet-react'; +import cn from 'classnames'; +import { format, isValid, parseISO } from 'date-fns'; +import { pick } from 'dot-object'; + +import classes from 'src/app-components/table/Table.module.css'; + +interface Column { + /** Header text for the column */ + header: React.ReactNode; + /** Keys of the data item to display in this column */ + accessors: string[]; + /** Optional function to render custom cell content */ + renderCell?: (values: string[], rowData: object) => React.ReactNode; +} + +export interface TableActionButton { + onClick: (rowIdx: number, rowData: object) => void; + buttonText: React.ReactNode; + icon: React.ReactNode; + color?: 'first' | 'second' | 'success' | 'danger' | undefined; + variant?: 'tertiary' | 'primary' | 'secondary' | undefined; +} + +interface DataTableProps { + /** Array of data objects to display */ + data: T[]; + /** Configuration for table columns */ + columns: Column[]; + caption?: React.ReactNode; + /** Optional configuration for action buttons */ + actionButtons?: TableActionButton[]; + /** Accessible header value for action buttons for screenreaders */ + actionButtonHeader?: React.ReactNode; + /** Displays table in mobile mode */ + mobile?: boolean; + size?: 'sm' | 'md' | 'lg'; + zebra?: boolean; +} + +function formatIfDate(value: unknown): string { + if (typeof value === 'string') { + const parsedDate = parseISO(value); + if (isValid(parsedDate)) { + return format(parsedDate, 'dd.MM.yyyy'); + } + } + return String(value); +} + +export function AppTable({ + caption, + data, + columns, + actionButtons, + mobile, + actionButtonHeader, + size, + zebra, +}: DataTableProps) { + const defaultButtonVariant = mobile ? 'secondary' : 'tertiary'; + return ( + + {caption} + + + {columns.map((col, index) => ( + {col.header} + ))} + + {actionButtons && actionButtons.length > 0 && ( + + {actionButtonHeader} + + )} + + + + {data.map((rowData, rowIndex) => ( + + {columns.map((col, colIndex) => { + const cellValues = col.accessors + .filter((accessor) => !!pick(accessor, rowData)) + .map((accessor) => pick(accessor, rowData)); + if (cellValues.every((value) => value == null)) { + return ( + + - + + ); + } + + if (col.renderCell) { + return ( + + {col.renderCell(cellValues, rowData)} + + ); + } + + if (cellValues.length === 1) { + return ( + + {cellValues.map(formatIfDate)} + + ); + } + + return ( + +
    + {cellValues.map(formatIfDate).map((value, idx) => ( +
  • {value}
  • + ))} +
+
+ ); + })} + + {actionButtons && actionButtons.length > 0 && ( + +
+ {actionButtons?.map((button, idx) => ( + + ))} +
+
+ )} +
+ ))} +
+
+ ); +} diff --git a/src/components/form/Caption.tsx b/src/components/form/Caption.tsx deleted file mode 100644 index cac9059d3f..0000000000 --- a/src/components/form/Caption.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from 'react'; -import type { HtmlHTMLAttributes } from 'react'; - -import { Label as DesignsystemetLabel } from '@digdir/designsystemet-react'; -import cn from 'classnames'; -import type { LabelProps as DesignsystemetLabelProps } from '@digdir/designsystemet-react'; - -import classes from 'src/components/form/Caption.module.css'; -import { Description } from 'src/components/form/Description'; -import { HelpTextContainer } from 'src/components/form/HelpTextContainer'; -import { OptionalIndicator } from 'src/components/form/OptionalIndicator'; -import { RequiredIndicator } from 'src/components/form/RequiredIndicator'; -import { useLanguage } from 'src/features/language/useLanguage'; -import type { ILabelSettings } from 'src/layout/common.generated'; - -export type CaptionProps = { - title: React.ReactNode; - description?: React.ReactNode; - helpText?: React.ReactNode; - required?: boolean; - labelSettings?: ILabelSettings; - designSystemLabelProps?: DesignsystemetLabelProps; -} & Omit, 'children' | 'title'>; - -export const Caption = ({ - title, - description, - required, - labelSettings, - id, - className, - helpText, - designSystemLabelProps, - ...rest -}: CaptionProps) => { - const { elementAsString } = useLanguage(); - const titleAsText = elementAsString(title); - - return ( - - -
- {title} - - - {helpText && ( - - )} -
-
- {description && ( - - )} - - ); -}; diff --git a/src/components/form/Caption.module.css b/src/components/form/caption/Caption.module.css similarity index 100% rename from src/components/form/Caption.module.css rename to src/components/form/caption/Caption.module.css diff --git a/src/components/form/Caption.test.tsx b/src/components/form/caption/Caption.test.tsx similarity index 89% rename from src/components/form/Caption.test.tsx rename to src/components/form/caption/Caption.test.tsx index b973f3bbf8..4d62c55ca3 100644 --- a/src/components/form/Caption.test.tsx +++ b/src/components/form/caption/Caption.test.tsx @@ -2,9 +2,9 @@ import React from 'react'; import { screen } from '@testing-library/react'; -import { Caption } from 'src/components/form/Caption'; +import { Caption } from 'src/components/form/caption/Caption'; import { renderWithoutInstanceAndLayout } from 'src/test/renderWithProviders'; -import type { CaptionProps } from 'src/components/form/Caption'; +import type { CaptionProps } from 'src/components/form/caption/Caption'; describe('Caption', () => { const render = async (props?: Partial) => diff --git a/src/components/form/caption/Caption.tsx b/src/components/form/caption/Caption.tsx new file mode 100644 index 0000000000..f39595d008 --- /dev/null +++ b/src/components/form/caption/Caption.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import type { HtmlHTMLAttributes } from 'react'; + +import { Label as DesignsystemetLabel } from '@digdir/designsystemet-react'; +import cn from 'classnames'; +import type { LabelProps as DesignsystemetLabelProps } from '@digdir/designsystemet-react'; + +import classes from 'src/components/form/caption/Caption.module.css'; +import { Description } from 'src/components/form/Description'; +import { HelpTextContainer } from 'src/components/form/HelpTextContainer'; +import { OptionalIndicator } from 'src/components/form/OptionalIndicator'; +import { RequiredIndicator } from 'src/components/form/RequiredIndicator'; +import type { ILabelSettings } from 'src/layout/common.generated'; + +type HelpTextProps = { + text?: React.ReactNode; + accessibleTitle?: string; +}; + +export type CaptionProps = { + title: React.ReactNode; + description?: React.ReactNode; + helpText?: HelpTextProps; + required?: boolean; + labelSettings?: ILabelSettings; + designSystemLabelProps?: DesignsystemetLabelProps; +} & Omit, 'children' | 'title'>; + +export const Caption = ({ + title, + description, + required, + labelSettings, + id, + className, + helpText, + designSystemLabelProps, + ...rest +}: CaptionProps) => ( + + +
+ {title} + + +
+
+ {helpText && ( + + )} + {description && ( + + )} + +); diff --git a/src/features/expressions/shared-tests/functions/displayValue/table.json b/src/features/expressions/shared-tests/functions/displayValue/table.json new file mode 100644 index 0000000000..71b200e107 --- /dev/null +++ b/src/features/expressions/shared-tests/functions/displayValue/table.json @@ -0,0 +1,26 @@ +{ + "name": "Display value of RepeatingGroup component", + "expression": [ + "displayValue", + "table" + ], + "context": { + "component": "table", + "currentLayout": "Page" + }, + "expects": "", + "layouts": { + "Page": { + "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "table", + "type": "Table", + "children": [] + } + ] + } + } + } +} diff --git a/src/features/formData/FormDataWrite.tsx b/src/features/formData/FormDataWrite.tsx index 720ce1fe61..42a27e0a1a 100644 --- a/src/features/formData/FormDataWrite.tsx +++ b/src/features/formData/FormDataWrite.tsx @@ -922,6 +922,8 @@ export const FD = { */ useLastSaveValidationIssues: () => useSelector((s) => s.validationIssues), + useRemoveIndexFromList: () => useSelector((s) => s.removeIndexFromList), + useGetDataTypeForElementId: () => { const map: Record = useMemoSelector((s) => Object.fromEntries( diff --git a/src/features/formData/FormDataWriteStateMachine.tsx b/src/features/formData/FormDataWriteStateMachine.tsx index da58848071..e1feb5c69e 100644 --- a/src/features/formData/FormDataWriteStateMachine.tsx +++ b/src/features/formData/FormDataWriteStateMachine.tsx @@ -406,6 +406,7 @@ function makeActions( existingValue.splice(index, 1); }), + removeValueFromList: ({ reference, value }) => set((state) => { if (state.dataModels[reference.dataType].readonly) { diff --git a/src/layout/Grid/GridComponent.tsx b/src/layout/Grid/GridComponent.tsx index 37b09e46fa..b1b4327e67 100644 --- a/src/layout/Grid/GridComponent.tsx +++ b/src/layout/Grid/GridComponent.tsx @@ -5,7 +5,7 @@ import { Table } from '@digdir/designsystemet-react'; import cn from 'classnames'; import { ConditionalWrapper } from 'src/components/ConditionalWrapper'; -import { Caption } from 'src/components/form/Caption'; +import { Caption } from 'src/components/form/caption/Caption'; import { Fieldset } from 'src/components/form/Fieldset'; import { FullWidthWrapper } from 'src/components/form/FullWidthWrapper'; import { HelpTextContainer } from 'src/components/form/HelpTextContainer'; @@ -40,6 +40,8 @@ export function RenderGrid(props: PropsFromGenericComponent<'Grid'>) { const columnSettings: ITableColumnFormatting = {}; const isMobile = useIsMobile(); const isNested = node.parent instanceof BaseLayoutNode; + const { elementAsString } = useLanguage(); + const accessibleTitle = elementAsString(title); if (isMobile) { return ; @@ -59,7 +61,7 @@ export function RenderGrid(props: PropsFromGenericComponent<'Grid'>) { className={cn({ [css.captionFullWidth]: shouldHaveFullWidth })} title={} description={description && } - helpText={help && } + helpText={help ? { text: , accessibleTitle } : undefined} labelSettings={labelSettings} /> )} diff --git a/src/layout/Payment/PaymentReceiptDetails/PaymentReceiptDetails.tsx b/src/layout/Payment/PaymentReceiptDetails/PaymentReceiptDetails.tsx index 75016754e3..d09b34476f 100644 --- a/src/layout/Payment/PaymentReceiptDetails/PaymentReceiptDetails.tsx +++ b/src/layout/Payment/PaymentReceiptDetails/PaymentReceiptDetails.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Label, Paragraph } from '@digdir/designsystemet-react'; -import { Caption } from 'src/components/form/Caption'; +import { Caption } from 'src/components/form/caption/Caption'; import { useLaxInstanceId } from 'src/features/instance/InstanceContext'; import { Lang } from 'src/features/language/Lang'; import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; diff --git a/src/layout/PaymentDetails/PaymentDetailsTable.tsx b/src/layout/PaymentDetails/PaymentDetailsTable.tsx index cf8d46c769..67acc56840 100644 --- a/src/layout/PaymentDetails/PaymentDetailsTable.tsx +++ b/src/layout/PaymentDetails/PaymentDetailsTable.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { Label, Table } from '@digdir/designsystemet-react'; import cn from 'classnames'; -import { Caption } from 'src/components/form/Caption'; +import { Caption } from 'src/components/form/caption/Caption'; import { Lang } from 'src/features/language/Lang'; import classes from 'src/layout/PaymentDetails/PaymentDetailsTable.module.css'; import type { OrderDetails } from 'src/features/payment/types'; diff --git a/src/layout/RepeatingGroup/Providers/RepeatingGroupContext.tsx b/src/layout/RepeatingGroup/Providers/RepeatingGroupContext.tsx index 3f41fc1ac2..240616de95 100644 --- a/src/layout/RepeatingGroup/Providers/RepeatingGroupContext.tsx +++ b/src/layout/RepeatingGroup/Providers/RepeatingGroupContext.tsx @@ -436,6 +436,7 @@ function useExtendedRepeatingGroupState(node: LayoutNode<'RepeatingGroup'>): Ext return { result: 'stoppedByValidation', uuid: undefined, index: undefined }; } const uuid = uuidv4(); + appendToList({ reference: groupBinding, newValue: { [ALTINN_ROW_ID]: uuid }, diff --git a/src/layout/RepeatingGroup/Table/RepeatingGroupTable.tsx b/src/layout/RepeatingGroup/Table/RepeatingGroupTable.tsx index 2b2c664da5..295d874905 100644 --- a/src/layout/RepeatingGroup/Table/RepeatingGroupTable.tsx +++ b/src/layout/RepeatingGroup/Table/RepeatingGroupTable.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { Table } from '@digdir/designsystemet-react'; import cn from 'classnames'; -import { Caption } from 'src/components/form/Caption'; +import { Caption } from 'src/components/form/caption/Caption'; import { Lang } from 'src/features/language/Lang'; import { useIsMobileOrTablet } from 'src/hooks/useDeviceWidths'; import { GenericComponent } from 'src/layout/GenericComponent'; diff --git a/src/layout/Subform/SubformComponent.tsx b/src/layout/Subform/SubformComponent.tsx index d70197830e..d629f94564 100644 --- a/src/layout/Subform/SubformComponent.tsx +++ b/src/layout/Subform/SubformComponent.tsx @@ -7,7 +7,7 @@ import { Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon } from '@navikt/ import cn from 'classnames'; import dot from 'dot-object'; -import { Caption } from 'src/components/form/Caption'; +import { Caption } from 'src/components/form/caption/Caption'; import { useDataTypeFromLayoutSet } from 'src/features/form/layout/LayoutsContext'; import { useFormDataQuery } from 'src/features/formData/useFormDataQuery'; import { useStrictDataElements, useStrictInstanceId } from 'src/features/instance/InstanceContext'; diff --git a/src/layout/Subform/Summary/SubformSummaryTable.tsx b/src/layout/Subform/Summary/SubformSummaryTable.tsx index 72b5d7ce0f..cc7c67a886 100644 --- a/src/layout/Subform/Summary/SubformSummaryTable.tsx +++ b/src/layout/Subform/Summary/SubformSummaryTable.tsx @@ -5,7 +5,7 @@ import { Paragraph, Spinner, Table } from '@digdir/designsystemet-react'; import { Grid } from '@material-ui/core'; import classNames from 'classnames'; -import { Caption } from 'src/components/form/Caption'; +import { Caption } from 'src/components/form/caption/Caption'; import { Label } from 'src/components/label/Label'; import { useDataTypeFromLayoutSet } from 'src/features/form/layout/LayoutsContext'; import { useFormDataQuery } from 'src/features/formData/useFormDataQuery'; diff --git a/src/layout/Summary2/SummaryComponent2/SummaryComponent2.tsx b/src/layout/Summary2/SummaryComponent2/SummaryComponent2.tsx index 324495b124..04dc19e1e8 100644 --- a/src/layout/Summary2/SummaryComponent2/SummaryComponent2.tsx +++ b/src/layout/Summary2/SummaryComponent2/SummaryComponent2.tsx @@ -37,6 +37,7 @@ function SummaryBody({ target }: SummaryBodyProps) { export function SummaryComponent2({ summaryNode }: ISummaryComponent2) { const item = useNodeItem(summaryNode); + return ( ; + +type TableSummaryProps = { + componentNode: LayoutNode<'Table'>; +}; + +export function TableSummary({ componentNode }: TableSummaryProps) { + const { dataModelBindings, textResourceBindings, columns } = useNodeItem(componentNode, (item) => ({ + dataModelBindings: item.dataModelBindings, + textResourceBindings: item.textResourceBindings, + columns: item.columns, + })); + + const { formData } = useDataModelBindings(dataModelBindings, 1, 'raw'); + const { title } = textResourceBindings ?? {}; + const { langAsString } = useLanguage(); + const isMobile = useIsMobile(); + + const data = formData.tableData; + + if (!Array.isArray(data)) { + return null; + } + + if (data.length < 1) { + return null; + } + return ( + + caption={title && } />} + data={data} + columns={columns.map((config) => ({ + ...config, + header: , + }))} + mobile={isMobile} + /> + ); +} + +export function TableComponent({ node }: TableComponentProps) { + const item = useNodeItem(node); + const { formData } = useDataModelBindings(item.dataModelBindings, 1, 'raw'); + const removeFromList = FD.useRemoveFromListCallback(); + const { title, description, help } = item.textResourceBindings ?? {}; + const { langAsString } = useLanguage(); + const { elementAsString } = useLanguage(); + const accessibleTitle = elementAsString(title); + const isMobile = useIsMobile(); + + const data = formData.tableData; + + if (!Array.isArray(data)) { + return null; + } + + if (data.length < 1) { + return null; + } + + const actionButtons: TableActionButton[] = []; + + if (item.enableDelete) { + actionButtons.push({ + onClick: (idx) => { + removeFromList({ + startAtIndex: idx, + reference: { + dataType: item.dataModelBindings.tableData.dataType, + field: item.dataModelBindings.tableData.field, + }, + callback: (_) => true, + }); + }, + buttonText: , + icon: , + color: 'danger', + }); + } + + return ( + + zebra={item.zebra} + size={item.size} + caption={ + title && ( + } + description={description && } + helpText={help ? { text: , accessibleTitle } : undefined} + /> + ) + } + data={data} + columns={item.columns.map((config) => ({ + ...config, + header: , + }))} + mobile={isMobile} + actionButtons={actionButtons} + actionButtonHeader={} + /> + ); +} diff --git a/src/layout/Table/config.ts b/src/layout/Table/config.ts new file mode 100644 index 0000000000..7ae34b2681 --- /dev/null +++ b/src/layout/Table/config.ts @@ -0,0 +1,60 @@ +import { CG } from 'src/codegen/CG'; +import { CompCategory } from 'src/layout/common'; + +export const Config = new CG.component({ + category: CompCategory.Form, + capabilities: { + renderInTable: false, + renderInButtonGroup: false, + renderInAccordion: false, + renderInAccordionGroup: false, + renderInCards: false, + renderInCardsMedia: false, + renderInTabs: true, + }, + functionality: { + customExpressions: false, + }, +}) + .extends(CG.common('LabeledComponentProps')) + .extendTextResources(CG.common('TRBLabel')) + .addProperty(new CG.prop('title', new CG.str())) + .addDataModelBinding( + new CG.obj( + new CG.prop( + 'tableData', + new CG.dataModelBinding().setTitle('TableData').setDescription('Array of objects where the data is stored'), + ), + ).exportAs('IDataModelBindingsForTable'), + ) + .addProperty( + new CG.prop( + 'columns', + new CG.arr( + new CG.obj( + new CG.prop('header', new CG.str()), + new CG.prop( + 'accessors', + new CG.arr(new CG.str()) + .setTitle('Accessors') + .setDescription('List of fields that should be included in the cell'), + ), + ).exportAs('Columns'), + ), + ), + ) + .addProperty( + new CG.prop( + 'zebra', + new CG.bool().setTitle('Size').setDescription('If true, the table will have zebra striping').optional(), + ), + ) + .addProperty( + new CG.prop( + 'enableDelete', + new CG.bool().setTitle('Enable delete').setDescription('If true, will allow user to delete row').optional(), + ), + ) + .addProperty( + new CG.prop('size', new CG.enum('sm', 'md', 'lg').setTitle('Size').setDescription('Size of table.').optional()), + ); diff --git a/src/layout/Table/index.tsx b/src/layout/Table/index.tsx new file mode 100644 index 0000000000..f266862b4b --- /dev/null +++ b/src/layout/Table/index.tsx @@ -0,0 +1,44 @@ +import React, { forwardRef } from 'react'; + +import { TableDef } from 'src/layout/Table/config.def.generated'; +import { TableComponent, TableSummary } from 'src/layout/Table/TableComponent'; +import type { LayoutValidationCtx } from 'src/features/devtools/layoutValidation/types'; +import type { PropsFromGenericComponent } from 'src/layout'; +import type { SummaryRendererProps } from 'src/layout/LayoutComponent'; +import type { Summary2Props } from 'src/layout/Summary2/SummaryComponent2/types'; + +export class Table extends TableDef { + validateDataModelBindings(ctx: LayoutValidationCtx<'Table'>): string[] { + const [errors, result] = this.validateDataModelBindingsAny(ctx, 'tableData', ['array']); + if (errors) { + return errors; + } + + if (Array.isArray(result.items) && result?.items.length > 0) { + const innerType = result?.items[0]; + if (typeof innerType !== 'object' || !innerType.type || innerType.type !== 'object') { + return [ + `group-datamodellbindingen må peke på en liste av objekter. Bruk andre komponenter for å vise lister av strings eller tall.`, + ]; + } + } + + return []; + } + + getDisplayData(): string { + return ''; + } + renderSummary2(props: Summary2Props<'Table'>): React.JSX.Element | null { + return ; + } + render = forwardRef>( + function LayoutComponentTableRender(props, _): React.JSX.Element | null { + return ; + }, + ); + + renderSummary(_: SummaryRendererProps<'Table'>): React.JSX.Element | null { + return null; + } +}