diff --git a/src/layout/RepeatingGroup/Summary2/RepGroupSummaryEditableContext.tsx b/src/layout/RepeatingGroup/Summary2/RepGroupSummaryEditableContext.tsx new file mode 100644 index 0000000000..5d5040ae09 --- /dev/null +++ b/src/layout/RepeatingGroup/Summary2/RepGroupSummaryEditableContext.tsx @@ -0,0 +1,33 @@ +import React, { useMemo } from 'react'; +import type { PropsWithChildren } from 'react'; + +import { createContext } from 'src/core/contexts/context'; + +interface RepGroupEditContextValue { + editableChildIds: Set; +} + +const { Provider, useCtx } = createContext({ + name: 'RepGroupSummaryEditable', + required: false, + default: undefined, +}); + +export function RepGroupSummaryEditableProvider({ + editableChildIds, + children, +}: PropsWithChildren<{ editableChildIds: string[] }>) { + const value = useMemo(() => ({ editableChildIds: new Set(editableChildIds) }), [editableChildIds]); + return {children}; +} + +/** + * Hook to check if a summary component is editable within a repeating group row context + */ +export function useIsEditableInRepGroup(baseId: string): boolean { + const ctx = useCtx(); + if (!ctx) { + return true; + } + return ctx.editableChildIds.has(baseId); +} diff --git a/src/layout/RepeatingGroup/Summary2/RepeatingGroupSummary.tsx b/src/layout/RepeatingGroup/Summary2/RepeatingGroupSummary.tsx index c95e17da4e..224454ab1f 100644 --- a/src/layout/RepeatingGroup/Summary2/RepeatingGroupSummary.tsx +++ b/src/layout/RepeatingGroup/Summary2/RepeatingGroupSummary.tsx @@ -10,6 +10,7 @@ import { useUnifiedValidationsForNode } from 'src/features/validation/selectors/ import { validationsOfSeverity } from 'src/features/validation/utils'; import classes from 'src/layout/RepeatingGroup/Summary2/RepeatingGroupSummary.module.css'; import { RepeatingGroupTableSummary } from 'src/layout/RepeatingGroup/Summary2/RepeatingGroupTableSummary/RepeatingGroupTableSummary'; +import { RepGroupSummaryEditableProvider } from 'src/layout/RepeatingGroup/Summary2/RepGroupSummaryEditableContext'; import { RepGroupHooks } from 'src/layout/RepeatingGroup/utils'; import { SingleValueSummary } from 'src/layout/Summary2/CommonSummaryComponents/SingleValueSummary'; import { @@ -22,6 +23,8 @@ import { import { useSummaryOverrides, useSummaryProp } from 'src/layout/Summary2/summaryStoreContext'; import { DataModelLocationProvider } from 'src/utils/layout/DataModelLocation'; import { useItemWhenType } from 'src/utils/layout/useNodeItem'; +import type { IDataModelReference } from 'src/layout/common.generated'; +import type { RepGroupRow } from 'src/layout/RepeatingGroup/utils'; import type { Summary2Props } from 'src/layout/Summary2/SummaryComponent2/types'; export const RepeatingGroupSummary = ({ targetBaseComponentId }: Summary2Props) => { @@ -96,32 +99,20 @@ export const RepeatingGroupSummary = ({ targetBaseComponentId }: Summary2Props)
- {rows.map((row) => { + {rows.map((row, index) => { if (!row) { return null; } return ( - - {row.index != 0 &&
} - - {visibleChildIds.map((baseId) => ( - - ))} - -
+ ); })}
@@ -140,3 +131,45 @@ export const RepeatingGroupSummary = ({ targetBaseComponentId }: Summary2Props) ); }; + +interface RepGroupListRowProps { + row: RepGroupRow; + targetBaseComponentId: string; + visibleChildIds: string[]; + dataModelBindings: { group: IDataModelReference }; + showDivider: boolean; +} + +function RepGroupListRow({ + row, + targetBaseComponentId, + visibleChildIds, + dataModelBindings, + showDivider, +}: RepGroupListRowProps) { + const rowWithExpressions = RepGroupHooks.useRowWithExpressions(targetBaseComponentId, { uuid: row.uuid }); + const editableChildIds = RepGroupHooks.useEditableChildren(targetBaseComponentId, rowWithExpressions); + + return ( + + + {showDivider &&
} + + {visibleChildIds.map((baseId) => ( + + ))} + +
+
+ ); +} diff --git a/src/layout/RepeatingGroup/Summary2/RepeatingGroupTableSummary/RepeatingGroupTableSummary.test.tsx b/src/layout/RepeatingGroup/Summary2/RepeatingGroupTableSummary/RepeatingGroupTableSummary.test.tsx index 3090d287ec..d23ce5679e 100644 --- a/src/layout/RepeatingGroup/Summary2/RepeatingGroupTableSummary/RepeatingGroupTableSummary.test.tsx +++ b/src/layout/RepeatingGroup/Summary2/RepeatingGroupTableSummary/RepeatingGroupTableSummary.test.tsx @@ -18,7 +18,7 @@ describe('RepeatingGroupTableSummary', () => { jest.restoreAllMocks(); }); - const layoutWithHidden = (hidden: NodeId[]): ILayoutCollection => ({ + const layoutWithHidden = (hidden: NodeId[], editButton?: boolean): ILayoutCollection => ({ FormPage1: { data: { layout: [ @@ -32,6 +32,11 @@ describe('RepeatingGroupTableSummary', () => { children: ['input1', 'input2', 'input3'], maxCount: 3, hidden: hidden.includes('repeating-group'), + ...(editButton !== undefined && { + edit: { + editButton, + }, + }), }, { id: 'input1', @@ -140,6 +145,16 @@ describe('RepeatingGroupTableSummary', () => { ); }); + test('should not render edit button when edit.editButton is false', async () => { + await render({ layout: layoutWithHidden([], false) }); + expect(screen.queryByRole('button', { name: /endre/i })).not.toBeInTheDocument(); + }); + + test('should render edit button when edit.editButton is true', async () => { + await render({ layout: layoutWithHidden([], true) }); + expect(screen.getByRole('button', { name: /endre/i })).toBeInTheDocument(); + }); + type IRenderProps = { navigate?: jest.Mock; layout?: ILayoutCollection; diff --git a/src/layout/RepeatingGroup/Summary2/RepeatingGroupTableSummary/RepeatingGroupTableSummary.tsx b/src/layout/RepeatingGroup/Summary2/RepeatingGroupTableSummary/RepeatingGroupTableSummary.tsx index 4f18040108..2c669c8d95 100644 --- a/src/layout/RepeatingGroup/Summary2/RepeatingGroupTableSummary/RepeatingGroupTableSummary.tsx +++ b/src/layout/RepeatingGroup/Summary2/RepeatingGroupTableSummary/RepeatingGroupTableSummary.tsx @@ -19,7 +19,7 @@ import tableClasses from 'src/layout/RepeatingGroup/Summary2/RepeatingGroupTable import { RepeatingGroupTableTitle, useTableTitle } from 'src/layout/RepeatingGroup/Table/RepeatingGroupTableTitle'; import { useTableComponentIds } from 'src/layout/RepeatingGroup/useTableComponentIds'; import { RepGroupHooks } from 'src/layout/RepeatingGroup/utils'; -import { EditButtonFirstVisible } from 'src/layout/Summary2/CommonSummaryComponents/EditButton'; +import { EditButtonFirstVisibleAndEditable } from 'src/layout/Summary2/CommonSummaryComponents/EditButton'; import { useReportSummaryRender } from 'src/layout/Summary2/isEmpty/EmptyChildrenContext'; import { ComponentSummary, SummaryContains } from 'src/layout/Summary2/SummaryComponent2/ComponentSummary'; import utilClasses from 'src/styles/utils.module.css'; @@ -138,9 +138,12 @@ type DataRowProps = { function DataRow({ row, baseComponentId, pdfModeActive, columnSettings }: DataRowProps) { const layoutLookups = useLayoutLookups(); - const ids = useTableComponentIds(baseComponentId); const children = RepGroupHooks.useChildIds(baseComponentId); + const ids = useTableComponentIds(baseComponentId); const visibleIds = ids.filter((id) => columnSettings[id]?.hidden !== true); + const rowWithExpressions = RepGroupHooks.useRowWithExpressions(baseComponentId, { uuid: row?.uuid ?? '' }); + const editableChildren = RepGroupHooks.useEditableChildren(baseComponentId, rowWithExpressions); + const editableIds = [...ids, ...children].filter((id) => editableChildren.includes(id)); if (!row) { return null; @@ -166,9 +169,10 @@ function DataRow({ row, baseComponentId, pdfModeActive, columnSettings }: DataRo align='right' className={tableClasses.buttonCell} > - )} diff --git a/src/layout/RepeatingGroup/utils.ts b/src/layout/RepeatingGroup/utils.ts index 1fa5475458..0bc83b84b7 100644 --- a/src/layout/RepeatingGroup/utils.ts +++ b/src/layout/RepeatingGroup/utils.ts @@ -3,14 +3,19 @@ import { useCallback, useMemo } from 'react'; 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 { FD } from 'src/features/formData/FormDataWrite'; import { useMemoDeepEqual } from 'src/hooks/useStateDeepEqual'; +import { getComponentDef } from 'src/layout'; +import { CompCategory } from 'src/layout/common'; import { useComponentIdMutator } from 'src/utils/layout/DataModelLocation'; import { useIsHiddenMulti } from 'src/utils/layout/hidden'; import { useDataModelBindingsFor, useExternalItem } from 'src/utils/layout/hooks'; import { useExpressionDataSources } from 'src/utils/layout/useExpressionDataSources'; import type { ExprValToActual, ExprValToActualOrExpr } from 'src/features/expressions/types'; +import type { LayoutLookups } from 'src/features/form/layout/makeLayoutLookups'; import type { IDataModelReference } from 'src/layout/common.generated'; +import type { CompExternal } from 'src/layout/layout'; import type { GroupExpressions } from 'src/layout/RepeatingGroup/types'; import type { BaseRow } from 'src/utils/layout/types'; import type { ExpressionDataSources } from 'src/utils/layout/useExpressionDataSources'; @@ -60,6 +65,35 @@ function evalBool({ expr, defaultValue = false, dataSources, groupBinding, rowIn return evalExpr(expr, { ...dataSources, currentDataModelPath }, { returnType: ExprVal.Boolean, defaultValue }); } +/** + * Helper function to check if a child component is editable in a repeating group + */ +function isChildEditableCheck( + childBaseComponentId: string, + layoutLookups: LayoutLookups, + parentComponent: CompExternal<'RepeatingGroup'>, + rowWithExpressions: RepGroupRowWithExpressions | undefined, +): boolean { + const childComponent = layoutLookups.getComponent(childBaseComponentId); + + const def = getComponentDef(childComponent.type); + if (def.category !== CompCategory.Form) { + return false; // Must be a form component + } + + const columnSettings = parentComponent.tableColumns?.[childBaseComponentId]; + const hiddenInTable = columnSettings?.hidden === true; + const editInTable = columnSettings?.editInTable ?? false; + const showInExpandedEdit = columnSettings?.showInExpandedEdit ?? true; + const editButtonVisible = rowWithExpressions?.edit?.editButton !== false; + + if (editButtonVisible) { + return showInExpandedEdit; + } + + return editInTable && !hiddenInTable; +} + export const RepGroupHooks = { useAllBaseRows(baseComponentId: string) { const groupBinding = useDataModelBindingsFor(baseComponentId, 'RepeatingGroup')?.group; @@ -228,4 +262,14 @@ export const RepGroupHooks = { hidden: hidden[baseId] ?? false, })); }, + + useEditableChildren(baseComponentId: string, rowWithExpressions: RepGroupRowWithExpressions | undefined): string[] { + const childrenBaseIds = RepGroupHooks.useChildIds(baseComponentId); + const layoutLookups = useLayoutLookups(); + const component = layoutLookups.getComponent(baseComponentId, 'RepeatingGroup'); + + return childrenBaseIds.filter((childId) => + isChildEditableCheck(childId, layoutLookups, component, rowWithExpressions), + ); + }, }; diff --git a/src/layout/Summary2/CommonSummaryComponents/EditButton.tsx b/src/layout/Summary2/CommonSummaryComponents/EditButton.tsx index 17e4a67286..7ffcb059f3 100644 --- a/src/layout/Summary2/CommonSummaryComponents/EditButton.tsx +++ b/src/layout/Summary2/CommonSummaryComponents/EditButton.tsx @@ -10,6 +10,7 @@ import { useLanguage } from 'src/features/language/useLanguage'; import { usePdfModeActive } from 'src/features/pdf/PdfWrapper'; import { useIsMobile } from 'src/hooks/useDeviceWidths'; import { useCurrentView, useNavigateToComponent } from 'src/hooks/useNavigatePage'; +import { useIsEditableInRepGroup } from 'src/layout/RepeatingGroup/Summary2/RepGroupSummaryEditableContext'; import { useSummaryProp } from 'src/layout/Summary2/summaryStoreContext'; import { useIndexedId } from 'src/utils/layout/DataModelLocation'; import { useIsHidden, useIsHiddenMulti } from 'src/utils/layout/hidden'; @@ -22,24 +23,26 @@ export type EditButtonProps = { } & React.HTMLAttributes; /** - * Render an edit button for the first visible (non-hidden) node in a list of possible IDs + * Render an edit button for the first visible (non-hidden) and editable component in a list of possible IDs */ -export function EditButtonFirstVisible({ +export function EditButtonFirstVisibleAndEditable({ ids, fallback, ...rest -}: { ids: string[]; fallback: string } & Omit) { +}: { ids: string[]; fallback: string | undefined } & Omit) { const hiddenIds = useIsHiddenMulti(ids); const first = ids.find((id) => hiddenIds[id] === false); const isFallbackHidden = useIsHidden(fallback); - if (!first && isFallbackHidden) { + const target = first ?? (isFallbackHidden ? undefined : fallback); + + if (!target) { return null; } return ( ); @@ -72,6 +75,12 @@ export function EditButton({ const indexedId = useIndexedId(targetBaseComponentId, skipLastIdMutator); const summary2Id = useSummaryProp('id'); + // Check if we're in a repeating group row and if this component is editable + const editableInRepGroup = useIsEditableInRepGroup(targetBaseComponentId); + if (!editableInRepGroup) { + return null; + } + if (isReadOnly) { return null; } @@ -104,6 +113,7 @@ export function EditButton({ onClick={onChangeClick} variant='tertiary' className={className} + data-target-id={indexedId} > {!isMobile && } { cy.contains('span', validationMessage).should('exist'); }); }); + + it('Should hide edit buttons in Summary2 when edit.editButton is set to false', () => { + cy.startAppInstance(appFrontend.apps.componentLibrary, { authenticationLevel: '2' }); + cy.gotoNavPage('Repeterende gruppe'); + + // The first repeating group + cy.findAllByRole('button', { name: /Legg til ny/ }) + .first() + .click(); + cy.findByRole('textbox', { name: /Navn/ }).type('Test Navn 1'); + cy.findByRole('textbox', { name: /Poeng/ }).type('10'); + cy.findByRole('textbox', { name: /Dato/ }).type('01.01.2026'); + cy.findAllByRole('button', { name: /Lagre og lukk/ }) + .first() + .click(); + + // Repeating group with nested RepeatingGroup + cy.findAllByRole('button', { name: /Legg til ny/ }) + .eq(2) + .click(); + cy.findByRole('textbox', { name: /Navn/ }).type('Test Navn 2'); + cy.findByRole('textbox', { name: /Poeng/ }).type('20'); + cy.findByRole('textbox', { name: /Dato/ }).type('02.01.2026'); + + // Nested repeating group + cy.findAllByRole('button', { name: /Legg til ny/ }) + .last() + .click(); + cy.findByRole('textbox', { name: /Bilmerke/ }).type('Toyota'); + cy.findByRole('textbox', { name: /Modell/ }).type('Corolla'); + cy.findByRole('textbox', { name: /Ã…rsmodell/ }).type('2024'); + + cy.findAllByRole('button', { name: /Lagre og lukk/ }) + .first() + .click(); + + cy.findAllByRole('button', { name: /Endre/ }).should('have.length', 13); + cy.changeLayout((component) => { + if (component.type === 'RepeatingGroup') { + component.edit = component.edit ?? {}; + component.edit.editButton = false; // Hiding the edit button hides every edit button in summary + } + }); + + // The remaining 3 ones are edit buttons for the whole repeating groups from legacy Summary + cy.findAllByRole('button', { name: /Endre/ }).should('have.length', 3); + + cy.changeLayout((component) => { + if (component.type === 'RepeatingGroup' && component.id === 'RepeatingGroup') { + component.tableColumns = { + 'RepeatingGroup-Input-Points': { + editInTable: true, // It can still be edited if editable in the table even when the edit button is gone + }, + }; + } + if (component.type === 'RepeatingGroup' && component.id === 'RepeatingGroup-With-RepeatingGroup') { + component.tableColumns = { + 'RepeatingGroup-With-RepeatingGroup-Input-Points': { + editInTable: true, + }, + }; + } + }); + + cy.findAllByRole('button', { name: /Endre/ }).should('have.length', 3 + 3); + cy.get('button[data-target-id="RepeatingGroup-Input-Points-0"]').should('have.length', 2); + cy.get('button[data-target-id="RepeatingGroup-With-RepeatingGroup-Input-Points-0"]').should('have.length', 1); + + cy.changeLayout((component) => { + if (component.type === 'Input' && component.id.match(/^RepeatingGroup-.*?Input-Points$/)) { + component.readOnly = true; // No point in having an edit button for a readOnly component + } + }); + + cy.findAllByRole('button', { name: /Endre/ }).should('have.length', 3); + cy.get('button[data-target-id="RepeatingGroup-Input-Points-0"]').should('have.length', 0); + cy.get('button[data-target-id="RepeatingGroup-With-RepeatingGroup-Input-Points-0"]').should('have.length', 0); + }); });