diff --git a/src/components/form/Form.tsx b/src/components/form/Form.tsx index 906debbc9b..63394a8fa5 100644 --- a/src/components/form/Form.tsx +++ b/src/components/form/Form.tsx @@ -91,10 +91,7 @@ export function FormPage({ currentPageId }: { currentPageId: string | undefined return ( <> - + ); } diff --git a/src/components/presentation/Presentation.tsx b/src/components/presentation/Presentation.tsx index 6bad9e14b5..6727ba2a20 100644 --- a/src/components/presentation/Presentation.tsx +++ b/src/components/presentation/Presentation.tsx @@ -11,6 +11,7 @@ import { Header } from 'src/components/presentation/Header'; import { NavBar } from 'src/components/presentation/NavBar'; import classes from 'src/components/presentation/Presentation.module.css'; import { Progress } from 'src/components/presentation/Progress'; +import { createContext } from 'src/core/contexts/context'; import { RenderStart } from 'src/core/ui/RenderStart'; import { Footer } from 'src/features/footer/Footer'; import { useUiConfigContext } from 'src/features/form/layout/UiConfigContext'; @@ -45,38 +46,40 @@ export const PresentationComponent = ({ header, type, children, renderNavBar = t return ( -
- -
- {isProcessStepsArchived && instanceStatus?.substatus && ( - } - description={} - /> - )} - {renderNavBar && } -
-
- -
-
{children}
-
-
-
-
+ +
+ +
+ {isProcessStepsArchived && instanceStatus?.substatus && ( + } + description={} + /> + )} + {renderNavBar && } +
+
+ +
+
{children}
+
+
+
+
+
); }; @@ -98,3 +101,10 @@ function ProgressBar({ type }: { type: ProcessTaskType | PresentationType }) { ); } + +const { Provider: PresentationProvider, useHasProvider } = createContext({ + name: 'Presentation', + required: true, +}); + +export const useHasPresentation = () => useHasProvider(); diff --git a/src/components/wrappers/ProcessWrapper.tsx b/src/components/wrappers/ProcessWrapper.tsx index fe81c0fe3e..a18da97c8e 100644 --- a/src/components/wrappers/ProcessWrapper.tsx +++ b/src/components/wrappers/ProcessWrapper.tsx @@ -5,7 +5,7 @@ import { Route, Routes } from 'react-router-dom'; import Grid from '@material-ui/core/Grid'; import { Button } from 'src/app-components/button/Button'; -import { Form, FormFirstPage } from 'src/components/form/Form'; +import { Form } from 'src/components/form/Form'; import { PresentationComponent } from 'src/components/presentation/Presentation'; import classes from 'src/components/wrappers/ProcessWrapper.module.css'; import { Loader } from 'src/core/loading/Loader'; @@ -187,7 +187,7 @@ export const ProcessWrapper = () => { } /> @@ -196,10 +196,6 @@ export const ProcessWrapper = () => { } /> - } - /> ); diff --git a/src/core/contexts/context.tsx b/src/core/contexts/context.tsx index c5b5b1c97a..ffe56ac92e 100644 --- a/src/core/contexts/context.tsx +++ b/src/core/contexts/context.tsx @@ -50,12 +50,12 @@ export function createContext({ name, required, ...rest }: CreateContextProps }); Context.displayName = name; - const useHasProvider = () => Boolean(useContext(Context).provided); + const useHasProvider = () => useContext(Context).provided; const useCtx = (): T => { - const hasProvider = useHasProvider(); - const value = useContext(Context).innerValue; - if (!hasProvider) { + const ctx = useContext(Context); + const value = ctx.innerValue; + if (!ctx.provided) { if (required) { throw new Error(`${name} is missing`); } diff --git a/src/core/loading/Loader.tsx b/src/core/loading/Loader.tsx index 022e28718a..7510c8b624 100644 --- a/src/core/loading/Loader.tsx +++ b/src/core/loading/Loader.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { AltinnContentIconFormData } from 'src/components/atoms/AltinnContentIconFormData'; import { AltinnContentLoader } from 'src/components/molecules/AltinnContentLoader'; -import { PresentationComponent } from 'src/components/presentation/Presentation'; +import { PresentationComponent, useHasPresentation } from 'src/components/presentation/Presentation'; import { useTaskStore } from 'src/core/contexts/taskStoreContext'; import { LoadingProvider } from 'src/core/loading/LoadingContext'; import { Lang } from 'src/features/language/Lang'; @@ -11,12 +11,12 @@ import { ProcessTaskType } from 'src/types'; interface LoaderProps { reason: string; // The reason is used by developers to identify the reason for the loader details?: string; - renderPresentation?: boolean; } -export const Loader = ({ renderPresentation = true, ...rest }: LoaderProps) => { +export const Loader = (props: LoaderProps) => { const overriddenDataModelUuid = useTaskStore((state) => state.overriddenDataModelUuid); const overriddenTaskId = useTaskStore((state) => state.overriddenTaskId); + const hasPresentation = useHasPresentation(); if (overriddenDataModelUuid) { return null; @@ -25,23 +25,23 @@ export const Loader = ({ renderPresentation = true, ...rest }: LoaderProps) => { if (overriddenTaskId) { return null; } - if (renderPresentation) { + if (!hasPresentation) { return ( - + } type={ProcessTaskType.Unknown} renderNavBar={false} > - + ); } return ( - - + + ); }; diff --git a/src/features/formData/FormDataWrite.tsx b/src/features/formData/FormDataWrite.tsx index 73e9cd368d..8d354d0e39 100644 --- a/src/features/formData/FormDataWrite.tsx +++ b/src/features/formData/FormDataWrite.tsx @@ -971,11 +971,4 @@ export const FD = { return useCallback((dataElementId: string) => map[dataElementId], [map]); }, - - /** - * This lets you set to a function that will be called as soon as the saving operation finishes. - * Beware that this is not a subscription service, so you can easily overwrite an existing callback here. This - * is only meant to be used in NodesContext. - */ - useSetOnSaveFinished: () => useSelector((s) => s.setOnSaveFinished), }; diff --git a/src/features/formData/FormDataWriteStateMachine.tsx b/src/features/formData/FormDataWriteStateMachine.tsx index e1feb5c69e..258fba9e1f 100644 --- a/src/features/formData/FormDataWriteStateMachine.tsx +++ b/src/features/formData/FormDataWriteStateMachine.tsx @@ -80,11 +80,6 @@ type FormDataState = { // This contains the validation issues we receive from the server last time we saved the data model. validationIssues: BackendValidationIssueGroups | undefined; - // This may contain a callback function that will be called whenever the save finishes. - // Should only be set from NodesContext. - onSaveFinished: ((result: FDSaveFinished) => void) | undefined; - setOnSaveFinished: (callback: (result: FDSaveFinished) => void) => void; - // This is used to track which component is currently blocking the auto-saving feature. If this is set to a string // value, auto-saving will be disabled, even if the autoSaving flag is set to true. This is useful when you want // to temporarily disable auto-saving, for example when clicking a CustomButton and waiting for the server to @@ -238,7 +233,6 @@ function makeActions( const { validationIssues, savedData, newDataModels, instance } = toProcess; state.manualSaveRequested = false; state.validationIssues = validationIssues; - state.onSaveFinished?.(toProcess); if (instance && changeInstance) { changeInstance(() => instance); @@ -548,11 +542,6 @@ export const createFormDataWriteStore = ( debounceTimeout: DEFAULT_DEBOUNCE_TIMEOUT, manualSaveRequested: false, validationIssues: undefined, - onSaveFinished: undefined, - setOnSaveFinished: (callback) => - set((state) => { - state.onSaveFinished = callback; - }), ...actions, }; }), diff --git a/src/features/routing/AppRoutingContext.tsx b/src/features/routing/AppRoutingContext.tsx index 8f54cd2524..47fe582ec4 100644 --- a/src/features/routing/AppRoutingContext.tsx +++ b/src/features/routing/AppRoutingContext.tsx @@ -1,6 +1,7 @@ import React, { useEffect } from 'react'; import { matchPath, useLocation, useNavigate as useNativeNavigate } from 'react-router-dom'; import type { MutableRefObject, PropsWithChildren } from 'react'; +import type { NavigateOptions } from 'react-router-dom'; import { createStore } from 'zustand'; @@ -30,17 +31,18 @@ interface Context { updateHash: (hash: string) => void; effectCallback: NavigationEffectCb | null; setEffectCallback: (cb: NavigationEffectCb | null) => void; - navigateRef: MutableRefObject>; + navigateRef: MutableRefObject; } +export type SimpleNavigate = (target: string, options?: NavigateOptions) => void; + function newStore({ initialLocation }: { initialLocation: string | undefined }) { return createStore((set) => ({ hash: initialLocation ? initialLocation : `${window.location.hash}`, updateHash: (hash: string) => set({ hash }), effectCallback: null, setEffectCallback: (effectCallback: NavigationEffectCb) => set({ effectCallback }), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - navigateRef: { current: undefined as any }, + navigateRef: { current: undefined }, })); } @@ -139,11 +141,13 @@ export const useIsSubformPage = () => useSelector((s) => { const path = getPath(s.hash); const matches = matchers.map((matcher) => matchPath(matcher, path)); - return !!paramFrom(matches, 'mainPageKey'); + const mainPageKey = paramFrom(matches, 'mainPageKey'); + const subformPageKey = paramFrom(matches, 'pageKey'); + return !!(mainPageKey && subformPageKey); }); // Use this instead of the native one to avoid re-rendering whenever the route changes -export const useNavigate = () => useSelector((ctx) => ctx.navigateRef).current; +export const useNavigate = () => useStaticSelector((ctx) => ctx.navigateRef).current!; const matchers: string[] = [ '/instance/:partyId/:instanceGuid', @@ -198,8 +202,20 @@ function UpdateHash() { } function UpdateNavigate() { - const navigateRef = useSelector((ctx) => ctx.navigateRef); - navigateRef.current = useNativeNavigate(); + const store = useStore(); + const navigateRef = useStaticSelector((ctx) => ctx.navigateRef); + const nativeNavigate = useNativeNavigate(); + + navigateRef.current = (target, options) => { + if (target && !target.startsWith('/')) { + // Used for relative navigation, e.g. navigating to a subform page + const currentPath = getPath(store.getState().hash).replace(/\/$/, ''); + const newTarget = `${currentPath}/${target}`; + nativeNavigate(newTarget, options); + return; + } + nativeNavigate(target, options); + }; return null; } diff --git a/src/features/validation/StoreValidationsInNode.tsx b/src/features/validation/StoreValidationsInNode.tsx index 9d2a445f73..dd6dcffa0d 100644 --- a/src/features/validation/StoreValidationsInNode.tsx +++ b/src/features/validation/StoreValidationsInNode.tsx @@ -10,7 +10,7 @@ import { GeneratorCondition, StageFormValidation } from 'src/utils/layout/genera import { NodesInternal } from 'src/utils/layout/NodesContext'; import type { AnyValidation, AttachmentValidation } from 'src/features/validation/index'; import type { CompCategory } from 'src/layout/common'; -import type { TypesFromCategory } from 'src/layout/layout'; +import type { CompIntermediate, TypesFromCategory } from 'src/layout/layout'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; export function StoreValidationsInNode() { @@ -29,9 +29,9 @@ type Node = LayoutNode !deepEqual(data.validations, validations)); @@ -56,15 +56,6 @@ function StoreValidationsInNodeWorker() { visibilityToSet !== undefined, ); - const shouldSetProcessedLast = NodesInternal.useNodeData( - node, - (data) => data.validationsProcessedLast !== processedLast, - ); - NodesStateQueue.useSetNodeProp( - { node, prop: 'validationsProcessedLast', value: processedLast, force: true }, - shouldSetProcessedLast, - ); - return null; } @@ -91,3 +82,7 @@ function useUpdatedValidations(validations: AnyValidation[], node: Node) { return copy; }); } + +export function shouldValidateNode(item: CompIntermediate): boolean { + return !('renderAsSummary' in item && item.renderAsSummary); +} diff --git a/src/features/validation/ValidationPlugin.tsx b/src/features/validation/ValidationPlugin.tsx index d57d30dc70..088ed45c04 100644 --- a/src/features/validation/ValidationPlugin.tsx +++ b/src/features/validation/ValidationPlugin.tsx @@ -1,22 +1,16 @@ import { CG } from 'src/codegen/CG'; import { NodeDefPlugin } from 'src/utils/layout/plugins/NodeDefPlugin'; import type { ComponentConfig } from 'src/codegen/ComponentConfig'; -import type { ComponentValidation, ValidationsProcessedLast } from 'src/features/validation/index'; +import type { ComponentValidation } from 'src/features/validation/index'; import type { CompCategory } from 'src/layout/common'; import type { TypesFromCategory } from 'src/layout/layout'; -import type { NodesContext } from 'src/utils/layout/NodesContext'; -import type { - DefPluginExtraState, - DefPluginState, - DefPluginStateFactoryProps, -} from 'src/utils/layout/plugins/NodeDefPlugin'; +import type { DefPluginExtraState, DefPluginStateFactoryProps } from 'src/utils/layout/plugins/NodeDefPlugin'; interface Config { componentType: TypesFromCategory; extraState: { validations: ComponentValidation[]; validationVisibility: number; - validationsProcessedLast: ValidationsProcessedLast; }; } @@ -49,7 +43,6 @@ export class ValidationPlugin extends NodeDefPlugin { return { validations: [], validationVisibility: 0, - validationsProcessedLast: { initial: undefined, incremental: undefined }, }; } @@ -61,11 +54,4 @@ export class ValidationPlugin extends NodeDefPlugin { return `<${StoreValidationsInNode} />`; } - - stateIsReady(state: DefPluginState, fullState: NodesContext): boolean { - return ( - state.validationsProcessedLast.initial === fullState.validationsProcessedLast.initial && - state.validationsProcessedLast.incremental === fullState.validationsProcessedLast.incremental - ); - } } diff --git a/src/features/validation/backendValidation/BackendValidation.tsx b/src/features/validation/backendValidation/BackendValidation.tsx index 3edaa35f85..39dda82b88 100644 --- a/src/features/validation/backendValidation/BackendValidation.tsx +++ b/src/features/validation/backendValidation/BackendValidation.tsx @@ -13,7 +13,6 @@ import { useShouldValidateInitial, } from 'src/features/validation/backendValidation/backendValidationUtils'; import { Validation } from 'src/features/validation/validationContext'; -import { NodesStore } from 'src/utils/layout/NodesContext'; const emptyObject = {}; const emptyArray = []; @@ -91,15 +90,3 @@ export function BackendValidation({ dataTypes }: { dataTypes: string[] }) { return null; } - -export function MaintainInitialValidationsInNodesContext() { - const enabled = useShouldValidateInitial(); - const { data: initialValidations } = useBackendValidationQuery(enabled); - const setInitialValidations = NodesStore.useSelector((state) => state.setLatestInitialValidations); - - useEffect(() => { - setInitialValidations(initialValidations); - }, [initialValidations, setInitialValidations]); - - return null; -} diff --git a/src/features/validation/nodeValidation/useNodeValidation.ts b/src/features/validation/nodeValidation/useNodeValidation.ts index 910e2401a1..cc902348b4 100644 --- a/src/features/validation/nodeValidation/useNodeValidation.ts +++ b/src/features/validation/nodeValidation/useNodeValidation.ts @@ -1,119 +1,59 @@ -import { useMemo } from 'react'; - -import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; -import { useAttachmentsSelector } from 'src/features/attachments/hooks'; -import { useLayoutSets } from 'src/features/form/layoutSets/LayoutSetsProvider'; -import { FD } from 'src/features/formData/FormDataWrite'; -import { useLaxDataElementsSelector } from 'src/features/instance/InstanceContext'; -import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; import { Validation } from 'src/features/validation/validationContext'; import { implementsValidateComponent, implementsValidateEmptyField } from 'src/layout'; +import { GeneratorInternal } from 'src/utils/layout/generator/GeneratorContext'; import { GeneratorData } from 'src/utils/layout/generator/GeneratorDataSources'; -import { NodesInternal } from 'src/utils/layout/NodesContext'; -import type { - AnyValidation, - BaseValidation, - ValidationDataSources, - ValidationsProcessedLast, -} from 'src/features/validation'; +import type { AnyValidation, BaseValidation } from 'src/features/validation'; import type { CompDef, ValidationFilter } from 'src/layout'; import type { IDataModelReference } from 'src/layout/common.generated'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; import type { NodeDataSelector } from 'src/utils/layout/NodesContext'; +const emptyArray: AnyValidation[] = []; + /** * Runs validations defined in the component classes. This runs from the node generator, and will collect all * validations for a node and return them. */ -export function useNodeValidation( - node: LayoutNode, - shouldValidate: boolean, -): { validations: AnyValidation[]; processedLast: ValidationsProcessedLast } { - const dataModelSelector = Validation.useDataModelSelector(); - const validationDataSources = GeneratorData.useValidationDataSources(); - const nodeDataSelector = NodesInternal.useNodeDataSelector(); - +export function useNodeValidation(node: LayoutNode, shouldValidate: boolean): AnyValidation[] { + const registry = GeneratorInternal.useRegistry(); + const dataSources = GeneratorData.useValidationDataSources(); const getDataElementIdForDataType = GeneratorData.useGetDataElementIdForDataType(); - const processedLast = GeneratorData.useValidationsProcessedLast(); + const dataModelBindings = GeneratorInternal.useIntermediateItem()?.dataModelBindings; + const bindings = Object.entries((dataModelBindings ?? {}) as Record); - return { - processedLast, - validations: useMemo(() => { - const validations: AnyValidation[] = []; - if (!shouldValidate) { - return validations; - } + return Validation.useFullState((state) => { + if (!shouldValidate) { + return emptyArray; + } - if (implementsValidateEmptyField(node.def)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - validations.push(...node.def.runEmptyFieldValidation(node as any, validationDataSources)); - } + const validations: AnyValidation[] = []; + if (implementsValidateEmptyField(node.def)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + validations.push(...node.def.runEmptyFieldValidation(node as any, dataSources)); + } - if (implementsValidateComponent(node.def)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - validations.push(...node.def.runComponentValidation(node as any, validationDataSources)); - } + if (implementsValidateComponent(node.def)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + validations.push(...node.def.runComponentValidation(node as any, dataSources)); + } - const dataModelBindings = validationDataSources.nodeDataSelector( - (picker) => picker(node)?.layout.dataModelBindings, - [node], - ); - for (const [bindingKey, { dataType, field }] of Object.entries( - (dataModelBindings ?? {}) as Record, - )) { - const dataElementId = getDataElementIdForDataType(dataType) ?? dataType; // stateless does not have dataElementId - const fieldValidations = dataModelSelector( - (dataModels) => dataModels[dataElementId]?.[field], - [dataType, field], - ); - if (fieldValidations) { - validations.push(...fieldValidations.map((v) => ({ ...v, bindingKey }))); - } + for (const [bindingKey, { dataType, field }] of bindings) { + const dataElementId = getDataElementIdForDataType(dataType) ?? dataType; // stateless does not have dataElementId + const fieldValidations = state.state.dataModels[dataElementId]?.[field]; + if (fieldValidations) { + validations.push(...fieldValidations.map((v) => ({ ...v, bindingKey }))); } + } - return filter(validations, node, nodeDataSelector); - }, [shouldValidate, node, validationDataSources, nodeDataSelector, getDataElementIdForDataType, dataModelSelector]), - }; -} + const result = filter(validations, node, dataSources.nodeDataSelector); + registry.current.validationsProcessed[node.id] = state.processedLast; -/** - * Hook providing validation data sources - */ -function useValidationDataSources(): ValidationDataSources { - const formDataSelector = FD.useDebouncedSelector(); - const invalidDataSelector = FD.useInvalidDebouncedSelector(); - const attachmentsSelector = useAttachmentsSelector(); - const currentLanguage = useCurrentLanguage(); - const nodeSelector = NodesInternal.useNodeDataSelector(); - const applicationMetadata = useApplicationMetadata(); - const dataElementsSelector = useLaxDataElementsSelector(); - const layoutSets = useLayoutSets(); - const dataElementHasErrorsSelector = Validation.useDataElementHasErrorsSelector(); + if (result.length === 0) { + return emptyArray; + } - return useMemo( - () => ({ - formDataSelector, - invalidDataSelector, - attachmentsSelector, - currentLanguage, - nodeDataSelector: nodeSelector, - applicationMetadata, - dataElementsSelector, - layoutSets, - dataElementHasErrorsSelector, - }), - [ - formDataSelector, - invalidDataSelector, - attachmentsSelector, - currentLanguage, - nodeSelector, - applicationMetadata, - dataElementsSelector, - layoutSets, - dataElementHasErrorsSelector, - ], - ); + return result; + }); } /** diff --git a/src/features/validation/nodeValidation/waitForNodesToValidate.ts b/src/features/validation/nodeValidation/waitForNodesToValidate.ts new file mode 100644 index 0000000000..09229564b7 --- /dev/null +++ b/src/features/validation/nodeValidation/waitForNodesToValidate.ts @@ -0,0 +1,54 @@ +import { useCallback } from 'react'; + +import { shouldValidateNode } from 'src/features/validation/StoreValidationsInNode'; +import { GeneratorInternal } from 'src/utils/layout/generator/GeneratorContext'; +import { NodesStore } from 'src/utils/layout/NodesContext'; +import type { ValidationsProcessedLast } from 'src/features/validation'; + +export function useWaitForNodesToValidate() { + const registry = GeneratorInternal.useRegistry(); + const nodesStore = NodesStore.useStore(); + + return useCallback( + async (processedLast: ValidationsProcessedLast): Promise => { + let callbackId: ReturnType | undefined; + const request = window.requestIdleCallback || window.requestAnimationFrame; + const cancel = window.cancelIdleCallback || window.cancelAnimationFrame; + + function check(): boolean { + const allNodeData = nodesStore.getState().nodeData; + const nodeIds = Object.keys(allNodeData); + for (const nodeId of nodeIds) { + const nodeData = allNodeData[nodeId]; + if (!nodeData || !('validations' in nodeData) || !shouldValidateNode(nodeData.layout)) { + // Node does not support validation + continue; + } + + const lastValidations = registry.current.validationsProcessed[nodeId]; + const initialIsLatest = lastValidations?.initial === processedLast.initial; + const incrementalIsLatest = lastValidations?.incremental === processedLast.incremental; + if (!(lastValidations && initialIsLatest && incrementalIsLatest)) { + return false; + } + } + + return true; + } + + return new Promise((resolve) => { + function checkAndResolve() { + if (check()) { + resolve(); + callbackId && cancel(callbackId); + } else { + callbackId = request(checkAndResolve); + } + } + + checkAndResolve(); + }); + }, + [nodesStore, registry], + ); +} diff --git a/src/features/validation/validationContext.tsx b/src/features/validation/validationContext.tsx index 0d52480958..c4dc098623 100644 --- a/src/features/validation/validationContext.tsx +++ b/src/features/validation/validationContext.tsx @@ -31,6 +31,7 @@ import { useShouldValidateInitial, } from 'src/features/validation/backendValidation/backendValidationUtils'; import { InvalidDataValidation } from 'src/features/validation/invalidDataValidation/InvalidDataValidation'; +import { useWaitForNodesToValidate } from 'src/features/validation/nodeValidation/waitForNodesToValidate'; import { SchemaValidation } from 'src/features/validation/schemaValidation/SchemaValidation'; import { hasValidationErrors, mergeFieldValidations, selectValidations } from 'src/features/validation/utils'; import { useAsRef } from 'src/hooks/useAsRef'; @@ -146,6 +147,7 @@ function initialCreateStore() { const { Provider, useSelector, + useMemoSelector, useLaxShallowSelector, useSelectorAsRef, useStore, @@ -188,6 +190,7 @@ function useWaitForValidation(): WaitForValidation { const hasWritableDataTypes = !!DataModels.useWritableDataTypes()?.length; const enabled = useShouldValidateInitial(); const getCachedInitialValidations = useGetCachedInitialValidations(); + const waitForNodesToValidate = useWaitForNodesToValidate(); return useCallback( async (forceSave = true) => { @@ -201,21 +204,22 @@ function useWaitForValidation(): WaitForValidation { await waitForNodesReady(); const validationsFromSave = await waitForSave(forceSave); // If validationsFromSave is not defined, we check if initial validations are done processing - const lastInitialValidations = await waitForState((state, setReturnValue) => { + await waitForState(async (state) => { const { isFetching, cachedInitialValidations } = getCachedInitialValidations(); - if ( + const validationsReady = state.processedLast.incremental === validationsFromSave && state.processedLast.initial === cachedInitialValidations && - !isFetching - ) { - setReturnValue(cachedInitialValidations); + !isFetching; + + if (validationsReady) { + await waitForNodesToValidate(state.processedLast); return true; } return false; }); - await waitForNodesReady({ initial: lastInitialValidations, incremental: validationsFromSave }); + await waitForNodesReady(); }, [ enabled, @@ -223,6 +227,7 @@ function useWaitForValidation(): WaitForValidation { hasWritableDataTypes, waitForAttachments, waitForNodesReady, + waitForNodesToValidate, waitForSave, waitForState, ], @@ -334,8 +339,6 @@ export type ValidationDataModelSelector = ReturnType; export const Validation = { - useFullStateRef: () => useSelectorAsRef((state) => state.state), - // Selectors. These are memoized, so they won't cause a re-render unless the selected fields change. useSelector: () => useDS((state) => state), useDataModelSelector: () => useDS((state) => state.state.dataModels), @@ -377,8 +380,12 @@ export const Validation = { useUpdateDataModelValidations: () => useSelector((state) => state.updateDataModelValidations), useUpdateBackendValidations: () => useSelector((state) => state.updateBackendValidations), - useProcessedLast: () => useSelector((state) => state.processedLast), - useProcessedLastRef: () => useSelectorAsRef((state) => state.processedLast), + useFullState: (selector: (state: ValidationContext & Internals) => U): U => + useMemoSelector((state) => selector(state)), + useGetProcessedLast: () => { + const store = useStore(); + return useCallback(() => store.getState().processedLast, [store]); + }, useRef: () => useSelectorAsRef((state) => state), useLaxRef: () => useLaxSelectorAsRef((state) => state), diff --git a/src/hooks/useWaitForState.ts b/src/hooks/useWaitForState.ts index 349d860492..d6a6d8b819 100644 --- a/src/hooks/useWaitForState.ts +++ b/src/hooks/useWaitForState.ts @@ -6,9 +6,9 @@ import type { StoreApi } from 'zustand'; import { ContextNotProvided } from 'src/core/contexts/context'; export type WaitForState = (callback: Callback) => Promise; -type Callback = (state: T, setReturnValue: (val: RetVal) => void) => boolean; +type Callback = (state: T, setReturnValue: (val: RetVal) => void) => boolean | Promise; // eslint-disable-next-line @typescript-eslint/no-explicit-any -type Subscriber = (state: any) => boolean; +type Subscriber = (state: any) => boolean | Promise; // If ContextNotProvided is a valid state, it will possibly be provided as a direct // input (not wrapped in a ref or store) @@ -27,8 +27,13 @@ export function useWaitForState(state: ValidInputs[0]): WaitForSta // Call subscribers on every re-render/state change if state is a ref if (isRef(state)) { for (const subscriber of subscribersRef.current) { - if (subscriber(state.current)) { + const result = subscriber(state.current); + if (result === true) { subscribersRef.current.delete(subscriber); + } else if (result instanceof Promise) { + result.then((res) => { + res && subscribersRef.current.delete(subscriber); + }); } } } @@ -36,9 +41,9 @@ export function useWaitForState(state: ValidInputs[0]): WaitForSta useEffect(() => { // Call subscribers on every state change if state is a zustand store if (isStore(state)) { - return state.subscribe((state) => { + return state.subscribe(async (state) => { for (const subscriber of subscribersRef.current) { - if (subscriber(state)) { + if (await subscriber(state)) { subscribersRef.current.delete(subscriber); } } @@ -56,8 +61,8 @@ export function useWaitForState(state: ValidInputs[0]): WaitForSta } if (state === ContextNotProvided) { - subscribersRef.current.add((state) => { - if (callback(state, setReturnValue)) { + subscribersRef.current.add(async (state) => { + if (await callback(state, setReturnValue)) { resolve(returnValue as RetVal); return true; } @@ -69,13 +74,20 @@ export function useWaitForState(state: ValidInputs[0]): WaitForSta const currentState = isRef(state) ? state.current : state.getState(); // If state is already correct, resolve immediately - if (callback(currentState, setReturnValue)) { + const result = callback(currentState, setReturnValue); + + if (result === true) { resolve(returnValue as RetVal); return; } + if (result instanceof Promise) { + result.then((res) => { + res && resolve(returnValue as RetVal); + }); + } - subscribersRef.current.add((state) => { - if (callback(state, setReturnValue)) { + subscribersRef.current.add(async (state) => { + if (await callback(state, setReturnValue)) { resolve(returnValue as RetVal); return true; } diff --git a/src/layout/Subform/SubformComponent.tsx b/src/layout/Subform/SubformComponent.tsx index 5db47207f5..48de463d6d 100644 --- a/src/layout/Subform/SubformComponent.tsx +++ b/src/layout/Subform/SubformComponent.tsx @@ -1,5 +1,4 @@ import React, { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; import { Spinner, Table } from '@digdir/designsystemet-react'; import { Grid } from '@material-ui/core'; @@ -14,7 +13,7 @@ import { useFormDataQuery } from 'src/features/formData/useFormDataQuery'; import { useStrictDataElements, useStrictInstanceId } from 'src/features/instance/InstanceContext'; import { Lang } from 'src/features/language/Lang'; import { useLanguage } from 'src/features/language/useLanguage'; -import { useIsSubformPage } from 'src/features/routing/AppRoutingContext'; +import { useIsSubformPage, useNavigate } from 'src/features/routing/AppRoutingContext'; import { useAddEntryMutation, useDeleteEntryMutation } from 'src/features/subformData/useSubformMutations'; import { isSubformValidation } from 'src/features/validation'; import { useComponentValidationsForNode } from 'src/features/validation/selectors/componentValidationsForNode'; diff --git a/src/layout/Subform/SubformWrapper.tsx b/src/layout/Subform/SubformWrapper.tsx index 57086eb08b..7a773cecd4 100644 --- a/src/layout/Subform/SubformWrapper.tsx +++ b/src/layout/Subform/SubformWrapper.tsx @@ -1,11 +1,11 @@ import React, { useEffect } from 'react'; -import { Form, FormFirstPage } from 'src/components/form/Form'; +import { Form } from 'src/components/form/Form'; import { useTaskStore } from 'src/core/contexts/taskStoreContext'; import { Loader } from 'src/core/loading/Loader'; import { FormProvider } from 'src/features/form/FormContext'; import { useDataTypeFromLayoutSet } from 'src/features/form/layout/LayoutsContext'; -import { useIsCurrentView, useNavigationParam } from 'src/features/routing/AppRoutingContext'; +import { useNavigationParam } from 'src/features/routing/AppRoutingContext'; import { useNavigatePage } from 'src/hooks/useNavigatePage'; import { useNodeItem } from 'src/utils/layout/useNodeItem'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; @@ -19,21 +19,10 @@ export function SubformWrapper({ node }: { node: LayoutNode<'Subform'> }) { return ( - +
); } - -function SubformForm() { - const hasFormPage = !useIsCurrentView(undefined); - - if (hasFormPage) { - return ; - } - - return ; -} - export const RedirectBackToMainForm = () => { const mainPageKey = useNavigationParam('mainPageKey'); const { navigateToPage } = useNavigatePage(); diff --git a/src/layout/Subform/Summary/SubformSummaryTable.tsx b/src/layout/Subform/Summary/SubformSummaryTable.tsx index 75e5b37d15..9d2cb2a1c4 100644 --- a/src/layout/Subform/Summary/SubformSummaryTable.tsx +++ b/src/layout/Subform/Summary/SubformSummaryTable.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { useNavigate } from 'react-router-dom'; import { Paragraph, Spinner, Table } from '@digdir/designsystemet-react'; import { Grid } from '@material-ui/core'; @@ -13,7 +12,7 @@ import { useStrictDataElements, useStrictInstanceId } from 'src/features/instanc import { Lang } from 'src/features/language/Lang'; import { useLanguage } from 'src/features/language/useLanguage'; import { usePdfModeActive } from 'src/features/pdf/PDFWrapper'; -import { useIsSubformPage } from 'src/features/routing/AppRoutingContext'; +import { useIsSubformPage, useNavigate } from 'src/features/routing/AppRoutingContext'; import { isSubformValidation } from 'src/features/validation'; import { useComponentValidationsForNode } from 'src/features/validation/selectors/componentValidationsForNode'; import { ComponentStructureWrapper } from 'src/layout/ComponentStructureWrapper'; diff --git a/src/utils/layout/LayoutPage.ts b/src/utils/layout/LayoutPage.ts index 14688e9e60..237f0585cb 100644 --- a/src/utils/layout/LayoutPage.ts +++ b/src/utils/layout/LayoutPage.ts @@ -12,19 +12,34 @@ export class LayoutPage implements LayoutObject { public layoutSet: LayoutPages; public pageKey: string; - private allChildren: Map = new Map(); + private allChildren: LayoutNode[] = []; + private allChildIds = new Set(); + + private _directChildren: LayoutNode[] = []; /** * Adds a child to the collection. For internal use only. */ public _addChild(child: LayoutNode) { - this.allChildren.set(child.id, child); - this.layoutSet.registerNode(child); + if (!this.allChildIds.has(child.id)) { + this.layoutSet.registerNode(child); + this.allChildIds.add(child.id); + this.allChildren.push(child); + + // Direct children of a layout page are always static. + // Only children of components like repeating groups are dynamic + if (child.parent === this) { + this._directChildren.push(child); + } + } } public _removeChild(child: LayoutNode) { - this.allChildren.delete(child.id); - this.layoutSet.unregisterNode(child); + if (this.allChildIds.has(child.id)) { + this.layoutSet.unregisterNode(child); + this.allChildIds.delete(child.id); + this.allChildren.splice(this.allChildren.indexOf(child), 1); + } } /** @@ -47,7 +62,7 @@ export class LayoutPage implements LayoutObject { } protected directChildren(_task: TraversalTask): LayoutNode[] { - return [...this.allChildren.values()].filter((node) => node.parent === this); + return this._directChildren; } public firstChild(task: TraversalTask): LayoutNode | undefined { @@ -61,7 +76,7 @@ export class LayoutPage implements LayoutObject { } private firstDeepChild(task: TraversalTask): LayoutNode | undefined { - for (const node of this.allChildren.values()) { + for (const node of this.allChildren) { if (task.passes(node)) { return node; } @@ -86,7 +101,7 @@ export class LayoutPage implements LayoutObject { } public flat(task?: TraversalTask): LayoutNode[] { - return task ? [...this.allChildren.values()].filter((n) => task.passes(n)) : [...this.allChildren.values()]; + return task ? this.allChildren.filter((n) => task.passes(n)) : this.allChildren; } public isRegisteredInCollection(layoutSet: LayoutPages): boolean { @@ -98,7 +113,7 @@ export class LayoutPage implements LayoutObject { this.layoutSet = layoutSet; layoutSet.replacePage(this); - for (const node of this.allChildren.values()) { + for (const node of this.allChildren) { layoutSet.registerNode(node); } } diff --git a/src/utils/layout/NodesContext.tsx b/src/utils/layout/NodesContext.tsx index d844ceef6f..dd4d06e902 100644 --- a/src/utils/layout/NodesContext.tsx +++ b/src/utils/layout/NodesContext.tsx @@ -17,11 +17,8 @@ import { UpdateAttachmentsForCypress } from 'src/features/attachments/UpdateAtta import { HiddenComponentsProvider } from 'src/features/form/dynamics/HiddenComponentsProvider'; import { useLayouts } from 'src/features/form/layout/LayoutsContext'; import { useLaxLayoutSettings, useLayoutSettings } from 'src/features/form/layoutSettings/LayoutSettingsContext'; -import { FD } from 'src/features/formData/FormDataWrite'; import { OptionsStorePlugin } from 'src/features/options/OptionsStorePlugin'; import { useIsCurrentView } from 'src/features/routing/AppRoutingContext'; -import { MaintainInitialValidationsInNodesContext } from 'src/features/validation/backendValidation/BackendValidation'; -import { useGetCachedInitialValidations } from 'src/features/validation/backendValidation/backendValidationQuery'; import { ExpressionValidation } from 'src/features/validation/expressionValidation/ExpressionValidation'; import { LoadingBlockerWaitForValidation, @@ -51,7 +48,6 @@ import { LayoutPages } from 'src/utils/layout/LayoutPages'; import { RepeatingChildrenStorePlugin } from 'src/utils/layout/plugins/RepeatingChildrenStorePlugin'; import { TraversalTask } from 'src/utils/layout/useNodeTraversal'; import type { AttachmentsStorePluginConfig } from 'src/features/attachments/AttachmentsStorePlugin'; -import type { FDSaveFinished } from 'src/features/formData/FormDataWriteStateMachine'; import type { OptionsStorePluginConfig } from 'src/features/options/OptionsStorePlugin'; import type { ValidationsProcessedLast } from 'src/features/validation'; import type { ValidationStorePluginConfig } from 'src/features/validation/ValidationStorePlugin'; @@ -129,7 +125,6 @@ export interface SetPagePropRequest { export enum NodesReadiness { Ready = 'READY', NotReady = 'NOT READY', - WaitingUntilLastSaveHasProcessed = 'WAIT FOR SAVE', } export type NodesContext = { @@ -144,7 +139,6 @@ export type NodesContext = { prevNodeData: { [key: string]: NodeData } | undefined; // Earlier node data from before the state became non-ready hiddenViaRules: { [key: string]: true | undefined }; hiddenViaRulesRan: boolean; - validationsProcessedLast: ValidationsProcessedLast; layouts: ILayouts | undefined; // Used to detect if the layouts have changed stages: GeneratorStagesContext; @@ -158,8 +152,6 @@ export type NodesContext = { addPage: (pageKey: string) => void; setPageProps: (requests: SetPagePropRequest[]) => void; markReady: (reason: string, readiness?: NodesReadiness) => void; - onSaveFinished: (result: FDSaveFinished) => void; - setLatestInitialValidations: (validations: ValidationsProcessedLast['initial']) => void; reset: (layouts: ILayouts, validationsProcessedLast: ValidationsProcessedLast) => void; @@ -349,28 +341,6 @@ export function createNodesDataStore({ registry, validationsProcessedLast }: Cre }), markReady: (reason, readiness = NodesReadiness.Ready) => set((state) => setReadiness({ state, target: readiness, reason })), - onSaveFinished: (result) => - set((state) => { - if (state.readiness !== NodesReadiness.WaitingUntilLastSaveHasProcessed) { - generatorLog('logReadiness', `Marking state as NOT READY: processing save results`); - } - - return { - readiness: NodesReadiness.WaitingUntilLastSaveHasProcessed, - prevNodeData: state.nodeData, - validationsProcessedLast: { - ...state.validationsProcessedLast, - incremental: result.validationIssues, - }, - }; - }), - setLatestInitialValidations: (validations) => - set((state) => ({ - validationsProcessedLast: { - ...state.validationsProcessedLast, - initial: validations, - }, - })), reset: (layouts, validationsProcessedLast: ValidationsProcessedLast) => set(() => { @@ -518,12 +488,12 @@ const { export const NodesProvider = ({ children }: React.PropsWithChildren) => { const registry = useRegistry(); - const processedLast = Validation.useProcessedLastRef(); + const getProcessedLast = Validation.useGetProcessedLast(); return ( @@ -551,7 +521,7 @@ function ProvideGlobalContext({ children, registry }: PropsWithChildren<{ regist const layouts = Store.useSelector((s) => s.layouts); const markNotReady = NodesInternal.useMarkNotReady(); const reset = Store.useSelector((s) => s.reset); - const processedLast = Validation.useProcessedLastRef(); + const getProcessedLast = Validation.useGetProcessedLast(); const pagesRef = useRef(); if (!pagesRef.current) { pagesRef.current = new LayoutPages(); @@ -561,9 +531,9 @@ function ProvideGlobalContext({ children, registry }: PropsWithChildren<{ regist if (layouts !== latestLayouts) { markNotReady('new layouts'); pagesRef.current = new LayoutPages(); - reset(latestLayouts, processedLast.current); + reset(latestLayouts, getProcessedLast()); } - }, [latestLayouts, layouts, markNotReady, reset, processedLast]); + }, [latestLayouts, layouts, markNotReady, reset, getProcessedLast]); const layoutMap = useMemo(() => { const out: { [id: string]: CompExternal } = {}; @@ -647,16 +617,6 @@ function IndicateReadiness() { ); } -function MarkAsReady() { - return ( - <> - - - - - ); -} - /** * Some selectors (like NodeTraversal) only re-runs when the data store is 'ready', and when nodes start being added * or removed, the store is marked as not ready. This component will mark the store as ready when all nodes are added, @@ -665,49 +625,27 @@ function MarkAsReady() { * This causes the node traversal selectors to re-run only when all nodes in a new repeating group row (and similar) * have been added. */ -function InnerMarkAsReady() { +function MarkAsReady() { + const store = Store.useStore(); const markReady = Store.useSelector((s) => s.markReady); const readiness = Store.useSelector((s) => s.readiness); const hiddenViaRulesRan = Store.useSelector((s) => s.hiddenViaRulesRan); const stagesFinished = GeneratorStages.useIsFinished(); - const hasUnsavedChanges = FD.useHasUnsavedChanges(); const registry = GeneratorInternal.useRegistry(); - const getCachedInitialValidations = useGetCachedInitialValidations(); // Even though the getAwaitingCommits() function works on refs in the GeneratorStages context, the effects of such // commits always changes the NodesContext. Thus our useSelector() re-runs and re-renders this components when // commits are done. const getAwaitingCommits = useGetAwaitingCommits(); - const savingOk = readiness === NodesReadiness.WaitingUntilLastSaveHasProcessed ? !hasUnsavedChanges : true; - const checkNodeStates = stagesFinished && savingOk && hiddenViaRulesRan && readiness !== NodesReadiness.Ready; + const checkNodeStates = stagesFinished && hiddenViaRulesRan && readiness !== NodesReadiness.Ready; const nodeStateReady = Store.useSelector((state) => { if (!checkNodeStates) { return false; } - const { cachedInitialValidations, isFetching } = getCachedInitialValidations(); - if (isFetching || cachedInitialValidations !== state.validationsProcessedLast.initial) { - generatorLog('logReadiness', `Initial validations are still being fetched, waiting...`); - return false; - } - - for (const nodeData of Object.values(state.nodeData)) { - const def = getComponentDef(nodeData.layout.type) as LayoutComponent; - const nodeReady = def.stateIsReady(nodeData); - const pluginsReady = def.pluginStateIsReady(nodeData, state); - if (!nodeReady || !pluginsReady) { - generatorLog( - 'logReadiness', - `Node ${nodeData.layout.id} is not ready yet because of ` + - `${nodeReady ? 'plugins' : pluginsReady ? 'node' : 'both node and plugins'}`, - ); - return false; - } - } - - return true; + return areAllNodesReady(state); }); const maybeReady = checkNodeStates && nodeStateReady; @@ -719,22 +657,40 @@ function InnerMarkAsReady() { // isn't ready. return setIdleInterval(registry, () => { const awaiting = getAwaitingCommits(); - if (awaiting === 0) { - markReady('idle and nothing to commit'); - return true; + if (awaiting > 0) { + generatorLog('logReadiness', `Not quite ready yet (waiting for ${awaiting} commits)`); + return false; } - generatorLog('logReadiness', `Not quite ready yet (waiting for ${awaiting} commits)`); - return false; + markReady('idle, nothing to commit'); + return true; }); } return () => undefined; - }, [maybeReady, getAwaitingCommits, markReady, registry]); + }, [maybeReady, getAwaitingCommits, markReady, registry, store]); return null; } +function areAllNodesReady(state: NodesContext) { + for (const nodeData of Object.values(state.nodeData)) { + const def = getComponentDef(nodeData.layout.type) as LayoutComponent; + const nodeReady = def.stateIsReady(nodeData); + const pluginsReady = def.pluginStateIsReady(nodeData, state); + if (!nodeReady || !pluginsReady) { + generatorLog( + 'logReadiness', + `Node ${nodeData.layout.id} is not ready yet because of ` + + `${nodeReady ? 'plugins' : pluginsReady ? 'node' : 'both node and plugins'}`, + ); + return false; + } + } + + return true; +} + const IDLE_COUNTDOWN = 3; /** @@ -775,19 +731,6 @@ function setIdleInterval(registry: MutableRefObject, fn: () => boolean return () => (id === undefined ? undefined : cancel(id)); } -function RegisterOnSaveFinished() { - const setOnSaveFinished = FD.useSetOnSaveFinished(); - const ourOnSaveFinished = Store.useSelector((s) => s.onSaveFinished); - - useEffect(() => { - setOnSaveFinished((result) => { - ourOnSaveFinished(result); - }); - }, [ourOnSaveFinished, setOnSaveFinished]); - - return null; -} - function BlockUntilLoaded({ children }: PropsWithChildren) { const hasBeenReady = useRef(false); const ready = Store.useSelector((state) => { @@ -1147,29 +1090,17 @@ export const NodesInternal = { const store = Store.useLaxStore(); const waitForState = useWaitForState(store); const waitForCommits = Store.useSelector((s) => s.waitForCommits); - return useCallback( - async (lastValidations?: ValidationsProcessedLast) => { - await waitForState((state) => { - if (state === ContextNotProvided) { - return true; - } - const initialIsLatest = - lastValidations?.initial === undefined || - lastValidations.initial === state.validationsProcessedLast.initial; - const incrementalIsLatest = - lastValidations?.incremental === undefined || - lastValidations.incremental === state.validationsProcessedLast.incremental; - if (!incrementalIsLatest || !initialIsLatest) { - return false; - } - return state.readiness === NodesReadiness.Ready && state.hiddenViaRulesRan; - }); - if (waitForCommits) { - await waitForCommits(); + return useCallback(async () => { + await waitForState((state) => { + if (state === ContextNotProvided) { + return true; } - }, - [waitForState, waitForCommits], - ); + return state.readiness === NodesReadiness.Ready && state.hiddenViaRulesRan; + }); + if (waitForCommits) { + await waitForCommits(); + } + }, [waitForState, waitForCommits]); }, useMarkNotReady() { const markReady = Store.useSelector((s) => s.markReady); diff --git a/src/utils/layout/generator/GeneratorDataSources.tsx b/src/utils/layout/generator/GeneratorDataSources.tsx index aaae8f43d1..7c00edf8db 100644 --- a/src/utils/layout/generator/GeneratorDataSources.tsx +++ b/src/utils/layout/generator/GeneratorDataSources.tsx @@ -29,10 +29,7 @@ const { Provider, hooks } = createHookContext({ useReadableDataTypes: () => DataModels.useReadableDataTypes(), useExternalApis: () => useExternalApis(useApplicationMetadata().externalApiIds ?? []), useIsForcedVisibleByDevTools: () => useDevToolsStore((state) => state.isOpen && state.hiddenComponents !== 'hide'), - useGetDataElementIdForDataType: () => DataModels.useGetDataElementIdForDataType(), - useValidationsProcessedLast: () => Validation.useProcessedLast(), - useCommitWhenFinished: () => useCommitWhenFinished(), }); @@ -42,10 +39,7 @@ export const GeneratorData = { useValidationDataSources, useDefaultDataType: hooks.useDefaultDataType, useIsForcedVisibleByDevTools: hooks.useIsForcedVisibleByDevTools, - useGetDataElementIdForDataType: hooks.useGetDataElementIdForDataType, - useValidationsProcessedLast: hooks.useValidationsProcessedLast, - useCommitWhenFinished: hooks.useCommitWhenFinished, }; diff --git a/src/utils/layout/generator/GeneratorStages.tsx b/src/utils/layout/generator/GeneratorStages.tsx index 43643ad3f6..dd7d2b89c7 100644 --- a/src/utils/layout/generator/GeneratorStages.tsx +++ b/src/utils/layout/generator/GeneratorStages.tsx @@ -53,7 +53,9 @@ export type Registry = { toCommit: RegistryCommitQueues; toCommitCount: number; commitTimeout: ReturnType | null; - validations: ValidationsProcessedLast; + validationsProcessed: { + [nodeId: string]: ValidationsProcessedLast; + }; }; /** @@ -238,10 +240,7 @@ export function useRegistry() { setPageProps: [], }, commitTimeout: null, - validations: { - initial: undefined, - incremental: undefined, - }, + validationsProcessed: {}, }); } diff --git a/src/utils/layout/generator/NodeRepeatingChildren.tsx b/src/utils/layout/generator/NodeRepeatingChildren.tsx index 8745b4b0dc..2068add44f 100644 --- a/src/utils/layout/generator/NodeRepeatingChildren.tsx +++ b/src/utils/layout/generator/NodeRepeatingChildren.tsx @@ -150,8 +150,7 @@ interface ResolveRowProps { function ResolveRowExpressions({ plugin }: ResolveRowProps) { const node = GeneratorInternal.useParent() as LayoutNode; const rowIndex = GeneratorInternal.useRowIndex()!; - const nodeChildren = useNodeDirectChildren(node, rowIndex); - const firstChild = nodeChildren ? nodeChildren[0] : undefined; + const firstChild = useNodeDirectChildren(node, rowIndex).at(0); const internal = NodesInternal.useNodeData( node, diff --git a/src/utils/layout/plugins/RepeatingChildrenPlugin.tsx b/src/utils/layout/plugins/RepeatingChildrenPlugin.tsx index 1c60ebe1da..3291ec61bf 100644 --- a/src/utils/layout/plugins/RepeatingChildrenPlugin.tsx +++ b/src/utils/layout/plugins/RepeatingChildrenPlugin.tsx @@ -218,24 +218,21 @@ export class RepeatingChildrenPlugin>, restriction?: TraversalRestriction): string[] { - const out: string[] = []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const rows = (state.item as any)[this.settings.internalProp] as Row[]; + const rows = (state.item as any)[this.settings.internalProp] as (Row | undefined)[]; if (!rows) { - return out; + return emptyArray; } - for (const row of rows) { - if (!row || (restriction !== undefined && row.index !== restriction)) { - continue; - } - - for (const child of row.itemIds || []) { - child && out.push(child); - } + if (restriction !== undefined) { + return rows[restriction]?.itemIds ?? emptyArray; } + const out: string[] = []; + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + row && out.push(...row.itemIds); + } return out; } @@ -255,3 +252,5 @@ export class RepeatingChildrenPlugin row && row.uuid !== undefined && row.itemIds !== undefined) ?? false; } } + +const emptyArray = []; diff --git a/src/utils/layout/plugins/RepeatingChildrenStorePlugin.tsx b/src/utils/layout/plugins/RepeatingChildrenStorePlugin.tsx index 75096f6ff7..97eafc431a 100644 --- a/src/utils/layout/plugins/RepeatingChildrenStorePlugin.tsx +++ b/src/utils/layout/plugins/RepeatingChildrenStorePlugin.tsx @@ -36,6 +36,10 @@ export class RepeatingChildrenStorePlugin extends NodeDataPlugin { let changes = false; const nodeData = { ...state.nodeData }; + const newPartialItems: { + [nodeId: string]: { [internalProp: string]: (RepChildrenRow | undefined)[] | undefined } | undefined; + } = {}; + for (const { node, rowIndex, plugin, extras } of requests) { if (typeof extras !== 'object' || !extras) { throw new Error('Extras must be an object'); @@ -43,22 +47,30 @@ export class RepeatingChildrenStorePlugin extends NodeDataPlugin