From e5c75273c671240c6826a3e6a1699e30886a5358 Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Tue, 20 Aug 2024 15:27:22 +0200 Subject: [PATCH] Performance: Hierarchy generator rewrite (#2244) * Revert "Experimenting with flattening the node storage in order to increase throughput when adding/removing nodes" This reverts commit d2ced73701a53f3e49e3178da8a3e31439ca5ae1. * The best part from the last (reverted) commit: Removing the `row` from add/remove nodes functions * Bundling up calls to addNode/setNodeProp/setRowExtras in order to reduce the impact of setState() with immer. Calling lots of setState() makes React have to decide what to re-render, so reducing this as much as possible speeds up things quite a bit. Now 250 rows in a repeating group is sort-of feasible (but takes ~5 seconds to generate, which was the time it initially took to just generate the 'group' form in the beginning). * Reverting the revert - adding back the attempt to flatten the store. This looks to be a better solution that also works with optional immer (I dropped immer for the hot paths). * Making it possible to restart the GeneratorStages when adding new components * Storing data about repeating group rows inside the item itself, allowing partial item updates (i.e. making it possible for evalExpressions() to maintain one part and for RepeatingChildrenPlugin to maintain the 'rows' state in the item object * Got a little carried away with renaming before committing last time - fixing it all up * Simplifying * Attempting to create some hooks that only select data when state store is ready * Fixing node removal * Filtering validations at the source * Improving the APIs in ValidationStorePlugin to limit the need to run selectValidations() in the result * Fixing non-updating validation state * Fixing minor issues that caused errors in frontend-test. When the 'pets' repeating group was hidden (because no pets are shown), the node context is not ready yet when the pets group started rendering (it was just starting to generate rows/nodes). So even if we're not ready yet, selecting node data should still do its job when the hook is running for the first time. * Fixing problems causing the pets group to crash when resetting the rows to 0 * Fixes after merge from main * Rewriting to hooks so that we can useNodeItem() instead of node.item * Moving the isValid state to inside components, adding validation filter for the Input component. This makes GenericComponent not have to re-render whenever validations change. * Using memoSelector to avoid large re-rendering of repeating groups when row state doesn't really change * Disabling debugging again * Re-introducing addRemoveCounter, as it optimizes re-rendering of NodeTraversal (which I forgot about) to only run when nodes have been added/removed. * Quick and dirty TabsPlugin based on CardsPlugin * Applying optimizations, useNode, etc * Replacing direct usages of node.item * Fixing an issue that caused the pets group to show validations for all rows, not just the relevant ones * Removing pickDataStorePath() and ignoreNodePathNotFound() * Removing the unused pickChild() function * Lots of changes: - Removing NodeRef, there is no need for this as long as BaseLayoutNode is just a reference to the internal state/data - Simplifying all the plugins, storing all the state for children in 'item' instead of inside the node-level state - Removing removeChild(), as there's no need to remove nodes (only to remove rows). If Studio removes a node, we'll regenerate them all anyway. - Adding data to child claims, so that plugins can more effectively set state in addChild(), and making it possible to have multiple child-bearing plugins in the future (as needed in RepeatingGroup, with 1x RepeatingChildrenPlugin and 2x GridRowsPlugin). * Removing the path property for BaseLayoutNode, as it's not needed anymore when state is not stored in a hierarchy * Implementing config and optionality for GridRowsPlugin (adding it to RepeatingGroup) and speeding up type-checking * Fixes after introducing support for multiple child-bearing components - filtering children for each of them * Fixes a crash when RepeatingGroup has no rows * Fixes for Grid rows in repeating groups * Low hanging fruit for unit tests - skipping node prop validation, fixing a bug where node generation would get stuck on options if no option-components exists * Implementing LikertRowsPlugin, which generates a statically defined LikertItem per row inside it * Fixing Tabs, along with some issues with laxness that caused frontend-test to crash when proceeding through the app * Implementing validation for expressions again. Slimming down prettyErrors and logging only to our devtools. * Implementing some more TODOs (mostly skipping layout pre-processing, as we support validation for expressions now) * Fixing more TODOs * Adding a couple more queues * Using public variables instead of getId()/getBaseId()/getType() etc on node objects * Re-implementing parts of #2186 after merge from main * Removing remaining uses of node.item (still some left, but very few) * Whoops, I found a bug * Whoops, I broke nested repeating groups by thinking mutateComponentId() wasn't a recursive mutator. Easy fix! * Re-implementing conditional rendering by using react components. This will now make sure to only run rules that are needed each time, instead of running them all when one of them needs to change. * Fixing a direct usage of node.item * Started getting shared-functions.test.tsx to work (split from shared.test.ts). Trying to fix some import order bugs, and I found that layout.d.ts was very broken. Renaming it to .ts shows all the problems that the typescript type-checker just ignored previously, leading me to having to make things like renderInTabs required. * Cleaning up d.ts files that were broken * First steps in fixing problems after merge from main * Fixes after merging from main (Summary2) * Trying to fix GridSummary properly * Fixing the broken 'summary overrides' in Summary2. In GroupSummary it was assumed the overrides included _all overrides_, but all other places assumed the overrides were specific to them. * Fixes for Summary2 after merge from main * Fixing a few things that crashed when testing out ttd/component-library * Delaying using import so that nodeConstructor works again (import order caused this to become undefined) * Fixing the basic workings of shared-functions.test.tsx * Fixes for running shared-functions.test.tsx * Fixes for component lookup expression tests * Fixes for running shared-functions.test.tsx * Fixes for attachments * Fixes to make sure shared-functions.test.tsx runs and reports about errors when they happen, not after * Fixing shared test: The 'ansatte' component does not exist with rowIndices [1,0], so I probably meant 'alder' or something like that * Fixing shared test: linkToPage crashes when using Task_3, because that task does not exist in applicationmetadata (we're using our default mocks because there are none here). This test should probably mock a lot more to work on the backend. * Fixing the linkToComponent so that it actually looks up the target component and page (and making a shared test for this make sense again). Removing summary tests, as these are no longer container components. * Low hanging test fruit * Low hanging test fruit * Fixing broken config in getMultiPageGroupMock.ts * Reverting back to useNavigate() instead of AppRouter.navigate(), as the latter does not seem to work well in unit tests * This test does not make sense - hidden: undefined means it's not hidden * More test fixes * The layout preprocessor does not exist in this way anymore, and I don't think the backend ever used these tests. No need to try to replicate them. * Rewriting runner for expression validation shared tests * Fixing "scalar" validation for expressions. Expressions are only invalid if they are expressions (i.e. arrays) * Re-implementing shared-context.test.tsx * Re-implementing all.test.tsx (not quite done yet) * Many smaller fixes to all.test.tsx - errors are now properly logged * Simulating a data model that always has one row in every repeating group (and Likert) * Fixing the other all-apps-tests (removing the one for layout validation, as we now do that in the hierarchy generator) * Hopefully fixing minor stuff after merge from main * Reverting to the old code in HiddenComponentsProvider. Turns out that for rules to work properly they cannot run individually, and my attempt to optimize them actually caused things to break in ttd/frontend-test. * Cleaning up a lot of stuff in Summary. Preparing for making it possible to render summaries without a summaryNode (from automatic PDF pages), simplifying the code to avoid passing in selectors (WIP), and getting rid of re-definitions and duplicates of SummaryRendererProps. * Rendering legacy PDFs using the method in PdfView2, and making it look more similar to the PdfView(1) version. * Fixes after merge from main * Fixing some tests * Fixing the functionality for changing the layout via DevTools, Cypress ++ * Fixing typescript errors in cypress * Fixing number formatting, which injected 'undefined' some places, which overwrote derived formatting from Intl * Importing a test in another means _running_ a test when running another. Fixing import in cypress tests. * Trying to enable adding nodes even though node generation is already running (by scheduling a new run right after the current one). This works sometimes, but also crashes sometimes. I think I'll need to move the runNum somewhere more central, to make sure no generator stages starts running before the node has been added properly. * Making sure new nodes (or new rows) are not being added to the node generator in-flight, but waits until the node generator is ready for it * Fixes for pet-group row sorting tests. No need to remove nodes unless they are part of a row ++. * Giving up on fixing the silly logic in SummaryComponent.tsx. Making it painfully visible instead. * Thank goodness for tests! * Fixing returnToView and heavy useNavigate() issues * Fixing broken unloading of Likert components * Fixing broken reference to page data * Fixing some broken group deep validation + error navigation * Implementing pretty much the rest of the TODOs (specifically the implicit hiding functionality) * Removing TODO - I'm not sure this was really needed * Re-fixing the broken onGroupCloseValidation. I struggled too long to figure out that I missed adding nodeValidationSelector as a dependency * Recursively removing children in rows * Marking nodes as not ready when there are outstanding commits, and waiting for nodes to become ready in 'wait for validation' * Fixes for validation, uncommitted requests in GeneratorStages * Making sure we detect form saving early enough * Using a proper setter instead of a ref (that does not work with immer, as it freezes the object) * Removing the 'removePage' functionality. There is no need for this, just like there is no real need for removing nodes. * A better way to reset the state when calling changeLayout() in tests. This way 'await validation()' is not broken in the process * Minor test fixes * Adding a comment in the repeating group, so that it doesn't fail when trying to close it * Fixing depth in mapping mutation, inlining function in useSourceOptions * More test fixes, trying to make all-process-steps more stable * Checking that the input is not disabled should produce more stable results - it will wait until the attachment has been uploaded before clicking in dsSelect() * This should probably produce the same result as the last commit * The code here did not make sense to me originally, but now I remember the intention behind it. Making that code work again, and documenting it in a comment. * One test failed at some point because the validating function was not set (in custom confirm). Making sure we don't call undefined. * Minor test fixes, making sure things are ready before interacting, etc * Multiple fixes for auto-save-behavior.ts * Removing cy.startAppInstance(), as it just loads the app twice (and flashes an error for a short while) * Fixing the group test in pdf.ts * Fixing issues with the savingJustFinishedRef that causes the state to sometimes lock up in a non-ready state * Making numberFormatClear more robust * Fixing clearing of stale options * Fixing navigation test cases after I rewrote parts of linkToComponent * Catching errors on the end of a test-run (I saw this happen in navigation.ts, and I'm bothered we don't catch those). * It could be difficult to notice this functionality, so I'm logging it. * Fixing over-eager stale value removal (it removed values when fetching) * Opening row for editing after calling changeLayout() * Moving the validation filtering back. This breaks our internal validation system, so I re-opened the issue we have for this. * Re-implementing useAttachmentsMappedToFormData() inside AttachmentsStorePlugin. This makes the functionality a lot more stable, as we no longer rely on the FileUpload component being displayed while the upload is happening. * Allowing failure on end * Unseen groups now still run preselectedOptionIndex logic * Making sure attachment upload waits until upload is done before selecting in dropdown * Whoops, forgot dataModelBindings here * Making sure we set onCurrentOrPreviousPage only when there are errors * A couple more minor test fixes * Making sure pets arrays are copied and duplicated instead of re-used. Cypress seems to run this code multiple times, and not in the expected order, so mutating arrays seemed to make the test fail at least once. * Making the test less flaky by asserting read-only before attempting to click * Fetching form data re-used old values when rewriting the URL in this navigation.ts test, and later updates to the form data thus failed with a 409. I also removed prefetching of form data here, because it doesn't make sense (we keep a local copy of the form data, so prefetching and using potentially stale data means we might have overwritten the data in the past and are using an invalid model). * Fixes for stateless apps, type fixes * Making sure nodes in hidden rows are considered hidden (a test for the expression-validation-test app asserted this) * Fixing crash when groupExpressions were undefined * Instead of waitForNetworkIdle(), which seems to slow down a later reload() to a halt, intercepting and waiting for the actual upload * Test updates * Test fixes * Fixing duplicate IDs in shared tests, removing tests for duplicate IDs (these can no longer work, this causes a runtime hard error) * Fixing language tests in shared-functions * Fixing useDataModelBindingTranspose() so that it no longer needs node traversal, and thus works earlier * Adding expected warning for OpenByDefaultProvider.test. The way I read this test, this error should have been expected before as well, but at least now it's present in the log. * Extracting useResetScrollPosition() and making it more robust (by optionally waiting for an external element to be visible, and not scrolling until it's at least one pixel) * CodeQL found a scary bug! GridRows was imported, but also overwritten * Fixing tests that failed on tt02+github, but worked locally. One of them was actually fundamentally broken, as hiding the editButton should also not open the editContainer when adding a new row - but that limitation broke on my machine (because it's faster then the one on github?). When running on tt02+github, the edit container did not show up, which was the correct behaviour. Waiting to make sure the correct behaviour is consistent. * After I added the code to wait until the repeating group rows state was in sync, now suddenly the edit container won't open when the edit button has been hidden. This is really how it should have worked all along. Adjusting the tests to match, and making this functionality explicitly implemented, correctly detecting this case as the children having been hidden. * Making sure this validation test waits until the button is clickable and the validation is present * Using a better way to detect unsaved changes / wait for save * Fixing flakiness in group-pets.ts * Making this test less flaky * Making tests less flaky * I mistakenly committed this change, which was only supposed to be used locally * When you've provided a dedicated pdf layout file, you'll want those components rendered directly, not via PdfForPage (which will attempt to summarize the pdf layout). * Adding quirks for some apps with broken layouts, in order to make them work again after this rewrite (these quirks will stop working if layouts change just slightly) * More quirks, filtering out duplicate components automatically (using that logic to generate quirks code for duplicates that we want to keep) * Fixing flakiness in case the number-formatted field has been saved (and we get the default '0' back from the server) * Fixing shared function tests with duplicate component IDs (that I now removed) * Making sure validations and options exist before allowing clicks and displaying the error message to the user * Waiting a bit to ensure test pass * Fixing transposeSelector that was not passed into useLanguageWithForcedNodeSelector() and thus not available in expressions. This was caused by a forced typescript type that was outdated. * Fixes after testing ssb/ra0760-01 * More fixes to make the test less flaky. This time adding code to wait for commits to finish when awaiting validation (which will also wait until validations are stored in nodes) * Hopefully a flaky fix * Making sure to wait until ruleHandler has run once before marking nodes as ready, and making sure to not set preselectedOptionIndex for nodes that are already hidden. This makes sure we maintain some backwards compatibility, and ensures the 'next' button does not show up early in frontend-test (as I noticed in Percy) * Fixes for Cards, to make sure title/texts gets passed on * Making percy snapshot match, even though functionality now have changed * Flaky fix + making sure grid in summary is backwards compatible * Now that I changed the behaviour for preSelectedOptionIndex for hidden components, these tests failed. Reverting the changes. * Adding an assertion for what I observe to happen in this test * Hopefully fixing flakiness issues in validation.ts (which uses changeLayout heavily) * Fixes after merge from main * Removing TODOs * Fixes in formFiller, adding back force.. :-( Also, printing out details of what went wrong when data models does not match expectations * Making sure the validation test actually waits for layout changes to become effective * Using URLSearchParams instead * Adding back force for uploader * eslint --fix * No need to resolve this expression ourselves - the plugin does it for us * Reverting changes to .gitignore * Instead of waiting for save, wait for the element we want * Fixing a very rare problem in the all-process-steps.ts test. When two attachments were uploaded one after the other, the first upload (when the data model was returned back from tt02) overwrote the second one, and the attachment disappeared from the UI. * Fixing problems with EditWindowComponent rendering when it shouldn't, and not showing the spinner when it should. Removing the functionality to show the button and dropdown as disabled until they were ready * Fixing unit tests after I changed behaviour around readOnly for FileUpload * Removing unused file * Reducing the diff from main * Fixes after merge from main * Fixing getting isCompact from parent overrides * Type fix * Fixes after merge from main * Minor fixes after merge from main - some unit tests broke * Applying change from #2275 (originally made to PdfView.tsx, which is deleted in this branch) * Removing prop that was deleted in #2275 * Fixing minor issues from SonarCloud * Fixes after merge from main * Avoiding destructing object and leaving a rest object that's passed as a dependency in useLanguage(). This caused every re-render to re-generate the callbacks and trash later useMemo() optimizations. * Avoiding reading an entire repeating group (including child data) when generating source options. This caused useless re-renders and re-generations of source options. * Adding mock function for formDataRowsSelector() * Evaluating expression in forceShowInSummary * Fixing expression evaluation, moving property to only work for summarizable components * Removing TODO * Basing row generation solely off the row index, not the uuid. This means the rows and nodes will only be removed if the actual row is removed from the data model, but now re-ordering rows will just cause the same nodes as before to reference _other data_ (i.e. a different row UUID at the same index). This aims to fix the breaking group-pets.ts test (although something else went wrong here, and some nodes are not showing up when re-ordering, so I'll look into that as well). * Reverting accidental commit for debug.ts * Making row-restrictions index-only. This actually makes more sense than using uuids everywhere (kindof regret that now), as nodes and data model bindings are always index-based. * Removing UpdateNodeRow now that the property is no longer in use * Partial revert. The index property in the 'editingRow' was not always up-to-date when rows are sorted/re-ordered. Only storing the UUID for which row is being edited right now, and getting the index from that when we need to. * Fixes to Likert after row changes. Now rows will be indexes according to their actual index, meaning rows[0] might not work in Likert (as it can have start/stop filters) * Removing debug log * Fix for failing unit test * Using TS 5.5 type inference instead of casting types * Keeping track of setTimeout * Re-introducing automatic removeNode(). This also causes child-types to have to be `| undefined`, as it's difficult to remove without that. This fixes the problems where we would like to remove a row and all nodes inside of it. Removing an early row will now shift the content from later rows up automatically. * Fixes for undefined rows * Review feedback * Review feedback: Converting useHasUnsavedChangesRef to a function, removing extra call to isSavingNow() * Fixing bug with innerGrid after my merge attempt regressed it (the new test pointed that out nicely!) * Fixes after merge from main. Number component has a value with type 'number', not string * Fixes for Text component after merge from main * Fixes for failing test in hide-row-in-group (pickDirectChildren did not account for restrictions in non-repeating plugins) * Removing commented-out code and TODOs --------- Co-authored-by: Ole Martin Handeland --- src/__mocks__/getExpressionDataSourcesMock.ts | 33 + src/__mocks__/getHierarchyDataSourcesMock.ts | 21 - src/__mocks__/getMultiPageGroupMock.ts | 2 +- src/__mocks__/getProfileMock.ts | 12 - src/codegen/CG.ts | 36 - src/codegen/CodeGenerator.ts | 63 +- src/codegen/Common.ts | 131 +-- src/codegen/ComponentConfig.ts | 449 ++++++-- src/codegen/Config.ts | 45 + src/codegen/SerializableSetting.ts | 8 + src/codegen/dataTypes/GenerateArray.ts | 20 +- src/codegen/dataTypes/GenerateCommonImport.ts | 31 +- .../dataTypes/GenerateComponentLike.ts | 92 -- src/codegen/dataTypes/GenerateExpressionOr.ts | 47 - .../dataTypes/GenerateImportedSymbol.ts | 21 +- src/codegen/dataTypes/GenerateIntersection.ts | 8 - src/codegen/dataTypes/GenerateLinked.ts | 58 - src/codegen/dataTypes/GenerateObject.ts | 100 +- src/codegen/dataTypes/GenerateProperty.ts | 55 +- src/codegen/dataTypes/GenerateRaw.ts | 31 - .../dataTypes/GenerateTextResourceBinding.ts | 21 +- src/codegen/dataTypes/GenerateUnion.ts | 25 +- src/codegen/run.ts | 28 +- src/components/ErrorBoundary.tsx | 5 - src/components/altinnAppHeader.test.tsx | 6 +- src/components/form/Form.tsx | 104 +- src/components/form/LinkToPotentialNode.tsx | 6 +- src/components/form/LinkToPotentialPage.tsx | 11 +- src/components/form/RadioButton.tsx | 4 +- src/components/label/Label.tsx | 49 +- src/components/message/ErrorReport.tsx | 14 +- src/components/presentation/NavBar.tsx | 5 +- src/components/presentation/Presentation.tsx | 21 +- src/components/presentation/Progress.tsx | 4 +- src/components/wrappers/ProcessWrapper.tsx | 63 +- src/core/contexts/context.tsx | 6 +- src/core/contexts/zustandContext.tsx | 181 +-- src/core/loading/Loader.tsx | 48 +- src/core/loading/LoadingContext.tsx | 21 + src/core/queries/usePrefetchQuery.ts | 6 +- src/core/structures/ShallowArrayMap.test.ts | 79 ++ src/core/structures/ShallowArrayMap.ts | 136 +++ src/core/ui/RenderStart.tsx | 19 +- src/core/ui/useResetScrollPosition.ts | 33 + .../alertOnChange/AlertOnChangePlugin.tsx | 66 ++ .../DeleteWarningPopover.module.css | 0 .../alertOnChange}/DeleteWarningPopover.tsx | 2 +- .../alertOnChange}/useAlertOnChange.ts | 0 .../ApplicationMetadataProvider.tsx | 6 +- .../VersionErrorOrChildren.tsx | 6 +- .../applicationMetadata/minVersion.ts | 4 + src/features/applicationMetadata/types.ts | 4 - .../attachments/AttachmentsContext.tsx | 151 --- .../attachments/AttachmentsPlugin.tsx | 47 + .../attachments/AttachmentsStorePlugin.tsx | 554 +++++++++ .../attachments/StoreAttachmentsInNode.tsx | 131 +++ .../UpdateAttachmentsForCypress.tsx | 15 + src/features/attachments/hooks.ts | 49 + src/features/attachments/index.ts | 31 +- src/features/attachments/sortAttachments.ts | 8 + .../useAttachmentDeletionInRepGroups.ts | 39 +- .../useAttachmentsMappedToFormData.tsx | 104 -- src/features/attachments/utils/mapping.ts | 150 --- src/features/attachments/utils/postUpload.ts | 290 ----- src/features/attachments/utils/preUpload.ts | 199 ---- .../attachments/utils/sorting.test.ts | 36 - src/features/attachments/utils/sorting.ts | 33 - .../customValidation/customValidationUtils.ts | 31 +- src/features/dataLists/index.d.ts | 6 - src/features/dataLists/index.ts | 14 + .../datamodel/dataModelLookups.test.ts | 75 -- .../DevHiddenFunctionality.tsx | 52 +- .../DevNavigationButtons.tsx | 17 +- .../ExpressionPlayground.tsx | 90 +- .../LayoutInspector/LayoutInspector.tsx | 9 +- .../LayoutInspector/LayoutInspectorItem.tsx | 4 +- .../NodeInspector/DefaultNodeInspector.tsx | 13 +- .../NodeInspector/NodeHierarchy.tsx | 128 ++- .../NodeInspector/NodeInspector.tsx | 16 +- .../NodeInspector/NodeInspectorDataField.tsx | 28 +- .../NodeInspectorTextResourceBindings.tsx | 11 +- .../NodeInspector/ValidationInspector.tsx | 107 +- .../devtools/hooks/useComponentRefs.ts | 5 +- .../devtools/layoutValidation/all.test.tsx | 98 -- .../devtools/layoutValidation/types.ts | 13 +- .../useLayoutSchemaValidation.ts | 53 - .../layoutValidation/useLayoutValidation.tsx | 174 +-- .../devtools/utils/layoutSchemaValidation.ts | 56 +- src/features/displayData/displayData.test.ts | 22 + src/features/displayData/index.ts | 12 +- src/features/displayData/useDisplayData.ts | 32 +- src/features/entrypoint/Entrypoint.tsx | 31 +- src/features/expressions/ExprContext.ts | 67 +- src/features/expressions/errors.ts | 14 +- src/features/expressions/index.test.ts | 68 +- src/features/expressions/index.ts | 418 ++----- src/features/expressions/prettyErrors.ts | 77 +- .../expressions/shared-context.test.tsx | 129 +++ .../expressions/shared-functions.test.tsx | 187 +++ .../component/across-pages-hidden.json | 16 +- .../functions/component/across-pages.json | 16 +- .../functions/component/duplicate-id-1.json | 129 --- .../functions/component/duplicate-id-2.json | 129 --- .../component/hide-group-component.json | 2 +- ...on-component.json => accordion-group.json} | 0 .../functions/displayValue/accordion.json | 26 + .../functions/displayValue/summary.json | 38 - .../functions/displayValue/summary2.json | 41 - .../linkToComponent/linkToComponent.json | 41 +- .../functions/linkToPage/linkToPage.json | 4 +- .../layout-preprocessor/failures.json | 162 --- .../layout-preprocessor/successful.json | 159 --- src/features/expressions/shared.test.ts | 221 ---- src/features/expressions/shared.ts | 35 +- src/features/expressions/types.ts | 63 +- src/features/expressions/validation.test.ts | 82 +- src/features/expressions/validation.ts | 173 ++- src/features/form/FormContext.tsx | 44 +- .../dynamics/HiddenComponentsProvider.tsx | 133 +++ .../dynamics/conditionalRenderingSagas.ts | 60 - src/features/form/dynamics/index.ts | 4 +- src/features/form/layout/LayoutsContext.tsx | 79 +- src/features/form/layout/NavigateToNode.tsx | 49 +- .../form/layout/PageNavigationContext.tsx | 64 +- src/features/form/layout/cleanLayout.ts | 13 +- src/features/form/layout/quirks.ts | 822 +++++++++++++ src/features/form/rules/index.d.ts | 15 - src/features/formData/FormData.test.tsx | 9 +- src/features/formData/FormDataReaders.tsx | 4 +- src/features/formData/FormDataWrite.tsx | 147 ++- .../formData/FormDataWriteStateMachine.tsx | 20 +- src/features/formData/InitialFormData.tsx | 4 +- .../formData/jsonPatch/createPatch.test.ts | 9 +- .../formData/jsonPatch/createPatch.ts | 9 +- src/features/formData/useFormDataQuery.tsx | 3 +- src/features/instance/InstanceContext.tsx | 17 +- src/features/instance/ProcessContext.tsx | 61 +- .../instance/ProcessNavigationContext.tsx | 2 +- src/features/instance/useProcessTaskId.ts | 4 +- .../instantiate/InstantiationContext.tsx | 8 +- .../instantiate/containers/PartySelection.tsx | 4 +- .../selection/InstanceSelection.tsx | 5 +- .../language/LangDataSourcesProvider.tsx | 12 +- .../textResources/{index.d.ts => index.ts} | 4 - src/features/language/useLanguage.ts | 34 +- src/features/logging.ts | 3 +- src/features/options/OptionsPlugin.tsx | 65 ++ src/features/options/OptionsStorePlugin.tsx | 56 + src/features/options/StoreOptionsInNode.tsx | 47 + src/features/options/useAllOptions.test.tsx | 75 -- src/features/options/useAllOptions.tsx | 244 ---- src/features/options/useGetOptions.test.tsx | 10 +- src/features/options/useGetOptions.ts | 334 +++--- src/features/options/useNodeOptions.ts | 8 + src/features/orgs/index.d.ts | 9 - src/features/party/index.d.ts | 5 - src/features/pdf/PDFView.module.css | 5 +- src/features/pdf/PDFView.tsx | 111 -- src/features/pdf/PDFWrapper.tsx | 12 +- src/features/pdf/PdfView2.tsx | 219 ++-- src/features/profile/index.d.ts | 9 - src/features/receipt/ReceiptContainer.tsx | 4 +- src/features/routing/AppRoutingContext.tsx | 130 +++ src/features/toggles.ts | 2 +- .../validation/ComponentValidations.tsx | 56 +- .../validation/StoreValidationsInNode.tsx | 58 + src/features/validation/ValidationPlugin.tsx | 53 + .../validation/ValidationStorePlugin.tsx | 154 +++ .../validation/callbacks/onAttachmentSave.ts | 12 +- .../callbacks/onFormSubmitValidation.ts | 59 +- .../callbacks/onGroupCloseValidation.ts | 47 +- .../callbacks/onPageNavigationValidation.ts | 48 +- .../useExpressionValidation.test.ts | 109 -- .../useExpressionValidation.test.tsx | 108 ++ .../useExpressionValidation.ts | 32 +- src/features/validation/index.ts | 73 +- .../nodeValidation/useNodeValidation.ts | 136 ++- .../selectors/attachmentValidations.ts | 40 +- .../selectors/bindingValidationsForNode.ts | 53 +- .../selectors/componentValidationsForNode.ts | 26 +- .../selectors/deepValidationsForNode.ts | 38 +- src/features/validation/selectors/isValid.ts | 8 + .../validation/selectors/taskErrors.ts | 69 +- .../selectors/unifiedValidationsForNode.ts | 15 +- src/features/validation/utils.ts | 137 +-- src/features/validation/validationContext.tsx | 235 ++-- .../validation/visibility/useVisibility.ts | 170 --- .../validation/visibility/visibilityUtils.ts | 238 ---- src/{global.d.ts => global.ts} | 9 +- src/hooks/delayedSelectors.test.tsx | 225 ++++ src/hooks/delayedSelectors.ts | 215 ++++ src/hooks/useInstanceIdParams.ts | 14 - src/hooks/useIsPdf.ts | 5 +- src/hooks/useMapToReactNumberConfig.ts | 21 +- src/hooks/useNavigatePage.ts | 248 ++-- src/hooks/usePdfPage.ts | 109 -- src/hooks/useSourceOptions.ts | 162 ++- src/index.tsx | 45 +- src/language/texts/en.ts | 2 +- src/language/texts/nb.ts | 2 +- src/language/texts/nn.ts | 2 +- src/layout/Accordion/Accordion.tsx | 11 +- src/layout/Accordion/SummaryAccordion.tsx | 22 +- src/layout/Accordion/config.ts | 23 +- src/layout/Accordion/hierarchy.ts | 73 -- src/layout/Accordion/index.tsx | 8 - src/layout/AccordionGroup/AccordionGroup.tsx | 31 +- .../AccordionGroup/AccordionGroupContext.tsx | 14 + .../SummaryAccordionGroupComponent.tsx | 40 +- src/layout/AccordionGroup/config.ts | 22 +- src/layout/AccordionGroup/hierarchy.ts | 73 -- src/layout/AccordionGroup/index.tsx | 21 - .../ActionButton/ActionButtonComponent.tsx | 3 +- src/layout/ActionButton/config.ts | 4 + src/layout/Address/AddressComponent.test.tsx | 6 +- src/layout/Address/AddressComponent.tsx | 59 +- .../Address/AddressSummary/AddressSummary.tsx | 13 +- src/layout/Address/config.ts | 10 +- src/layout/Address/index.tsx | 37 +- src/layout/Alert/Alert.test.tsx | 19 +- src/layout/Alert/Alert.tsx | 7 +- src/layout/Alert/config.ts | 4 + .../AttachmentListComponent.test.tsx | 18 +- .../AttachmentListComponent.tsx | 8 +- src/layout/AttachmentList/config.ts | 4 + src/layout/Audio/Audio.tsx | 3 +- src/layout/Audio/config.ts | 4 + src/layout/Button/ButtonComponent.tsx | 16 +- src/layout/Button/config.ts | 16 +- .../ButtonGroup/ButtonGroupComponent.tsx | 15 +- src/layout/ButtonGroup/config.ts | 20 +- src/layout/ButtonGroup/hierarchy.ts | 69 -- src/layout/ButtonGroup/index.tsx | 17 - src/layout/Cards/Cards.tsx | 21 +- src/layout/Cards/CardsPlugin.tsx | 181 +++ src/layout/Cards/CardsSummary.tsx | 35 + src/layout/Cards/config.ts | 29 +- src/layout/Cards/hierarchy.ts | 123 -- src/layout/Cards/index.tsx | 42 +- .../CheckboxesContainerComponent.test.tsx | 2 +- .../CheckboxesContainerComponent.tsx | 24 +- src/layout/Checkboxes/CheckboxesSummary.tsx | 37 + .../Checkboxes/MultipleChoiceSummary.test.tsx | 2 +- .../Checkboxes/MultipleChoiceSummary.tsx | 8 +- src/layout/Checkboxes/WrappedCheckbox.tsx | 4 +- .../MultipleChoiceSummary.test.tsx.snap | 10 +- src/layout/Checkboxes/config.ts | 29 +- src/layout/Checkboxes/index.tsx | 57 +- src/layout/ComponentStructureWrapper.tsx | 34 +- src/layout/Custom/CustomWebComponent.tsx | 9 +- src/layout/Custom/config.ts | 4 + src/layout/Custom/index.tsx | 4 +- .../CustomButton/CustomButtonComponent.tsx | 97 +- src/layout/CustomButton/config.ts | 4 + src/layout/CustomButton/index.tsx | 9 - src/layout/Datepicker/DatepickerComponent.tsx | 9 +- src/layout/Datepicker/config.ts | 4 + src/layout/Datepicker/index.tsx | 45 +- .../Dropdown/DropdownComponent.test.tsx | 8 +- src/layout/Dropdown/DropdownComponent.tsx | 21 +- src/layout/Dropdown/DropdownSummary.tsx | 7 +- src/layout/Dropdown/config.ts | 23 +- src/layout/Dropdown/index.tsx | 23 +- .../AttachmentSummaryComponent2.tsx | 22 +- .../FileUpload/DropZone/DropzoneComponent.tsx | 5 +- .../FileUpload/FileUploadComponent.test.tsx | 29 +- src/layout/FileUpload/FileUploadComponent.tsx | 119 +- .../FileUpload/FileUploadTable/FileTable.tsx | 18 +- .../FileUploadTable/FileTableButtons.tsx | 19 +- .../FileUploadTable/FileTableRow.tsx | 2 +- .../Summary/AttachmentSummaryComponent.tsx | 10 +- src/layout/FileUpload/Summary/summary.ts | 2 +- src/layout/FileUpload/config.ts | 25 +- src/layout/FileUpload/index.tsx | 29 +- .../FileUploadWithTag/EditWindowComponent.tsx | 158 +-- src/layout/FileUploadWithTag/config.ts | 9 +- src/layout/FileUploadWithTag/index.tsx | 43 +- src/layout/FormComponentContext.tsx | 1 + src/layout/GenericComponent.test.tsx | 4 +- src/layout/GenericComponent.tsx | 115 +- src/layout/Grid/GridComponent.tsx | 89 +- src/layout/Grid/GridRowsPlugin.tsx | 237 ++++ src/layout/Grid/GridSummary.tsx | 361 +++--- src/layout/Grid/GridSummaryComponent.tsx | 6 +- src/layout/Grid/config.ts | 13 +- src/layout/Grid/hierarchy.ts | 79 -- src/layout/Grid/index.tsx | 30 +- src/layout/Grid/tools.ts | 56 +- src/layout/Grid/types.ts | 11 +- src/layout/Group/GroupComponent.tsx | 22 +- src/layout/Group/GroupSummary.tsx | 47 +- .../Group/SummaryGroupComponent.test.tsx | 30 +- src/layout/Group/SummaryGroupComponent.tsx | 137 ++- .../SummaryGroupComponent.test.tsx.snap | 10 +- src/layout/Group/config.ts | 23 +- src/layout/Group/hierarchy.ts | 62 - src/layout/Group/index.tsx | 79 +- src/layout/Group/types.d.ts | 5 - src/layout/Header/HeaderComponent.tsx | 3 +- src/layout/Header/config.ts | 4 + src/layout/Header/index.tsx | 6 +- src/layout/IFrame/IFrameComponent.tsx | 3 +- src/layout/IFrame/config.ts | 4 + src/layout/Image/ImageComponent.tsx | 3 +- src/layout/Image/config.ts | 4 + src/layout/Input/InputComponent.test.tsx | 6 +- src/layout/Input/InputComponent.tsx | 17 +- src/layout/Input/InputSummary.tsx | 5 +- src/layout/Input/config.ts | 4 + src/layout/Input/formatting.ts | 38 + src/layout/Input/index.tsx | 43 +- .../InstanceInformationComponent.tsx | 10 +- src/layout/InstanceInformation/config.ts | 4 + .../InstantiationButton.tsx | 2 +- .../InstantiationButtonComponent.tsx | 6 +- src/layout/InstantiationButton/config.ts | 4 + src/layout/LayoutComponent.tsx | 273 +++-- .../Generator/LikertGeneratorChildren.tsx | 144 +++ .../Likert/Generator/LikertRowsPlugin.tsx | 122 ++ src/layout/Likert/LikertComponent.tsx | 72 +- src/layout/Likert/LikertTestUtils.tsx | 4 +- .../Summary/LargeLikertSummaryContainer.tsx | 25 +- src/layout/Likert/Summary/LikertSummary.tsx | 88 +- src/layout/Likert/config.ts | 31 +- src/layout/Likert/hierarchy.ts | 165 --- src/layout/Likert/index.tsx | 30 +- src/layout/Likert/types.d.ts | 5 - src/layout/LikertItem/LikertItemComponent.tsx | 21 +- src/layout/LikertItem/config.ts | 18 +- src/layout/LikertItem/index.tsx | 23 +- src/layout/Link/LinkComponent.tsx | 3 +- src/layout/Link/config.ts | 4 + src/layout/List/ListComponent.test.tsx | 4 +- src/layout/List/ListComponent.tsx | 10 +- src/layout/List/config.ts | 7 +- src/layout/List/index.tsx | 36 +- src/layout/Map/MapComponent.test.tsx | 26 - src/layout/Map/MapComponent.tsx | 9 +- src/layout/Map/MapComponentSummary.tsx | 3 +- src/layout/Map/config.ts | 4 + src/layout/Map/index.tsx | 8 +- .../MultipleSelectComponent.test.tsx | 12 +- .../MultipleSelectComponent.tsx | 21 +- .../MultipleSelect/MultipleSelectSummary.tsx | 34 + src/layout/MultipleSelect/config.ts | 23 +- src/layout/MultipleSelect/index.tsx | 57 +- .../NavigationBar/NavigationBarComponent.tsx | 11 +- src/layout/NavigationBar/config.ts | 4 + .../NavigationButtonsComponent.tsx | 44 +- src/layout/NavigationButtons/config.ts | 4 + src/layout/Number/NumberComponent.tsx | 14 +- src/layout/Number/config.ts | 4 + src/layout/Number/index.tsx | 22 +- src/layout/Panel/PanelComponent.tsx | 16 +- src/layout/Panel/config.ts | 4 + src/layout/Paragraph/ParagraphComponent.tsx | 3 +- src/layout/Paragraph/config.ts | 4 + src/layout/Payment/PaymentComponent.tsx | 11 +- .../Payment/SummaryPaymentComponent.tsx | 16 +- src/layout/Payment/config.ts | 5 + .../PaymentDetailsComponent.tsx | 5 +- src/layout/PaymentDetails/config.ts | 5 + .../PrintButton/PrintButtonComponent.tsx | 3 +- src/layout/PrintButton/config.ts | 4 + .../RadioButtons/ControlledRadioGroup.tsx | 19 +- .../RadioButtons/RadioButtonsSummary.tsx | 7 +- src/layout/RadioButtons/config.ts | 30 +- src/layout/RadioButtons/index.tsx | 24 +- src/layout/RadioButtons/radioButtonsUtils.ts | 6 +- .../OpenByDefaultProvider.test.tsx | 18 +- .../RepeatingGroup/OpenByDefaultProvider.tsx | 43 +- .../RepeatingGroupContainer.test.tsx | 13 +- .../RepeatingGroupContainer.tsx | 39 +- .../RepeatingGroup/RepeatingGroupContext.tsx | 370 +++--- .../RepeatingGroupEditContainer.test.tsx | 10 +- .../RepeatingGroupEditContext.tsx | 44 +- .../RepeatingGroupFocusContext.tsx | 88 +- .../RepeatingGroupPagination.tsx | 119 +- .../RepeatingGroupTable.test.tsx | 25 +- .../RepeatingGroup/RepeatingGroupTable.tsx | 120 +- .../RepeatingGroup/RepeatingGroupTableRow.tsx | 260 +++-- .../RepeatingGroupTableTitle.tsx | 28 +- .../RepeatingGroupsEditContainer.tsx | 96 +- .../Summary/LargeGroupSummaryContainer.tsx | 28 +- .../Summary/SummaryRepeatingGroup.test.tsx | 30 +- .../Summary/SummaryRepeatingGroup.tsx | 231 ++-- .../SummaryRepeatingGroup.test.tsx.snap | 10 +- src/layout/RepeatingGroup/config.ts | 59 +- src/layout/RepeatingGroup/hierarchy.ts | 182 --- src/layout/RepeatingGroup/index.tsx | 152 ++- src/layout/RepeatingGroup/types.d.ts | 5 - src/layout/RepeatingGroup/types.ts | 52 + src/layout/RepeatingGroup/useTableNodes.ts | 26 + src/layout/Summary/SummaryComponent.tsx | 80 +- src/layout/Summary/SummaryContent.tsx | 22 +- src/layout/Summary/SummaryItemCompact.tsx | 19 +- src/layout/Summary/config.ts | 7 +- src/layout/Summary/index.tsx | 13 - .../CommonSummaryComponents/EditButton.tsx | 23 +- .../MultipleValueSummary.tsx | 31 +- .../SingleValueSummary.tsx | 2 +- .../SummaryComponent2/ComponentSummary.tsx | 63 +- .../SummaryComponent2/LayoutSetSummary.tsx | 4 +- .../SummaryComponent2/PageSummary.tsx | 12 +- .../SummaryComponent2/SummaryComponent2.tsx | 47 +- .../SummaryComponent2/TaskSummary.tsx | 65 +- .../Summary2/SummaryComponent2/types.ts | 8 + src/layout/Summary2/config.tsx | 14 +- src/layout/Summary2/index.tsx | 12 - src/layout/Tabs/Tabs.tsx | 30 +- src/layout/Tabs/TabsPlugin.tsx | 154 +++ src/layout/Tabs/TabsSummary.tsx | 36 + src/layout/Tabs/config.ts | 29 +- src/layout/Tabs/hierarchy.ts | 74 -- src/layout/Tabs/index.tsx | 37 +- src/layout/Text/TextComponent.tsx | 8 +- src/layout/Text/config.ts | 4 + src/layout/Text/index.tsx | 12 +- .../TextArea/TextAreaComponent.test.tsx | 16 +- src/layout/TextArea/TextAreaComponent.tsx | 10 +- src/layout/TextArea/TextAreaSummary.tsx | 7 +- src/layout/TextArea/config.ts | 10 +- src/layout/TextArea/index.tsx | 21 +- src/layout/Video/Video.tsx | 3 +- src/layout/Video/config.ts | 4 + src/layout/common.ts | 11 +- src/layout/index.test.ts | 24 - src/layout/index.ts | 109 +- src/layout/{layout.d.ts => layout.ts} | 86 +- src/{modules.d.ts => modules.ts} | 0 src/queries/formPrefetcher.ts | 17 +- src/queries/staticOptionsPrefetcher.tsx | 4 +- src/queries/{types.d.ts => types.ts} | 0 src/setupTests.ts | 2 + src/test/allApps.ts | 459 +++++--- src/test/renderWithProviders.tsx | 78 +- src/utils/conditionalRendering.test.ts | 147 --- src/utils/conditionalRendering.ts | 93 -- src/utils/databindings.ts | 14 - src/utils/formComponentUtils.test.ts | 58 +- src/utils/formComponentUtils.ts | 40 +- src/utils/formLayout.ts | 71 +- src/utils/layout/ComponentErrorBoundary.tsx | 49 + src/utils/layout/HierarchyGenerator.ts | 444 ------- src/utils/layout/LayoutNode.ts | 320 +----- src/utils/layout/LayoutObject.ts | 30 +- src/utils/layout/LayoutPage.ts | 195 ++-- src/utils/layout/LayoutPages.ts | 99 +- src/utils/layout/NodesContext.tsx | 1019 ++++++++++++++--- src/utils/layout/all.test.ts | 34 - src/utils/layout/all.test.tsx | 202 ++++ .../layout/generator/GeneratorContext.tsx | 150 +++ .../generator/GeneratorErrorBoundary.tsx | 77 ++ .../layout/generator/GeneratorStages.tsx | 877 ++++++++++++++ .../layout/generator/LayoutSetGenerator.tsx | 507 ++++++++ src/utils/layout/generator/NodeGenerator.tsx | 374 ++++++ .../generator/NodeRepeatingChildren.tsx | 217 ++++ src/utils/layout/generator/debug.ts | 15 + .../layout/generator/useEvalExpression.ts | 66 ++ .../GenerationValidationContext.tsx | 111 ++ .../validation/NodePropertiesValidation.tsx | 100 ++ src/utils/layout/hierarchy.test.ts | 505 -------- src/utils/layout/hierarchy.ts | 225 ---- src/utils/layout/plugins/NodeDataPlugin.tsx | 17 + src/utils/layout/plugins/NodeDefPlugin.tsx | 273 +++++ .../plugins/NonRepeatingChildrenPlugin.tsx | 179 +++ .../plugins/RepeatingChildrenPlugin.tsx | 263 +++++ .../plugins/RepeatingChildrenStorePlugin.tsx | 99 ++ src/utils/layout/schema.test.ts | 224 +--- src/utils/layout/types.ts | 41 + .../layout/useDataModelBindingTranspose.ts | 78 ++ src/utils/layout/useExpressionDataSources.ts | 82 ++ src/utils/layout/useNodeItem.ts | 121 ++ src/utils/layout/useNodeTraversal.ts | 441 +++++++ src/utils/memoize.test.ts | 51 - src/utils/memoize.ts | 20 - src/utils/schemaUtils.test.ts | 56 +- src/utils/splitDashedKey.ts | 48 + template.env | 4 + .../anonymous-stateless-app/validation.ts | 5 +- .../frontend-test/all-process-steps.ts | 37 +- .../frontend-test/attachments-in-group.ts | 9 + .../frontend-test/auto-save-behavior.ts | 10 +- .../integration/frontend-test/components.ts | 12 +- .../frontend-test/custom-button.ts | 11 +- .../e2e/integration/frontend-test/dynamics.ts | 6 +- .../integration/frontend-test/formatting.ts | 15 +- .../integration/frontend-test/group-pets.ts | 40 +- test/e2e/integration/frontend-test/group.ts | 28 +- .../frontend-test/hide-row-in-group.ts | 1 + .../integration/frontend-test/navigation.ts | 112 +- .../e2e/integration/frontend-test/on-entry.ts | 1 + test/e2e/integration/frontend-test/options.ts | 1 + .../frontend-test/party-selection.ts | 5 + test/e2e/integration/frontend-test/pdf.ts | 7 +- .../e2e/integration/frontend-test/redirect.ts | 2 + test/e2e/integration/frontend-test/summary.ts | 51 +- test/e2e/integration/frontend-test/tabbing.ts | 1 + .../integration/frontend-test/validation.ts | 83 +- test/e2e/support/custom.ts | 40 +- test/e2e/support/customReceipt.ts | 8 +- test/e2e/support/formFiller.ts | 17 +- test/e2e/support/{global.d.ts => global.ts} | 26 +- test/e2e/support/index.ts | 14 + test/e2e/support/lang.ts | 7 + test/global.ts | 2 + test/tsconfig.json | 2 +- webpack.config.development.js | 3 +- 508 files changed, 17175 insertions(+), 13844 deletions(-) create mode 100644 src/__mocks__/getExpressionDataSourcesMock.ts delete mode 100644 src/__mocks__/getHierarchyDataSourcesMock.ts create mode 100644 src/codegen/Config.ts create mode 100644 src/codegen/SerializableSetting.ts delete mode 100644 src/codegen/dataTypes/GenerateComponentLike.ts delete mode 100644 src/codegen/dataTypes/GenerateLinked.ts create mode 100644 src/core/loading/LoadingContext.tsx create mode 100644 src/core/structures/ShallowArrayMap.test.ts create mode 100644 src/core/structures/ShallowArrayMap.ts create mode 100644 src/core/ui/useResetScrollPosition.ts create mode 100644 src/features/alertOnChange/AlertOnChangePlugin.tsx rename src/{components/molecules => features/alertOnChange}/DeleteWarningPopover.module.css (100%) rename src/{components/molecules => features/alertOnChange}/DeleteWarningPopover.tsx (95%) rename src/{hooks => features/alertOnChange}/useAlertOnChange.ts (100%) create mode 100644 src/features/applicationMetadata/minVersion.ts delete mode 100644 src/features/attachments/AttachmentsContext.tsx create mode 100644 src/features/attachments/AttachmentsPlugin.tsx create mode 100644 src/features/attachments/AttachmentsStorePlugin.tsx create mode 100644 src/features/attachments/StoreAttachmentsInNode.tsx create mode 100644 src/features/attachments/UpdateAttachmentsForCypress.tsx create mode 100644 src/features/attachments/hooks.ts create mode 100644 src/features/attachments/sortAttachments.ts delete mode 100644 src/features/attachments/useAttachmentsMappedToFormData.tsx delete mode 100644 src/features/attachments/utils/mapping.ts delete mode 100644 src/features/attachments/utils/postUpload.ts delete mode 100644 src/features/attachments/utils/preUpload.ts delete mode 100644 src/features/attachments/utils/sorting.test.ts delete mode 100644 src/features/attachments/utils/sorting.ts delete mode 100644 src/features/dataLists/index.d.ts create mode 100644 src/features/dataLists/index.ts delete mode 100644 src/features/datamodel/dataModelLookups.test.ts delete mode 100644 src/features/devtools/layoutValidation/all.test.tsx delete mode 100644 src/features/devtools/layoutValidation/useLayoutSchemaValidation.ts create mode 100644 src/features/displayData/displayData.test.ts create mode 100644 src/features/expressions/shared-context.test.tsx create mode 100644 src/features/expressions/shared-functions.test.tsx delete mode 100644 src/features/expressions/shared-tests/functions/component/duplicate-id-1.json delete mode 100644 src/features/expressions/shared-tests/functions/component/duplicate-id-2.json rename src/features/expressions/shared-tests/functions/displayValue/{accordion-component.json => accordion-group.json} (100%) create mode 100644 src/features/expressions/shared-tests/functions/displayValue/accordion.json delete mode 100644 src/features/expressions/shared-tests/functions/displayValue/summary.json delete mode 100644 src/features/expressions/shared-tests/functions/displayValue/summary2.json delete mode 100644 src/features/expressions/shared-tests/layout-preprocessor/failures.json delete mode 100644 src/features/expressions/shared-tests/layout-preprocessor/successful.json delete mode 100644 src/features/expressions/shared.test.ts create mode 100644 src/features/form/dynamics/HiddenComponentsProvider.tsx delete mode 100644 src/features/form/dynamics/conditionalRenderingSagas.ts create mode 100644 src/features/form/layout/quirks.ts delete mode 100644 src/features/form/rules/index.d.ts rename src/features/language/textResources/{index.d.ts => index.ts} (80%) create mode 100644 src/features/options/OptionsPlugin.tsx create mode 100644 src/features/options/OptionsStorePlugin.tsx create mode 100644 src/features/options/StoreOptionsInNode.tsx delete mode 100644 src/features/options/useAllOptions.test.tsx delete mode 100644 src/features/options/useAllOptions.tsx create mode 100644 src/features/options/useNodeOptions.ts delete mode 100644 src/features/orgs/index.d.ts delete mode 100644 src/features/party/index.d.ts delete mode 100644 src/features/pdf/PDFView.tsx delete mode 100644 src/features/profile/index.d.ts create mode 100644 src/features/routing/AppRoutingContext.tsx create mode 100644 src/features/validation/StoreValidationsInNode.tsx create mode 100644 src/features/validation/ValidationPlugin.tsx create mode 100644 src/features/validation/ValidationStorePlugin.tsx delete mode 100644 src/features/validation/expressionValidation/useExpressionValidation.test.ts create mode 100644 src/features/validation/expressionValidation/useExpressionValidation.test.tsx create mode 100644 src/features/validation/selectors/isValid.ts delete mode 100644 src/features/validation/visibility/useVisibility.ts delete mode 100644 src/features/validation/visibility/visibilityUtils.ts rename src/{global.d.ts => global.ts} (91%) create mode 100644 src/hooks/delayedSelectors.test.tsx create mode 100644 src/hooks/delayedSelectors.ts delete mode 100644 src/hooks/useInstanceIdParams.ts delete mode 100644 src/hooks/usePdfPage.ts delete mode 100644 src/layout/Accordion/hierarchy.ts create mode 100644 src/layout/AccordionGroup/AccordionGroupContext.tsx delete mode 100644 src/layout/AccordionGroup/hierarchy.ts delete mode 100644 src/layout/ButtonGroup/hierarchy.ts create mode 100644 src/layout/Cards/CardsPlugin.tsx create mode 100644 src/layout/Cards/CardsSummary.tsx delete mode 100644 src/layout/Cards/hierarchy.ts create mode 100644 src/layout/Checkboxes/CheckboxesSummary.tsx create mode 100644 src/layout/Grid/GridRowsPlugin.tsx delete mode 100644 src/layout/Grid/hierarchy.ts delete mode 100644 src/layout/Group/hierarchy.ts delete mode 100644 src/layout/Group/types.d.ts create mode 100644 src/layout/Input/formatting.ts create mode 100644 src/layout/Likert/Generator/LikertGeneratorChildren.tsx create mode 100644 src/layout/Likert/Generator/LikertRowsPlugin.tsx delete mode 100644 src/layout/Likert/hierarchy.ts delete mode 100644 src/layout/Likert/types.d.ts create mode 100644 src/layout/MultipleSelect/MultipleSelectSummary.tsx delete mode 100644 src/layout/RepeatingGroup/hierarchy.ts delete mode 100644 src/layout/RepeatingGroup/types.d.ts create mode 100644 src/layout/RepeatingGroup/types.ts create mode 100644 src/layout/RepeatingGroup/useTableNodes.ts create mode 100644 src/layout/Summary2/SummaryComponent2/types.ts create mode 100644 src/layout/Tabs/TabsPlugin.tsx create mode 100644 src/layout/Tabs/TabsSummary.tsx delete mode 100644 src/layout/Tabs/hierarchy.ts delete mode 100644 src/layout/index.test.ts rename src/layout/{layout.d.ts => layout.ts} (51%) rename src/{modules.d.ts => modules.ts} (100%) rename src/queries/{types.d.ts => types.ts} (100%) delete mode 100644 src/utils/conditionalRendering.test.ts delete mode 100644 src/utils/conditionalRendering.ts create mode 100644 src/utils/layout/ComponentErrorBoundary.tsx delete mode 100644 src/utils/layout/HierarchyGenerator.ts delete mode 100644 src/utils/layout/all.test.ts create mode 100644 src/utils/layout/all.test.tsx create mode 100644 src/utils/layout/generator/GeneratorContext.tsx create mode 100644 src/utils/layout/generator/GeneratorErrorBoundary.tsx create mode 100644 src/utils/layout/generator/GeneratorStages.tsx create mode 100644 src/utils/layout/generator/LayoutSetGenerator.tsx create mode 100644 src/utils/layout/generator/NodeGenerator.tsx create mode 100644 src/utils/layout/generator/NodeRepeatingChildren.tsx create mode 100644 src/utils/layout/generator/debug.ts create mode 100644 src/utils/layout/generator/useEvalExpression.ts create mode 100644 src/utils/layout/generator/validation/GenerationValidationContext.tsx create mode 100644 src/utils/layout/generator/validation/NodePropertiesValidation.tsx delete mode 100644 src/utils/layout/hierarchy.test.ts delete mode 100644 src/utils/layout/hierarchy.ts create mode 100644 src/utils/layout/plugins/NodeDataPlugin.tsx create mode 100644 src/utils/layout/plugins/NodeDefPlugin.tsx create mode 100644 src/utils/layout/plugins/NonRepeatingChildrenPlugin.tsx create mode 100644 src/utils/layout/plugins/RepeatingChildrenPlugin.tsx create mode 100644 src/utils/layout/plugins/RepeatingChildrenStorePlugin.tsx create mode 100644 src/utils/layout/types.ts create mode 100644 src/utils/layout/useDataModelBindingTranspose.ts create mode 100644 src/utils/layout/useExpressionDataSources.ts create mode 100644 src/utils/layout/useNodeItem.ts create mode 100644 src/utils/layout/useNodeTraversal.ts delete mode 100644 src/utils/memoize.test.ts delete mode 100644 src/utils/memoize.ts create mode 100644 src/utils/splitDashedKey.ts rename test/e2e/support/{global.d.ts => global.ts} (88%) create mode 100644 test/e2e/support/lang.ts create mode 100644 test/global.ts diff --git a/src/__mocks__/getExpressionDataSourcesMock.ts b/src/__mocks__/getExpressionDataSourcesMock.ts new file mode 100644 index 0000000000..4d6b21d037 --- /dev/null +++ b/src/__mocks__/getExpressionDataSourcesMock.ts @@ -0,0 +1,33 @@ +import { getApplicationSettingsMock } from 'src/__mocks__/getApplicationSettingsMock'; +import { staticUseLanguageForTests } from 'src/features/language/useLanguage'; +import type { ExpressionDataSources } from 'src/features/expressions/ExprContext'; + +export function getExpressionDataSourcesMock(): ExpressionDataSources { + return { + formDataSelector: () => null, + formDataRowsSelector: () => [], + attachmentsSelector: () => { + throw new Error('Not implemented: attachmentsSelector()'); + }, + layoutSettings: { pages: { order: [] } }, + optionsSelector: () => ({ isFetching: false, options: [] }), + applicationSettings: getApplicationSettingsMock(), + instanceDataSources: {} as any, + authContext: null, + devToolsIsOpen: false, + devToolsHiddenComponents: 'hide', + langToolsSelector: () => staticUseLanguageForTests(), + currentLanguage: 'nb', + isHiddenSelector: () => false, + nodeFormDataSelector: () => ({}) as any, + nodeDataSelector: () => { + throw new Error('Not implemented: nodeDataSelector()'); + }, + nodeTraversal: () => { + throw new Error('Not implemented: nodeTraversal()'); + }, + transposeSelector: () => { + throw new Error('Not implemented: transposeSelector()'); + }, + }; +} diff --git a/src/__mocks__/getHierarchyDataSourcesMock.ts b/src/__mocks__/getHierarchyDataSourcesMock.ts deleted file mode 100644 index 1b6b01929f..0000000000 --- a/src/__mocks__/getHierarchyDataSourcesMock.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { getApplicationSettingsMock } from 'src/__mocks__/getApplicationSettingsMock'; -import { staticUseLanguageForTests } from 'src/features/language/useLanguage'; -import type { HierarchyDataSources } from 'src/layout/layout'; - -export function getHierarchyDataSourcesMock(): HierarchyDataSources { - return { - formDataSelector: () => null, - attachments: {}, - layoutSettings: { pages: { order: [] } }, - pageNavigationConfig: { isHiddenPage: () => false, hiddenExpr: {} }, - options: () => [], - applicationSettings: getApplicationSettingsMock(), - instanceDataSources: {} as any, - isHidden: () => false, - authContext: null, - devToolsIsOpen: false, - devToolsHiddenComponents: 'hide', - langToolsSelector: () => staticUseLanguageForTests(), - currentLanguage: 'nb', - }; -} diff --git a/src/__mocks__/getMultiPageGroupMock.ts b/src/__mocks__/getMultiPageGroupMock.ts index d219256f02..6c7aab74cb 100644 --- a/src/__mocks__/getMultiPageGroupMock.ts +++ b/src/__mocks__/getMultiPageGroupMock.ts @@ -10,6 +10,6 @@ export const getMultiPageGroupMock = (overrides: Partial): IProfileState { - const profileStateMock: IProfileState = { - profile: getProfileMock(), - }; - - return { - ...profileStateMock, - ...customStates, - }; -} diff --git a/src/codegen/CG.ts b/src/codegen/CG.ts index 7b99d4d24b..1764eaf4fe 100644 --- a/src/codegen/CG.ts +++ b/src/codegen/CG.ts @@ -2,14 +2,12 @@ import { ComponentConfig } from 'src/codegen/ComponentConfig'; import { GenerateArray } from 'src/codegen/dataTypes/GenerateArray'; import { GenerateBoolean } from 'src/codegen/dataTypes/GenerateBoolean'; import { GenerateCommonImport } from 'src/codegen/dataTypes/GenerateCommonImport'; -import { GenerateComponentLike } from 'src/codegen/dataTypes/GenerateComponentLike'; import { GenerateConst } from 'src/codegen/dataTypes/GenerateConst'; import { GenerateEnum } from 'src/codegen/dataTypes/GenerateEnum'; import { GenerateExpressionOr } from 'src/codegen/dataTypes/GenerateExpressionOr'; import { GenerateImportedSymbol } from 'src/codegen/dataTypes/GenerateImportedSymbol'; import { GenerateInteger } from 'src/codegen/dataTypes/GenerateInteger'; import { GenerateIntersection } from 'src/codegen/dataTypes/GenerateIntersection'; -import { GenerateLinked } from 'src/codegen/dataTypes/GenerateLinked'; import { GenerateNumber } from 'src/codegen/dataTypes/GenerateNumber'; import { GenerateObject } from 'src/codegen/dataTypes/GenerateObject'; import { GenerateProperty } from 'src/codegen/dataTypes/GenerateProperty'; @@ -23,33 +21,8 @@ function generateCommonImport(key: T): GenerateCommon return new GenerateCommonImport(key); } -/** - * The code generator can transform defined types into multiple 'variants' of code. For example, an external variant - * of a configuration may contain an expression that should be evaluated, but our internal types however would just - * contain the result of the expression evaluation. All configuration of components are processed into internal - * types before being used in most of our internal code, and these processors include (but are not limited to) the - * hierarchy generator, LayoutNode (etc), and the expression evaluator engine. - * - * @see generateHierarchy - */ -export enum Variant { - Internal = 'internal', - External = 'external', -} - -/** - * When the code generator sees types that include expressions or other types that differs between the internal and - * external variants, it will generate separate types for each variant (in our TypeScript outputs). These types are - * suffixed with these strings. In JsonSchema, only the external variant is generated, and so there are no suffixes. - */ -export const VariantSuffixes: { [variant in Variant]: string } = { - [Variant.Internal]: 'Internal', - [Variant.External]: 'External', -}; - export const CG = { component: ComponentConfig, - componentLike: GenerateComponentLike, // Scalars, types and expressions const: GenerateConst, @@ -73,19 +46,10 @@ export const CG = { // Known values that we have types for elsewhere, or other imported types common: generateCommonImport, import: GenerateImportedSymbol, - layoutNode: new GenerateImportedSymbol({ - import: 'LayoutNode', - from: 'src/utils/layout/LayoutNode', - }), - baseLayoutNode: new GenerateImportedSymbol({ - import: 'BaseLayoutNode', - from: 'src/utils/layout/LayoutNode', - }), // Others enum: GenerateEnum, union: GenerateUnion, intersection: GenerateIntersection, - linked: GenerateLinked, raw: GenerateRaw, }; diff --git a/src/codegen/CodeGenerator.ts b/src/codegen/CodeGenerator.ts index dbef99848c..c38d691520 100644 --- a/src/codegen/CodeGenerator.ts +++ b/src/codegen/CodeGenerator.ts @@ -1,6 +1,5 @@ import type { JSONSchema7, JSONSchema7Type } from 'json-schema'; -import { Variant, VariantSuffixes } from 'src/codegen/CG'; import { CodeGeneratorContext } from 'src/codegen/CodeGeneratorContext'; export interface JsonSchemaExt { @@ -20,7 +19,6 @@ export interface SymbolExt { export interface Optionality { default?: T; - onlyIn?: Variant; } export interface InternalConfig { @@ -35,7 +33,6 @@ export interface InternalConfig { export type Extract> = Val extends CodeGenerator ? X : never; export abstract class CodeGenerator { - public currentVariant: Variant | undefined; public internal: InternalConfig = { jsonSchema: { title: undefined, @@ -67,24 +64,6 @@ export abstract class CodeGenerator { this.internal.frozen = source; } - transformTo(variant: Variant): this | CodeGenerator { - const isImplementedLocally = - this.containsVariationDifferences === CodeGenerator.prototype.containsVariationDifferences || - this.containsVariationDifferences === MaybeOptionalCodeGenerator.prototype.containsVariationDifferences; - if (!this.currentVariant && this.containsVariationDifferences() && !isImplementedLocally) { - throw new Error( - 'You need to implement transformTo for this code generator, as it contains variation differences', - ); - } - - this.currentVariant = variant; - return this; - } - - containsVariationDifferences(): boolean { - return this.internal.source?.containsVariationDifferences() || false; - } - shouldUseParens(): boolean { return false; } @@ -96,10 +75,6 @@ export abstract class CodeGenerator { export abstract class MaybeSymbolizedCodeGenerator extends CodeGenerator { exportAs(name: string): this { this.ensureMutable(); - if (this.currentVariant) { - throw new Error('You have to call exportAs() before calling transformTo()'); - } - if (this.internal.symbol) { throw new Error('Cannot rename a symbolized code generator'); } @@ -114,10 +89,6 @@ export abstract class MaybeSymbolizedCodeGenerator extends CodeGenerator { named(name: string): this { this.ensureMutable(); - if (this.currentVariant) { - throw new Error('You have to call named() before calling transformTo()'); - } - if (this.internal.symbol) { throw new Error('Cannot rename a symbolized code generator'); } @@ -130,33 +101,24 @@ export abstract class MaybeSymbolizedCodeGenerator extends CodeGenerator { return this; } - getName(respectVariationDifferences = true): string | undefined { + getName(): string | undefined { if (!this.internal.symbol) { return undefined; } - if (!this.currentVariant) { - throw new Error('Cannot get name of symbolized code generator without variant - call transformTo() first'); - } - if (respectVariationDifferences && this.containsVariationDifferences()) { - return `${this.internal.symbol?.name}${VariantSuffixes[this.currentVariant]}`; - } return this.internal.symbol?.name; } - private shouldBeExported(): boolean { - return this.internal.symbol?.exported ?? false; + toString(): string { + return this.toTypeScript(); } - transformTo(variant: Variant): this | MaybeSymbolizedCodeGenerator { - return super.transformTo(variant) as this | MaybeSymbolizedCodeGenerator; + private shouldBeExported(): boolean { + return this.internal.symbol?.exported ?? false; } toTypeScript(): string { this.freeze('toTypeScript'); - if (!this.currentVariant) { - throw new Error('You need to transform this type to either external or internal before generating TypeScript'); - } const name = this.getName(); if (name) { @@ -172,11 +134,8 @@ export abstract class MaybeSymbolizedCodeGenerator extends CodeGenerator { toJsonSchema(): JSONSchema7 { this.freeze('toJsonSchema'); - if (!this.currentVariant || this.currentVariant === Variant.Internal) { - throw new Error('You need to transform this type to external before generating JsonSchema'); - } - const name = this.getName(false); + const name = this.getName(); if (name) { CodeGeneratorContext.curFile().addSymbol(name, this.shouldBeExported(), this); @@ -201,15 +160,7 @@ export abstract class MaybeOptionalCodeGenerator extends MaybeSymbolizedCodeG } isOptional(): boolean { - const isOptional = this.internal.optional !== false; - const onlyIn = this.internal.optional && this.internal.optional.onlyIn; - const matchesCurrentVariant = !onlyIn || onlyIn === this.currentVariant; - return isOptional && matchesCurrentVariant; - } - - containsVariationDifferences(): boolean { - const optionalIn = this.internal.optional !== false ? this.internal.optional.onlyIn : undefined; - return super.containsVariationDifferences() || optionalIn !== undefined; + return this.internal.optional !== false; } } diff --git a/src/codegen/Common.ts b/src/codegen/Common.ts index 5ac97be911..560ea60db2 100644 --- a/src/codegen/Common.ts +++ b/src/codegen/Common.ts @@ -1,4 +1,4 @@ -import { CG, Variant } from 'src/codegen/CG'; +import { CG } from 'src/codegen/CG'; import { ExprVal } from 'src/features/expressions/types'; import { DEFAULT_DEBOUNCE_TIMEOUT } from 'src/features/formData/types'; import type { MaybeSymbolizedCodeGenerator } from 'src/codegen/CodeGenerator'; @@ -20,15 +20,10 @@ const common = { 'layout', new CG.arr( new CG.raw({ - typeScript: new CG.linked( - new CG.import({ - import: 'CompOrGroupExternal', - from: 'src/layout/layout.d', - }), - new CG.raw({ - typeScript: 'never', - }), - ), + typeScript: new CG.import({ + import: 'CompExternal', + from: 'src/layout/layout', + }), jsonSchema: () => ({ $ref: '#/definitions/AnyComponent', }), @@ -168,10 +163,7 @@ const common = { 'Dot notation location for the answers. This must point to a property of the objects inside the ' + 'question array. The answer for each question will be stored in the answer property of the ' + 'corresponding question object.', - ) - .optional({ - onlyIn: Variant.Internal, - }), + ), ), new CG.prop( 'questions', @@ -436,21 +428,8 @@ const common = { 'Boolean value or expression indicating if the component should be hidden. Defaults to false.', ), ), - new CG.prop( - 'forceShowInSummary', - new CG.expr(ExprVal.Boolean) - .optional({ default: false }) - .setTitle('Force show in summary') - .setDescription( - 'Will force show the component in a summary even if hideEmptyFields is set to true in the summary component.', - ), - ), new CG.prop('grid', CG.common('IGrid').optional()), new CG.prop('pageBreak', CG.common('IPageBreak').optional()), - - // Internal-only properties (these are added by the hierarchy generator): - new CG.prop('baseComponentId', new CG.str().optional()).onlyIn(Variant.Internal), - new CG.prop('multiPageIndex', new CG.int().optional()).onlyIn(Variant.Internal), ), FormComponentProps: () => new CG.obj( @@ -478,11 +457,20 @@ const common = { new CG.obj( new CG.prop( 'renderAsSummary', - new CG.expr(ExprVal.Boolean) + new CG.bool() .optional({ default: false }) .setTitle('Render as summary') .setDescription( - 'Boolean value or expression indicating if the component should be rendered as a summary. Defaults to false.', + 'Boolean value indicating if the component should be rendered as a summary. Defaults to false.', + ), + ), + new CG.prop( + 'forceShowInSummary', + new CG.expr(ExprVal.Boolean) + .optional({ default: false }) + .setTitle('Force show in summary') + .setDescription( + 'Will force show the component in a summary even if hideEmptyFields is set to true in the summary component.', ), ), ), @@ -514,18 +502,7 @@ const common = { new CG.prop('columnOptions', CG.common('ITableColumnProperties').optional()), ).extends(CG.common('ITableColumnProperties')), GridCell: () => - new CG.union( - new CG.linked( - CG.common('GridComponentRef'), - new CG.import({ - import: 'GridComponent', - from: 'src/layout/Grid/types', - }), - ), - CG.null, - CG.common('GridCellText'), - CG.common('GridCellLabelFrom'), - ), + new CG.union(CG.common('GridComponentRef'), CG.null, CG.common('GridCellText'), CG.common('GridCellLabelFrom')), GridRow: () => new CG.obj( new CG.prop('header', new CG.bool().optional({ default: false }).setTitle('Is header row?')), @@ -725,6 +702,33 @@ const common = { ) .setTitle('Layout set') .setDescription('Settings regarding a specific layout-set'), + PatternFormatProps: () => + new CG.obj( + new CG.prop('format', new CG.expr(ExprVal.String)), + new CG.prop('mask', new CG.union(new CG.str(), new CG.arr(new CG.str())).optional()), + new CG.prop('allowEmptyFormatting', new CG.bool().optional()), + new CG.prop('patternChar', new CG.str().optional()), + ), + NumberFormatProps: () => + new CG.obj( + new CG.prop( + 'thousandSeparator', + new CG.union(new CG.expr(ExprVal.Boolean), new CG.expr(ExprVal.String)).optional(), + ), + new CG.prop('decimalSeparator', new CG.expr(ExprVal.String).optional()), + new CG.prop('allowedDecimalSeparators', new CG.arr(new CG.str()).optional()), + new CG.prop('thousandsGroupStyle', new CG.enum('thousand', 'lakh', 'wan', 'none').optional()), + new CG.prop('decimalScale', new CG.num().optional()), + new CG.prop('fixedDecimalScale', new CG.bool().optional()), + new CG.prop('allowNegative', new CG.bool().optional()), + new CG.prop('allowLeadingZeros', new CG.bool().optional()), + new CG.prop('suffix', new CG.expr(ExprVal.String).optional()), + new CG.prop('prefix', new CG.expr(ExprVal.String).optional()), + ) + .setTitle('Number formatting options') + .setDescription( + 'These options are sent directly to react-number-format in order to make it possible to format pretty numbers in the input field.', + ), IFormatting: () => new CG.obj( // Newer Intl.NumberFormat options @@ -777,39 +781,9 @@ const common = { ), // Older options based on react-number-format - new CG.prop( - 'number', - new CG.union( - new CG.obj( - new CG.prop('format', new CG.expr(ExprVal.String)), - new CG.prop('mask', new CG.union(new CG.str(), new CG.arr(new CG.str())).optional()), - new CG.prop('allowEmptyFormatting', new CG.bool().optional()), - new CG.prop('patternChar', new CG.str().optional()), - ), - new CG.obj( - new CG.prop( - 'thousandSeparator', - new CG.union(new CG.expr(ExprVal.Boolean), new CG.expr(ExprVal.String)).optional(), - ), - new CG.prop('decimalSeparator', new CG.expr(ExprVal.String).optional()), - new CG.prop('allowedDecimalSeparators', new CG.arr(new CG.str()).optional()), - new CG.prop('thousandsGroupStyle', new CG.enum('thousand', 'lakh', 'wan', 'none').optional()), - new CG.prop('decimalScale', new CG.num().optional()), - new CG.prop('fixedDecimalScale', new CG.bool().optional()), - new CG.prop('allowNegative', new CG.bool().optional()), - new CG.prop('allowLeadingZeros', new CG.bool().optional()), - new CG.prop('suffix', new CG.expr(ExprVal.String).optional()), - new CG.prop('prefix', new CG.expr(ExprVal.String).optional()), - ) - .setTitle('Number formatting options') - .setDescription( - 'These options are sent directly to react-number-format in order to make it possible to format pretty numbers in the input field.', - ), - ).optional(), - ), + new CG.prop('number', new CG.union(CG.common('PatternFormatProps'), CG.common('NumberFormatProps')).optional()), new CG.prop('align', new CG.enum('right', 'center', 'left').optional({ default: 'left' })), ) - .optional() .addExample({ currency: 'NOK', }) @@ -857,10 +831,6 @@ export function getSourceForCommon(key: ValidCommonKeys) { return impl; } -export function commonContainsVariationDifferences(key: ValidCommonKeys): boolean { - return getSourceForCommon(key).containsVariationDifferences(); -} - export function generateAllCommonTypes() { for (const key in common) { getSourceForCommon(key as ValidCommonKeys); @@ -873,18 +843,13 @@ export function generateCommonTypeScript() { // Calling toTypeScript() on an exported symbol will register it in the currently // generated file, so there's no need to output the result here - if (val.containsVariationDifferences()) { - val.transformTo(Variant.External).toTypeScript(); - val.transformTo(Variant.Internal).toTypeScript(); - } else { - val.transformTo(Variant.External).toTypeScript(); - } + val.toTypeScript(); } } export function generateCommonSchema() { for (const key in common) { const val = getSourceForCommon(key as ValidCommonKeys); - val.transformTo(Variant.External).toJsonSchema(); + val.toJsonSchema(); } } diff --git a/src/codegen/ComponentConfig.ts b/src/codegen/ComponentConfig.ts index a91be5b70a..38a74cfd6e 100644 --- a/src/codegen/ComponentConfig.ts +++ b/src/codegen/ComponentConfig.ts @@ -1,10 +1,13 @@ import type { JSONSchema7 } from 'json-schema'; -import { CG, Variant } from 'src/codegen/CG'; -import { GenerateComponentLike } from 'src/codegen/dataTypes/GenerateComponentLike'; +import { CG } from 'src/codegen/CG'; import { GenerateImportedSymbol } from 'src/codegen/dataTypes/GenerateImportedSymbol'; +import { GenerateRaw } from 'src/codegen/dataTypes/GenerateRaw'; +import { GenerateUnion } from 'src/codegen/dataTypes/GenerateUnion'; +import { ValidationPlugin } from 'src/features/validation/ValidationPlugin'; import { CompCategory } from 'src/layout/common'; -import type { MaybeSymbolizedCodeGenerator } from 'src/codegen/CodeGenerator'; +import { isNodeDefChildrenPlugin, NodeDefPlugin } from 'src/utils/layout/plugins/NodeDefPlugin'; +import type { CompBehaviors, RequiredComponentConfig } from 'src/codegen/Config'; import type { GenerateCommonImport } from 'src/codegen/dataTypes/GenerateCommonImport'; import type { GenerateObject } from 'src/codegen/dataTypes/GenerateObject'; import type { GenerateProperty } from 'src/codegen/dataTypes/GenerateProperty'; @@ -17,19 +20,6 @@ import type { PresentationComponent, } from 'src/layout/LayoutComponent'; -export interface RequiredComponentConfig { - category: CompCategory; - capabilities: { - renderInTable: boolean; - renderInButtonGroup: boolean; - renderInAccordion: boolean; - renderInAccordionGroup: boolean; - renderInTabs?: boolean; - renderInCards: boolean; - renderInCardsMedia: boolean; - }; -} - const CategoryImports: { [Category in CompCategory]: GenerateImportedSymbol } = { [CompCategory.Action]: new GenerateImportedSymbol>({ import: 'ActionComponent', @@ -49,55 +39,102 @@ const CategoryImports: { [Category in CompCategory]: GenerateImportedSymbol }), }; -export class ComponentConfig extends GenerateComponentLike { +const baseLayoutNode = new GenerateImportedSymbol({ + import: 'BaseLayoutNode', + from: 'src/utils/layout/LayoutNode', +}); + +export class ComponentConfig { public type: string; public typeSymbol: string; - public layoutNodeType = CG.baseLayoutNode; - - private exportedComp: MaybeSymbolizedCodeGenerator = this.inner; + public layoutNodeType = baseLayoutNode; + readonly inner = new CG.obj(); + public behaviors: CompBehaviors = { + isSummarizable: false, + canHaveLabel: false, + canHaveOptions: false, + canHaveAttachments: false, + }; + protected plugins: NodeDefPlugin[] = []; constructor(public readonly config: RequiredComponentConfig) { - super(); this.inner.extends(CG.common('ComponentBase')); - this.inner.addProperty( - new CG.prop('textResourceBindings', new CG.raw({ typeScript: 'undefined' }).optional()).onlyIn(Variant.Internal), - ); - this.inner.addProperty( - new CG.prop('dataModelBindings', new CG.raw({ typeScript: 'undefined' }).optional()).onlyIn(Variant.Internal), - ); if (config.category === CompCategory.Form) { this.inner.extends(CG.common('FormComponentProps')); this.extendTextResources(CG.common('TRBFormComp')); } - if (config.category === CompCategory.Form || config.category === CompCategory.Container) { + if (this.isFormLike()) { this.inner.extends(CG.common('SummarizableComponentProps')); this.extendTextResources(CG.common('TRBSummarizable')); + this.behaviors.isSummarizable = true; + this.addPlugin(new ValidationPlugin()); } } - private ensureNotOverridden(): void { - if (this.inner !== this.exportedComp) { - throw new Error('The exported symbol has been overridden, you cannot call this method anymore'); + public setType(type: string, symbol?: string): this { + const symbolName = symbol ?? type; + this.type = type; + this.typeSymbol = symbolName; + this.inner.addProperty(new CG.prop('type', new CG.const(this.type)).insertFirst()); + + return this; + } + + public addPlugin(plugin: NodeDefPlugin): this { + for (const existing of this.plugins) { + if (existing.getKey() === plugin.getKey()) { + throw new Error(`Component already has a plugin with the key ${plugin.getKey()}!`); + } + } + + plugin.addToComponent(this); + this.plugins.push(plugin); + return this; + } + + public addProperty(prop: GenerateProperty): this { + this.inner.addProperty(prop); + return this; + } + + private ensureTextResourceBindings(): void { + const existing = this.inner.getProperty('textResourceBindings'); + if (!existing || existing.type instanceof GenerateRaw) { + this.inner.addProperty(new CG.prop('textResourceBindings', new CG.obj().optional())); } } - addProperty(prop: GenerateProperty): this { - this.ensureNotOverridden(); - return super.addProperty(prop); + /** + * TODO: Add support for some required text resource bindings (but only make them required in external types) + */ + public addTextResource(arg: GenerateTextResourceBinding): this { + this.ensureTextResourceBindings(); + this.inner.getProperty('textResourceBindings')?.type.addProperty(arg); + + return this; + } + + public extendTextResources(type: GenerateCommonImport): this { + this.ensureTextResourceBindings(); + this.inner.getProperty('textResourceBindings')?.type.extends(type); + + return this; } - makeSelectionComponent(full = true): this { - this.ensureNotOverridden(); - return super.makeSelectionComponent(full); + public isFormLike(): boolean { + return this.config.category === CompCategory.Form || this.config.category === CompCategory.Container; } - addTextResourcesForLabel(): this { - this.ensureNotOverridden(); - return super.addTextResourcesForLabel(); + private hasDataModelBindings(): boolean { + const prop = this.inner.getProperty('dataModelBindings'); + return this.isFormLike() && prop !== undefined && !(prop.type instanceof GenerateRaw); } - addDataModelBinding( + /** + * Adding multiple data model bindings to the component makes it a union + */ + public addDataModelBinding( type: | GenerateCommonImport< | 'IDataModelBindingsSimple' @@ -107,45 +144,37 @@ export class ComponentConfig extends GenerateComponentLike { > | GenerateObject, ): this { - this.ensureNotOverridden(); - return super.addDataModelBinding(type); - } + if (!this.isFormLike()) { + throw new Error( + `Component wants dataModelBindings, but is not a form nor a container component. ` + + `Only these categories can have data model bindings.`, + ); + } - extendTextResources(type: GenerateCommonImport): this { - this.ensureNotOverridden(); - return super.extendTextResources(type); - } + const name = 'dataModelBindings'; + const existing = this.inner.getProperty(name)?.type; + if (existing && existing instanceof GenerateUnion) { + existing.addType(type); + } else if (existing && !(existing instanceof GenerateRaw)) { + const union = new CG.union(existing, type); + this.inner.addProperty(new CG.prop(name, union)); + } else { + this.inner.addProperty(new CG.prop(name, type)); + } - /** - * TODO: Add support for some required text resource bindings (but only make them required in external types) - */ - addTextResource(arg: GenerateTextResourceBinding): this { - this.ensureNotOverridden(); - return super.addTextResource(arg); + return this; } - public setType(type: string, symbol?: string): this { - const symbolName = symbol ?? type; - this.type = type; - this.typeSymbol = symbolName; - this.inner.addProperty(new CG.prop('type', new CG.const(this.type)).insertFirst()); - if (this.inner !== this.exportedComp) { - this.inner.exportAs(`${symbolName}Base`); + extends(type: GenerateCommonImport | ComponentConfig): this { + if (type instanceof ComponentConfig) { + this.inner.extends(type.inner); + return this; } + this.inner.extends(type); return this; } - /** - * Overrides the exported symbol for this component with something else. This lets you change the base component - * type to, for example, a union of multiple types. This is for example useful for components that have - * configurations that change a lot of options based on some other options. Examples include the Group component, - * where many options rely on the Group being configured as repeating or not. - */ - public overrideExported(comp: MaybeSymbolizedCodeGenerator): this { - this.exportedComp = comp; - return this; - } // This will not be used at the moment after we split the group to several components. // However, this is nice to keep for future components that might need it. public setLayoutNodeType(type: GenerateImportedSymbol): this { @@ -153,31 +182,65 @@ export class ComponentConfig extends GenerateComponentLike { return this; } + private beforeFinalizing(): void { + // We have to add these to our typescript types in order for ITextResourceBindings, and similar to work. + // Components that doesn't have them, will always have the 'undefined' value. + if (!this.inner.hasProperty('dataModelBindings')) { + this.inner.addProperty( + new CG.prop('dataModelBindings', new CG.raw({ typeScript: 'undefined' }).optional()).omitInSchema(), + ); + } + if (!this.inner.hasProperty('textResourceBindings')) { + this.inner.addProperty( + new CG.prop('textResourceBindings', new CG.raw({ typeScript: 'undefined' }).optional()).omitInSchema(), + ); + } + } + public generateConfigFile(): string { + this.beforeFinalizing(); // Forces the objects to register in the context and be exported via the context symbols table - this.exportedComp.exportAs(`Comp${this.typeSymbol}`); - const ext = this.exportedComp.transformTo(Variant.External); - ext.toTypeScript(); - const int = this.exportedComp.transformTo(Variant.Internal); - int.toTypeScript(); + this.inner.exportAs(`Comp${this.typeSymbol}External`); + this.inner.toTypeScript(); const impl = new CG.import({ import: this.typeSymbol, from: `./index`, - }).transformTo(Variant.Internal); + }); + + const nodeObj = this.layoutNodeType.toTypeScript(); + const nodeSuffix = this.layoutNodeType === baseLayoutNode ? `<'${this.type}'>` : ''; - const nodeObj = this.layoutNodeType.transformTo(Variant.Internal).toTypeScript(); - const nodeSuffix = this.layoutNodeType === CG.baseLayoutNode ? `<${int.getName()}, '${this.type}'>` : ''; + const CompCategory = new CG.import({ + import: 'CompCategory', + from: `src/layout/common`, + }); + + const pluginUnion = + this.plugins.length === 0 + ? 'never' + : this.plugins + .map((plugin) => { + const PluginName = plugin.makeImport(); + const genericArgs = plugin.makeGenericArgs(); + return genericArgs ? `${PluginName}<${genericArgs}>` : `${PluginName}`; + }) + .join(' | '); const staticElements = [ - `export const Config = { - def: new ${impl.toTypeScript()}(), - nodeConstructor: ${nodeObj}, + `export function getConfig() { + return { + def: new ${impl.toTypeScript()}(), + nodeConstructor: ${nodeObj}, + capabilities: ${JSON.stringify(this.config.capabilities, null, 2)} as const, + behaviors: ${JSON.stringify(this.behaviors, null, 2)} as const, + }; }`, `export type TypeConfig = { - layout: ${ext.getName()}; - nodeItem: ${int.getName()}; + category: ${CompCategory}.${this.config.category}, + layout: ${this.inner}; nodeObj: ${nodeObj}${nodeSuffix}; + plugins: ${pluginUnion}; }`, ]; @@ -187,26 +250,220 @@ export class ComponentConfig extends GenerateComponentLike { public generateDefClass(): string { const symbol = this.typeSymbol; const category = this.config.category; - const categorySymbol = CategoryImports[category].transformTo(Variant.Internal).toTypeScript(); - - const methods: string[] = []; - for (const [key, value] of Object.entries(this.config.capabilities)) { - if (key.startsWith('renderIn')) { - const name = key.replace('renderIn', ''); - const valueStr = JSON.stringify(value); - methods.push(`canRenderIn${name}(): ${valueStr} {\nreturn ${valueStr}; }`); - continue; + const categorySymbol = CategoryImports[category].toTypeScript(); + + const StateFactoryProps = new CG.import({ + import: 'StateFactoryProps', + from: 'src/utils/layout/types', + }); + + const BaseNodeData = new CG.import({ + import: 'BaseNodeData', + from: 'src/utils/layout/types', + }); + + const ExprResolver = new CG.import({ + import: 'ExprResolver', + from: 'src/layout/LayoutComponent', + }); + + const NodeGeneratorProps = new CG.import({ + import: 'NodeGeneratorProps', + from: 'src/layout/LayoutComponent', + }); + + const ReactJSX = new CG.import({ + import: 'JSX', + from: 'react', + }); + + const NodeGenerator = new CG.import({ + import: 'NodeGenerator', + from: 'src/utils/layout/generator/NodeGenerator', + }); + + const CompInternal = new CG.import({ + import: 'CompInternal', + from: 'src/layout/layout', + }); + + const BaseRow = new CG.import({ + import: 'BaseRow', + from: 'src/utils/layout/types', + }); + + const isFormComponent = this.config.category === CompCategory.Form; + const isSummarizable = this.behaviors.isSummarizable; + + const evalCommonProps = [ + { base: CG.common('ComponentBase'), condition: true, evaluator: 'evalBase' }, + { base: CG.common('FormComponentProps'), condition: isFormComponent, evaluator: 'evalFormProps' }, + { base: CG.common('SummarizableComponentProps'), condition: isSummarizable, evaluator: 'evalSummarizable' }, + ]; + + const evalLines: string[] = []; + const itemLine: string[] = []; + for (const { base, condition, evaluator } of evalCommonProps) { + if (condition) { + itemLine.push(`keyof ${base}`); + evalLines.push(`...props.${evaluator}(),`); } + } + + const pluginInstances = this.plugins.map((plugin) => { + const args = plugin.makeConstructorArgs(); + const instance = `new ${plugin.import}(${args})`; + return `'${plugin.getKey()}': ${instance}`; + }); + const pluginMap = pluginInstances.length ? `protected plugins = {${pluginInstances.join(',\n')}};` : ''; - throw new Error(`Unknown capability ${key}`); + function pluginRef(plugin: NodeDefPlugin): string { + return `this.plugins['${plugin.getKey()}']`; + } + + const pluginStateFactories = this.plugins + .filter((plugin) => plugin.stateFactory !== NodeDefPlugin.prototype.stateFactory) + .map((plugin) => `...${pluginRef(plugin)}.stateFactory(props as any),`) + .join('\n'); + + const pluginItemFactories = this.plugins + .filter((plugin) => plugin.itemFactory !== NodeDefPlugin.prototype.itemFactory) + .map((plugin) => `...${pluginRef(plugin)}.itemFactory(props as any)`) + .join(',\n'); + + const itemDef = pluginItemFactories + ? `const item = { ${pluginItemFactories} } as ${CompInternal}<'${this.type}'>;` + : ''; + + const pluginGeneratorChildren = this.plugins + .filter((plugin) => plugin.extraNodeGeneratorChildren !== NodeDefPlugin.prototype.extraNodeGeneratorChildren) + .map((plugin) => plugin.extraNodeGeneratorChildren()) + .join('\n'); + + const additionalMethods: string[] = []; + + if (!this.config.functionality.customExpressions) { + additionalMethods.push( + `// Do not override this one, set functionality.customExpressions to true instead + evalExpressions(props: ${ExprResolver}<'${this.type}'>) { + return this.evalDefaultExpressions(props); + }`, + ); + } + + if (this.hasDataModelBindings()) { + const LayoutValidationCtx = new CG.import({ + import: 'LayoutValidationCtx', + from: 'src/features/devtools/layoutValidation/types', + }); + additionalMethods.push( + `// You must implement this because the component has data model bindings defined + abstract validateDataModelBindings(ctx: ${LayoutValidationCtx}<'${this.type}'>): string[];`, + ); + } else if (this.isFormLike()) { + additionalMethods.push( + `// This component could have, but does not have any data model bindings defined + getDisplayData() { return ''; }`, + ); + } + + for (const plugin of this.plugins) { + const extraMethodsFromPlugin = plugin.extraMethodsInDef(); + additionalMethods.push(...extraMethodsFromPlugin); + + const extraInEval = plugin.extraInEvalExpressions(); + extraInEval && evalLines.push(extraInEval); + } + + const childrenPlugins = this.plugins.filter((plugin) => isNodeDefChildrenPlugin(plugin)); + if (childrenPlugins.length > 0) { + const ChildClaimerProps = new CG.import({ import: 'ChildClaimerProps', from: 'src/layout/LayoutComponent' }); + const NodeData = new CG.import({ import: 'NodeData', from: 'src/utils/layout/types' }); + const TraversalRestriction = new CG.import({ + import: 'TraversalRestriction', + from: 'src/utils/layout/useNodeTraversal', + }); + const LayoutNode = new CG.import({ import: 'LayoutNode', from: 'src/utils/layout/LayoutNode' }); + const ChildClaim = new CG.import({ import: 'ChildClaim', from: 'src/utils/layout/generator/GeneratorContext' }); + + const claimChildrenBody = childrenPlugins.map((plugin) => + `${pluginRef(plugin)}.claimChildren({ + ...props, + claimChild: (id: string, metadata: unknown) => + props.claimChild('${plugin.getKey()}', id, metadata), + });`.trim(), + ); + + const pickDirectChildrenBody = childrenPlugins.map( + (plugin) => `...${pluginRef(plugin)}.pickDirectChildren(state as any, restriction)`, + ); + + const isChildHiddenBody = childrenPlugins.map( + (plugin) => `${pluginRef(plugin)}.isChildHidden(state as any, childNode)`, + ); + + additionalMethods.push( + `claimChildren(props: ${ChildClaimerProps}<'${this.type}', unknown>) { + ${claimChildrenBody.join('\n')} + }`, + `pickDirectChildren(state: ${NodeData}<'${this.type}'>, restriction?: ${TraversalRestriction}) { + return [${pickDirectChildrenBody.join(', ')}]; + }`, + `addChild(state: ${NodeData}<'${this.type}'>, childNode: ${LayoutNode}, { pluginKey, metadata }: ${ChildClaim}, row: ${BaseRow} | undefined) { + return this.plugins[pluginKey!].addChild(state as any, childNode, metadata, row) as Partial<${NodeData}<'${this.type}'>>; + }`, + `removeChild(state: ${NodeData}<'${this.type}'>, childNode: ${LayoutNode}, { pluginKey, metadata }: ${ChildClaim}, row: ${BaseRow} | undefined) { + return this.plugins[pluginKey!].removeChild(state as any, childNode, metadata, row) as Partial<${NodeData}<'${this.type}'>>; + }`, + `isChildHidden(state: ${NodeData}<'${this.type}'>, childNode: ${LayoutNode}) { + return [${isChildHiddenBody.join(', ')}].some((h) => h); + }`, + ); } return `export abstract class ${symbol}Def extends ${categorySymbol}<'${this.type}'> { - ${methods.join('\n\n')} + protected readonly type = '${this.type}'; + ${pluginMap} + + ${this.config.directRendering ? 'directRender(): boolean { return true; }' : ''} + + renderNodeGenerator(props: ${NodeGeneratorProps}<'${this.type}'>): ${ReactJSX}.Element | null { + return ( + <${NodeGenerator} {...props}> + ${pluginGeneratorChildren} + + ); + } + + stateFactory(props: ${StateFactoryProps}<'${this.type}'>) { + const baseState: ${BaseNodeData}<'${this.type}'> = { + type: 'node', + item: undefined, + layout: props.item, + hidden: undefined, + row: props.row, + errors: undefined, + }; + ${itemDef} + + return { ...baseState, ${pluginStateFactories} ${itemDef ? 'item' : ''} }; + } + + // Do not override this one, set functionality.customExpressions to true instead + evalDefaultExpressions(props: ${ExprResolver}<'${this.type}'>) { + return { + ...props.item as Omit, + ${evalLines.join('\n')} + ...props.evalTrb(), + }; + } + + ${additionalMethods.join('\n\n')} }`; } public toJsonSchema(): JSONSchema7 { - return this.exportedComp.transformTo(Variant.External).toJsonSchema(); + this.beforeFinalizing(); + return this.inner.toJsonSchema(); } } diff --git a/src/codegen/Config.ts b/src/codegen/Config.ts new file mode 100644 index 0000000000..add068690d --- /dev/null +++ b/src/codegen/Config.ts @@ -0,0 +1,45 @@ +import type { CompCategory } from 'src/layout/common'; + +export interface RequiredComponentConfig { + category: CompCategory; + directRendering?: boolean; + capabilities: CompCapabilities; + functionality: FunctionalityConfig; +} + +export interface FunctionalityConfig { + /** + * If true, the component must implement its own evalExpressions() method, otherwise it will use the default + * implementation. + */ + customExpressions: boolean; +} + +/** + * Capabilities are configured directly when setting up a component config. You have to fill out each of the + * properties in the object. + * @see CompWithCap + * @see getComponentCapabilities + */ +export interface CompCapabilities { + renderInTable: boolean; + renderInButtonGroup: boolean; + renderInAccordion: boolean; + renderInAccordionGroup: boolean; + renderInTabs: boolean; + renderInCards: boolean; + renderInCardsMedia: boolean; +} + +/** + * Behaviors are more implicit, and are derived from the component config. I.e. when making a component summarizable, + * the behavior is set to true. + * @see CompWithBehavior + * @see getComponentBehavior + */ +export interface CompBehaviors { + isSummarizable: boolean; + canHaveLabel: boolean; + canHaveOptions: boolean; + canHaveAttachments: boolean; +} diff --git a/src/codegen/SerializableSetting.ts b/src/codegen/SerializableSetting.ts new file mode 100644 index 0000000000..5b9c279536 --- /dev/null +++ b/src/codegen/SerializableSetting.ts @@ -0,0 +1,8 @@ +/** + * This interface is used to define the structure of a setting that can be serialized. + * This is mostly used for Plugin settings that need to be passed as constructor arguments in generated classes. + */ +export interface SerializableSetting { + serializeToTypeScript(): string; + serializeToTypeDefinition(): string; +} diff --git a/src/codegen/dataTypes/GenerateArray.ts b/src/codegen/dataTypes/GenerateArray.ts index a1b3b50cbb..7744e36eec 100644 --- a/src/codegen/dataTypes/GenerateArray.ts +++ b/src/codegen/dataTypes/GenerateArray.ts @@ -1,8 +1,7 @@ import type { JSONSchema7 } from 'json-schema'; import { DescribableCodeGenerator } from 'src/codegen/CodeGenerator'; -import type { Variant } from 'src/codegen/CG'; -import type { CodeGenerator, Extract, MaybeSymbolizedCodeGenerator } from 'src/codegen/CodeGenerator'; +import type { CodeGenerator, Extract } from 'src/codegen/CodeGenerator'; /** * Generates an array with inner items of the given type @@ -44,21 +43,4 @@ export class GenerateArray> extends Describable maxItems: this._maxItems, }; } - - transformTo(variant: Variant): this | MaybeSymbolizedCodeGenerator { - if (this.currentVariant === variant) { - return this; - } - - const out = new GenerateArray(this.innerType.transformTo(variant)); - out.internal = structuredClone(this.internal); - out.internal.source = this; - out.currentVariant = variant; - - return out; - } - - containsVariationDifferences(): boolean { - return super.containsVariationDifferences() || this.innerType.containsVariationDifferences(); - } } diff --git a/src/codegen/dataTypes/GenerateCommonImport.ts b/src/codegen/dataTypes/GenerateCommonImport.ts index d2c98bf2f1..8ce681be0e 100644 --- a/src/codegen/dataTypes/GenerateCommonImport.ts +++ b/src/codegen/dataTypes/GenerateCommonImport.ts @@ -1,10 +1,9 @@ import type { JSONSchema7 } from 'json-schema'; -import { CG, VariantSuffixes } from 'src/codegen/CG'; +import { CG } from 'src/codegen/CG'; import { MaybeOptionalCodeGenerator } from 'src/codegen/CodeGenerator'; -import { commonContainsVariationDifferences, getSourceForCommon } from 'src/codegen/Common'; +import { getSourceForCommon } from 'src/codegen/Common'; import { GenerateObject } from 'src/codegen/dataTypes/GenerateObject'; -import type { Variant } from 'src/codegen/CG'; import type { CodeGeneratorWithProperties } from 'src/codegen/CodeGenerator'; import type { ValidCommonKeys } from 'src/codegen/Common'; import type { GenerateProperty } from 'src/codegen/dataTypes/GenerateProperty'; @@ -27,24 +26,6 @@ export class GenerateCommonImport this.realKey = realKey; } - transformTo(variant: Variant): this | GenerateCommonImport { - if (this.currentVariant === variant) { - return this; - } - - if (commonContainsVariationDifferences(this.key)) { - const out = new GenerateCommonImport(this.key, `${this.key}${VariantSuffixes[variant]}`); - out.internal = structuredClone(this.internal); - out.internal.source = this; - out.currentVariant = variant; - - return out; - } - - this.currentVariant = variant; - return this; - } - toJsonSchema(): JSONSchema7 { this.freeze('toJsonSchema'); return { $ref: `#/definitions/${this.key}` }; @@ -86,10 +67,6 @@ export class GenerateCommonImport } toTypeScriptDefinition(): string { - if (!this.currentVariant) { - throw new Error('Cannot generate TypeScript definition for common import without variant'); - } - const _import = new CG.import({ import: this.realKey ?? this.key, from: 'src/layout/common.generated', @@ -99,10 +76,6 @@ export class GenerateCommonImport return _import.toTypeScriptDefinition(undefined); } - containsVariationDifferences(): boolean { - return super.containsVariationDifferences() || commonContainsVariationDifferences(this.key); - } - getName(respectVariationDifferences = true): string { if (!respectVariationDifferences) { return this.key; diff --git a/src/codegen/dataTypes/GenerateComponentLike.ts b/src/codegen/dataTypes/GenerateComponentLike.ts deleted file mode 100644 index 31dec732f7..0000000000 --- a/src/codegen/dataTypes/GenerateComponentLike.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { CG } from 'src/codegen/CG'; -import { GenerateRaw } from 'src/codegen/dataTypes/GenerateRaw'; -import { GenerateUnion } from 'src/codegen/dataTypes/GenerateUnion'; -import type { GenerateCommonImport } from 'src/codegen/dataTypes/GenerateCommonImport'; -import type { GenerateObject } from 'src/codegen/dataTypes/GenerateObject'; -import type { GenerateProperty } from 'src/codegen/dataTypes/GenerateProperty'; -import type { GenerateTextResourceBinding } from 'src/codegen/dataTypes/GenerateTextResourceBinding'; - -/** - * A class that can be used to generate a component-like object. This is most likely used for advanced components - * where it is possible the base component configuration is a union of multiple possible configurations varying wildly. - * One example of this is Group (which can be a non-repeating group, a repeating group, etc) - * - * The way you should use this is to override the exported symbol of your component, and add new symbols of this type. - * I.e., you could override the exported symbol to a GenerateUnion-type, and adding multiples of this type to the union. - */ -export class GenerateComponentLike { - readonly inner = new CG.obj(); - - public addProperty(prop: GenerateProperty): this { - this.inner.addProperty(prop); - return this; - } - - private ensureTextResourceBindings(): void { - const existing = this.inner.getProperty('textResourceBindings'); - if (!existing || existing.type instanceof GenerateRaw) { - this.inner.addProperty(new CG.prop('textResourceBindings', new CG.obj().optional())); - } - } - - public addTextResource(arg: GenerateTextResourceBinding): this { - this.ensureTextResourceBindings(); - this.inner.getProperty('textResourceBindings')?.type.addProperty(arg); - - return this; - } - - public extendTextResources(type: GenerateCommonImport): this { - this.ensureTextResourceBindings(); - this.inner.getProperty('textResourceBindings')?.type.extends(type); - - return this; - } - - public addTextResourcesForLabel(): this { - return this.extendTextResources(CG.common('TRBLabel')); - } - - public makeSelectionComponent(full = true): this { - this.inner.extends(full ? CG.common('ISelectionComponentFull') : CG.common('ISelectionComponent')); - - return this; - } - - /** - * Adding multiple data model bindings to the component makes it a union - */ - public addDataModelBinding( - type: - | GenerateCommonImport< - | 'IDataModelBindingsSimple' - | 'IDataModelBindingsList' - | 'IDataModelBindingsOptionsSimple' - | 'IDataModelBindingsLikert' - > - | GenerateObject, - ): this { - const name = 'dataModelBindings'; - const existing = this.inner.getProperty(name)?.type; - if (existing && existing instanceof GenerateUnion) { - existing.addType(type); - } else if (existing && !(existing instanceof GenerateRaw)) { - const union = new CG.union(existing, type); - this.inner.addProperty(new CG.prop(name, union)); - } else { - this.inner.addProperty(new CG.prop(name, type)); - } - - return this; - } - - extends(type: GenerateCommonImport | GenerateComponentLike): this { - if (type instanceof GenerateComponentLike) { - this.inner.extends(type.inner); - return this; - } - - this.inner.extends(type); - return this; - } -} diff --git a/src/codegen/dataTypes/GenerateExpressionOr.ts b/src/codegen/dataTypes/GenerateExpressionOr.ts index 54f26851c5..a61dd296ff 100644 --- a/src/codegen/dataTypes/GenerateExpressionOr.ts +++ b/src/codegen/dataTypes/GenerateExpressionOr.ts @@ -1,12 +1,8 @@ import type { JSONSchema7 } from 'json-schema'; -import { CG, Variant } from 'src/codegen/CG'; import { DescribableCodeGenerator } from 'src/codegen/CodeGenerator'; import { CodeGeneratorContext } from 'src/codegen/CodeGeneratorContext'; import { ExprVal } from 'src/features/expressions/types'; -import type { GenerateBoolean } from 'src/codegen/dataTypes/GenerateBoolean'; -import type { GenerateNumber } from 'src/codegen/dataTypes/GenerateNumber'; -import type { GenerateString } from 'src/codegen/dataTypes/GenerateString'; const toTsMap: { [key in ExprVal]: string } = { [ExprVal.Any]: 'ExprValToActualOrExpr', @@ -30,14 +26,6 @@ type TypeMap = Val extends ExprVal.Boolean ? string : never; -type GeneratorMap = Val extends ExprVal.Boolean - ? GenerateBoolean - : Val extends ExprVal.Number - ? GenerateNumber - : Val extends ExprVal.String - ? GenerateString - : never; - /** * Generates a type that can be either a pure boolean, number, or string, or an expression that evaluates to * one of those types. Be sure you implement support for evaluating the expression as well, because adding @@ -48,38 +36,7 @@ export class GenerateExpressionOr extends DescribableCodeGe super(); } - transformTo(variant: Variant): GeneratorMap | this { - if (variant === Variant.External) { - this.currentVariant = variant; - return this; - } - - let out: GeneratorMap | undefined; - if (this.valueType === ExprVal.Boolean) { - out = new CG.bool() as GeneratorMap; - } - if (this.valueType === ExprVal.Number) { - out = new CG.num() as GeneratorMap; // Represents any number in TypeScript - } - if (this.valueType === ExprVal.String) { - out = new CG.str() as GeneratorMap; - } - - if (out) { - out.internal = structuredClone(this.internal) as any; - out.internal.source = this; - out.currentVariant = variant; - return out; - } - - throw new Error(`Unsupported type: ${this.valueType}`); - } - toTypeScriptDefinition(symbol: string | undefined): string { - if (this.currentVariant !== Variant.External) { - throw new Error('Cannot generate expr TypeScript definition for internal or non-transformed type'); - } - CodeGeneratorContext.curFile().addImport('ExprVal', 'src/features/expressions/types'); CodeGeneratorContext.curFile().addImport('ExprValToActualOrExpr', 'src/features/expressions/types'); return symbol ? `type ${symbol} = ${toTsMap[this.valueType]};` : toTsMap[this.valueType]; @@ -91,8 +48,4 @@ export class GenerateExpressionOr extends DescribableCodeGe ...toSchemaMap[this.valueType], }; } - - containsVariationDifferences(): boolean { - return true; - } } diff --git a/src/codegen/dataTypes/GenerateImportedSymbol.ts b/src/codegen/dataTypes/GenerateImportedSymbol.ts index 5610f2fa8f..a3df2aa7a8 100644 --- a/src/codegen/dataTypes/GenerateImportedSymbol.ts +++ b/src/codegen/dataTypes/GenerateImportedSymbol.ts @@ -1,8 +1,9 @@ import type { JSONSchema7 } from 'json-schema'; +import { CG } from 'src/codegen/CG'; import { MaybeOptionalCodeGenerator } from 'src/codegen/CodeGenerator'; import { CodeGeneratorContext } from 'src/codegen/CodeGeneratorContext'; -import type { Variant } from 'src/codegen/CG'; +import type { SerializableSetting } from 'src/codegen/SerializableSetting'; export interface ImportDef { import: string; @@ -13,14 +14,24 @@ export interface ImportDef { * Generates a plain import statement in TypeScript. Beware that if you use this in code generating a JsonSchema, * your code will fail (JsonSchema only supports imports from the definitions, i.e. 'common' imports). */ -export class GenerateImportedSymbol extends MaybeOptionalCodeGenerator { +export class GenerateImportedSymbol extends MaybeOptionalCodeGenerator implements SerializableSetting { public constructor(private readonly val: ImportDef) { super(); } - transformTo(variant: Variant): this | GenerateImportedSymbol { - this.currentVariant = variant; - return this; + serializeToTypeDefinition(): string { + return `${this}`; + } + + serializeToTypeScript(): string { + const _CG = new CG.import({ + import: 'CG', + from: 'src/codegen/CG', + }); + return `new ${_CG}.import<${this}>({ + import: ${JSON.stringify(this.val.import)}, + from: ${JSON.stringify(this.val.from)}, + })`; } toTypeScriptDefinition(symbol: string | undefined): string { diff --git a/src/codegen/dataTypes/GenerateIntersection.ts b/src/codegen/dataTypes/GenerateIntersection.ts index 76e7adb6b4..f21976adf8 100644 --- a/src/codegen/dataTypes/GenerateIntersection.ts +++ b/src/codegen/dataTypes/GenerateIntersection.ts @@ -11,14 +11,6 @@ export class GenerateIntersection[]> extends Descri this.types = types; } - containsVariationDifferences(): boolean { - if (super.containsVariationDifferences()) { - return true; - } - - return this.types.some((type) => type.containsVariationDifferences()); - } - toJsonSchemaDefinition(): JSONSchema7 { return { ...this.getInternalJsonSchema(), diff --git a/src/codegen/dataTypes/GenerateLinked.ts b/src/codegen/dataTypes/GenerateLinked.ts deleted file mode 100644 index dd2e63fbcd..0000000000 --- a/src/codegen/dataTypes/GenerateLinked.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { JSONSchema7 } from 'json-schema'; - -import { Variant } from 'src/codegen/CG'; -import { CodeGenerator } from 'src/codegen/CodeGenerator'; - -/** - * Generates a type that is one of two types, depending on the current variant of code we're generating (i.e. - * internal or external types). These variants could also be thought of as unresolved/resolved types, as all - * expressions are resolved in the internal types and unresolved in the external types (meaning external configuration - * can include expressions, but internal configuration have the evaluated results of these expressions). - * - * This is useful for 'children', for example, where the external type is a list of strings (component IDs), and the - * internal type is a list of LayoutNode objects (as generated by the hierarchy generator). - */ -export class GenerateLinked< - External extends CodeGenerator, - Internal extends CodeGenerator, -> extends CodeGenerator { - public ext: External; - public int: Internal; - - constructor(ext: External, int: Internal) { - super(); - this.ext = ext; - this.int = int; - } - - transformTo(variant: Variant): this { - this.currentVariant = variant; - if (variant === Variant.Internal) { - this.int = this.int.transformTo(variant) as Internal; - } else { - this.ext = this.ext.transformTo(variant) as External; - } - - return this; - } - - toTypeScript(): string { - if (!this.currentVariant) { - throw new Error('You need to transform this type to either external or internal before generating TypeScript'); - } - - return this.currentVariant === Variant.Internal ? this.int.toTypeScript() : this.ext.toTypeScript(); - } - - toJsonSchema(): JSONSchema7 { - if (!this.currentVariant) { - throw new Error('You need to transform this type to either external or internal before generating JsonSchema'); - } - - return this.currentVariant === Variant.Internal ? this.int.toJsonSchema() : this.ext.toJsonSchema(); - } - - containsVariationDifferences(): boolean { - return true; - } -} diff --git a/src/codegen/dataTypes/GenerateObject.ts b/src/codegen/dataTypes/GenerateObject.ts index 2ae1dec4b5..dc61420269 100644 --- a/src/codegen/dataTypes/GenerateObject.ts +++ b/src/codegen/dataTypes/GenerateObject.ts @@ -1,6 +1,6 @@ import type { JSONSchema7 } from 'json-schema'; -import { CG, Variant } from 'src/codegen/CG'; +import { CG } from 'src/codegen/CG'; import { DescribableCodeGenerator, MaybeOptionalCodeGenerator } from 'src/codegen/CodeGenerator'; import { getSourceForCommon } from 'src/codegen/Common'; import { GenerateCommonImport } from 'src/codegen/dataTypes/GenerateCommonImport'; @@ -76,12 +76,7 @@ export class GenerateObject

} hasProperty(name: string): boolean { - return this.properties.some((property) => { - if (property.name === name) { - const { onlyVariant } = property.toObject(); - return !onlyVariant || this.currentVariant === onlyVariant; - } - }); + return this.properties.some((property) => property.name === name); } addProperty(prop: GenerateProperty): this { @@ -141,50 +136,7 @@ export class GenerateObject

} getProperties(): GenerateProperty[] { - return this.properties.filter((property) => { - const { onlyVariant } = property.toObject(); - return !onlyVariant || this.currentVariant === onlyVariant; - }); - } - - transformTo(variant: Variant): GenerateObject { - if (this.currentVariant === variant) { - return this; - } - - const newProps: Props = []; - for (const prop of this.properties) { - if (!prop.shouldExistIn(variant)) { - continue; - } - - newProps.push(prop.transformTo(variant)); - } - - const next = new GenerateObject(...newProps); - next._additionalProperties = this._additionalProperties - ? (this._additionalProperties.transformTo(variant) as DescribableCodeGenerator) - : this._additionalProperties; - next._extends = this._extends.map((e) => e.transformTo(variant)); - next._extendedBy = this._extendedBy; - next.internal = structuredClone(this.internal); - next.internal.source = this; - next.currentVariant = variant; - - return next; - } - - containsVariationDifferences(): boolean { - if (super.containsVariationDifferences()) { - return true; - } - if (this.properties.some((prop) => prop.containsVariationDifferences())) { - return true; - } - if (this._additionalProperties && this._additionalProperties.containsVariationDifferences()) { - return true; - } - return this._extends.some((e) => e.containsVariationDifferences()); + return this.properties; } private ensureExtendsHaveNames() { @@ -221,25 +173,19 @@ export class GenerateObject

const adapted = new CG.intersection( prop.type, - ...parentsWithProp.map((e) => { - const out = new CG.raw({ - typeScript: `${e}['${prop.name}']`, - }); - out.currentVariant = this.currentVariant; - - return out; - }), + ...parentsWithProp.map( + (e) => + new CG.raw({ + typeScript: `${e}['${prop.name}']`, + }), + ), ); if (prop.type instanceof MaybeOptionalCodeGenerator && prop.type.isOptional()) { adapted.optional(); } - adapted.currentVariant = this.currentVariant; - - const newProp = new CG.prop(prop.name, adapted); - newProp.currentVariant = this.currentVariant; - return newProp; + return new CG.prop(prop.name, adapted); }); } @@ -277,7 +223,10 @@ export class GenerateObject

: `{ ${properties.join('\n')} }${extendsIntersection}`; } - private getPropertyList(): { all: { [key: string]: GenerateProperty }; required: string[] } { + private getPropertyList(target: 'typeScript' | 'jsonSchema'): { + all: { [key: string]: GenerateProperty }; + required: string[]; + } { const all: { [key: string]: GenerateProperty } = {}; const required: string[] = []; @@ -287,7 +236,7 @@ export class GenerateObject

throw new Error(`Cannot extend a non-object type`); } - const { all: allFromExtend, required: requiredFromExtend } = obj.getPropertyList(); + const { all: allFromExtend, required: requiredFromExtend } = obj.getPropertyList(target); for (const key of Object.keys(allFromExtend)) { const ourProp = this.getProperty(key); const theirProp = allFromExtend[key]; @@ -303,9 +252,10 @@ export class GenerateObject

} for (const prop of this.properties) { - if (!prop.shouldExistIn(Variant.External)) { + if (target === 'jsonSchema' && prop.shouldOmitInSchema()) { continue; } + all[prop.name] = prop; if (!(prop.type instanceof MaybeOptionalCodeGenerator) || !prop.type.isOptional()) { required.push(prop.name); @@ -322,7 +272,7 @@ export class GenerateObject

return { $ref: `#/definitions/${this._extends[0].getName(false)}` }; } - const { all: allProperties, required: requiredProperties } = this.getPropertyList(); + const { all: allProperties, required: requiredProperties } = this.getPropertyList('jsonSchema'); const allPropsAsTrue: { [key: string]: true } = {}; for (const key of Object.keys(allProperties)) { allPropsAsTrue[key] = true; @@ -357,19 +307,17 @@ export class GenerateObject

private innerToJsonSchema(respectAdditionalProperties = true): JSONSchema7 { const properties: { [key: string]: JSONSchema7 } = {}; + const requiredProps: string[] = []; for (const prop of this.properties) { - if (!prop.shouldExistIn(Variant.External)) { - // JsonSchema only supports external variants + if (prop.shouldOmitInSchema()) { continue; } - properties[prop.name] = prop.type.toJsonSchema(); - } - const requiredProps = this.properties - .filter((prop) => !(prop.type instanceof MaybeOptionalCodeGenerator) || !prop.type.isOptional()) - .filter((prop) => prop.shouldExistIn(Variant.External)) - .map((prop) => prop.name); + if (!(prop.type instanceof MaybeOptionalCodeGenerator) || !prop.type.isOptional()) { + requiredProps.push(prop.name); + } + } return { ...this.getInternalJsonSchema(), diff --git a/src/codegen/dataTypes/GenerateProperty.ts b/src/codegen/dataTypes/GenerateProperty.ts index 690ca044c1..0485405dbe 100644 --- a/src/codegen/dataTypes/GenerateProperty.ts +++ b/src/codegen/dataTypes/GenerateProperty.ts @@ -1,7 +1,6 @@ import type { JSONSchema7 } from 'json-schema'; import { CodeGenerator, MaybeOptionalCodeGenerator } from 'src/codegen/CodeGenerator'; -import type { Variant } from 'src/codegen/CG'; import type { Extract } from 'src/codegen/CodeGenerator'; /** @@ -12,8 +11,8 @@ export class GenerateProperty> extends CodeGenera private _insertBefore?: string; private _insertAfter?: string; private _insertFirst = false; - private _onlyVariant?: Variant; private _added = false; + private _inSchema = true; constructor( public readonly name: string, @@ -58,14 +57,13 @@ export class GenerateProperty> extends CodeGenera return this; } - onlyIn(variant: Variant): this { - this.ensureMutable(); - this._onlyVariant = variant; + omitInSchema(): this { + this._inSchema = false; return this; } - shouldExistIn(variant: Variant): boolean { - return !this._onlyVariant || this._onlyVariant === variant; + shouldOmitInSchema(): boolean { + return !this._inSchema; } toObject() { @@ -74,53 +72,10 @@ export class GenerateProperty> extends CodeGenera insertBefore: this._insertBefore, insertAfter: this._insertAfter, insertFirst: this._insertFirst, - onlyVariant: this._onlyVariant, }; } - containsVariationDifferences(): boolean { - if (super.containsVariationDifferences()) { - return true; - } - - if (this._onlyVariant) { - return true; - } - - return this.type.containsVariationDifferences(); - } - - transformTo(variant: Variant): GenerateProperty { - if (this._onlyVariant && this._onlyVariant !== variant) { - throw new Error( - 'Cannot transform to target variant when the property is not supposed to be present in this ' + - 'variants. This is probably a bug, as the property should have been filtered out before this point.', - ); - } - - if (this.currentVariant === variant) { - return this; - } - - const transformedType = this.type.transformTo(variant); - const next = new GenerateProperty(this.name, transformedType); - next._insertFirst = this._insertFirst; - next._insertBefore = this._insertBefore; - next._insertAfter = this._insertAfter; - next._onlyVariant = this._onlyVariant; - next._added = this._added; - next.internal = structuredClone(this.internal); - next.internal.source = this; - next.currentVariant = variant; - - return next; - } - toTypeScript() { - if (!this.currentVariant) { - throw new Error('You need to transform this type to either external or internal before generating TypeScript'); - } - return this.type instanceof MaybeOptionalCodeGenerator && this.type.isOptional() ? `${this.name}?: ${this.type.toTypeScript()};` : `${this.name}: ${this.type.toTypeScript()};`; diff --git a/src/codegen/dataTypes/GenerateRaw.ts b/src/codegen/dataTypes/GenerateRaw.ts index fb3b1b6ac4..2c3851ade0 100644 --- a/src/codegen/dataTypes/GenerateRaw.ts +++ b/src/codegen/dataTypes/GenerateRaw.ts @@ -1,7 +1,6 @@ import type { JSONSchema7 } from 'json-schema'; import { CodeGenerator, MaybeOptionalCodeGenerator } from 'src/codegen/CodeGenerator'; -import type { Variant } from 'src/codegen/CG'; type RawTypeScript = { typeScript: string | (() => string) | CodeGenerator; @@ -74,34 +73,4 @@ export class GenerateRaw extends MaybeOptionalCodeGenerator { toTypeScriptDefinition(_symbol: string | undefined): string { throw new Error('Method not implemented.'); } - - containsVariationDifferences(): boolean { - const realTypeScript = this.getRealTypeScript(false); - if (realTypeScript instanceof CodeGenerator && realTypeScript.containsVariationDifferences()) { - return true; - } - - const realJsonSchema = this.getRealJsonSchema(false); - if (realJsonSchema instanceof CodeGenerator && realJsonSchema.containsVariationDifferences()) { - return true; - } - - return super.containsVariationDifferences(); - } - - transformTo(variant: Variant): this { - const realTypeScript = this.getRealTypeScript(false); - if (realTypeScript instanceof CodeGenerator) { - this.realTypeScript = realTypeScript.transformTo(variant); - } - - const realJsonSchema = this.getRealJsonSchema(false); - if (realJsonSchema instanceof CodeGenerator) { - this.realJsonSchema = realJsonSchema.transformTo(variant); - } - - this.currentVariant = variant; - - return this; - } } diff --git a/src/codegen/dataTypes/GenerateTextResourceBinding.ts b/src/codegen/dataTypes/GenerateTextResourceBinding.ts index 2a4ea2c600..0a6fb8063e 100644 --- a/src/codegen/dataTypes/GenerateTextResourceBinding.ts +++ b/src/codegen/dataTypes/GenerateTextResourceBinding.ts @@ -1,4 +1,4 @@ -import { CG, Variant } from 'src/codegen/CG'; +import { CG } from 'src/codegen/CG'; import { GenerateProperty } from 'src/codegen/dataTypes/GenerateProperty'; import { ExprVal } from 'src/features/expressions/types'; import type { GenerateExpressionOr } from 'src/codegen/dataTypes/GenerateExpressionOr'; @@ -14,27 +14,8 @@ export interface TextResourceConfig { * helper to make sure you always provide a description and title, and never specify the inner type yourself. */ export class GenerateTextResourceBinding extends GenerateProperty> { - private readonly externalProp: GenerateExpressionOr; - constructor(config: TextResourceConfig) { const actualProp = new CG.expr(ExprVal.String).optional().setTitle(config.title).setDescription(config.description); super(config.name, actualProp); - this.externalProp = actualProp; - } - - containsVariationDifferences(): boolean { - return true; - } - - toTypeScript(): string { - throw new Error('Not transformed to any variant yet - please call transformTo(variant) first'); - } - - transformTo(variant: Variant): GenerateProperty { - if (variant === Variant.External) { - return new CG.prop(this.name, this.externalProp).transformTo(variant); - } - - return new CG.prop(this.name, new CG.str().optional()).transformTo(variant); } } diff --git a/src/codegen/dataTypes/GenerateUnion.ts b/src/codegen/dataTypes/GenerateUnion.ts index d2d1751a42..34db954445 100644 --- a/src/codegen/dataTypes/GenerateUnion.ts +++ b/src/codegen/dataTypes/GenerateUnion.ts @@ -1,8 +1,7 @@ import type { JSONSchema7 } from 'json-schema'; import { DescribableCodeGenerator, MaybeOptionalCodeGenerator } from 'src/codegen/CodeGenerator'; -import type { Variant } from 'src/codegen/CG'; -import type { CodeGenerator, Extract, MaybeSymbolizedCodeGenerator } from 'src/codegen/CodeGenerator'; +import type { CodeGenerator, Extract } from 'src/codegen/CodeGenerator'; /** * Generates a union of multiple types. In typescript this is a regular union, and in JsonSchema it is an 'anyOf'. @@ -20,28 +19,6 @@ export class GenerateUnion[]> extends DescribableCo this.types.push(type as any); } - containsVariationDifferences(): boolean { - if (super.containsVariationDifferences()) { - return true; - } - - return this.types.some((type) => type.containsVariationDifferences()); - } - - transformTo(variant: Variant): this | MaybeSymbolizedCodeGenerator { - if (this.currentVariant === variant) { - return this; - } - - const types = this.types.map((type) => type.transformTo(variant)); - const out = new GenerateUnion(...types); - out.internal = structuredClone(this.internal); - out.internal.source = this; - out.currentVariant = variant; - - return out; - } - toTypeScriptDefinition(symbol: string | undefined): string { const out = this.types.map((type) => type.toTypeScript()).join(' | '); diff --git a/src/codegen/run.ts b/src/codegen/run.ts index b201ee0c35..cc9dd5d30a 100644 --- a/src/codegen/run.ts +++ b/src/codegen/run.ts @@ -15,9 +15,16 @@ async function getComponentList() { const files = await fs.readdir('src/layout'); for (const file of files) { const stat = await fs.stat(path.join('src/layout', file)); - if (stat.isDirectory()) { - out[file] = file; + if (!stat.isDirectory()) { + continue; } + + const filesInside = await fs.readdir(path.join('src/layout', file)); + if (filesInside.length === 0) { + continue; + } + + out[file] = file; } return out; @@ -29,14 +36,16 @@ async function getComponentList() { const componentIndex = [ '// This file is generated by running `yarn gen`', '', - ...sortedKeys.map((key) => `import { Config as ${key}Config } from 'src/layout/${key}/config.generated';`), + ...sortedKeys.map((key) => `import { getConfig as get${key}Config } from 'src/layout/${key}/config.generated';`), ...sortedKeys.map( (key) => `import type { TypeConfig as ${key}TypeConfig } from 'src/layout/${key}/config.generated';`, ), '', - `export const ComponentConfigs = {`, - ...sortedKeys.map((key) => ` ${componentList[key]}: ${key}Config,`), - `};`, + `export function getComponentConfigs() {`, + ` return {`, + ...sortedKeys.map((key) => ` ${componentList[key]}: get${key}Config(),`), + ` };`, + `}`, '', `export type ComponentTypeConfigs = {`, ...sortedKeys.map((key) => ` ${componentList[key]}: ${key}TypeConfig;`), @@ -53,7 +62,7 @@ async function getComponentList() { const configMap: { [key: string]: ComponentConfig } = {}; for (const key of sortedKeys) { const tsPathConfig = `src/layout/${key}/config.generated.ts`; - const tsPathDef = `src/layout/${key}/config.def.generated.ts`; + const tsPathDef = `src/layout/${key}/config.def.generated.tsx`; const content = await CodeGeneratorContext.generateTypeScript(tsPathConfig, () => { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -62,7 +71,10 @@ async function getComponentList() { configMap[key] = config; return config.generateConfigFile(); }); - const defClass = await CodeGeneratorContext.generateTypeScript(tsPathDef, () => configMap[key].generateDefClass()); + const defClass = await CodeGeneratorContext.generateTypeScript(tsPathDef, () => { + const def = configMap[key].generateDefClass(); + return `import React from 'react';\n\n${def}`; + }); promises.push(saveTsFile(tsPathConfig, content)); promises.push(saveTsFile(tsPathDef, defClass)); diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 4f421e0e7a..3a22b421e5 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import type { ErrorInfo } from 'react'; import { DisplayError } from 'src/core/errorHandling/DisplayError'; @@ -17,10 +16,6 @@ export class ErrorBoundary extends React.Component; diff --git a/src/components/altinnAppHeader.test.tsx b/src/components/altinnAppHeader.test.tsx index 2ca5349935..34d334ed83 100644 --- a/src/components/altinnAppHeader.test.tsx +++ b/src/components/altinnAppHeader.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { screen } from '@testing-library/react'; import { getOrganisationMock } from 'src/__mocks__/getOrganisationMock'; -import { getProfileMock, getProfileStateMock } from 'src/__mocks__/getProfileMock'; +import { getProfileMock } from 'src/__mocks__/getProfileMock'; import { AltinnAppHeader } from 'src/components/altinnAppHeader'; import { renderWithoutInstanceAndLayout } from 'src/test/renderWithProviders'; import type { IHeaderProps } from 'src/components/altinnAppHeader'; @@ -46,10 +46,10 @@ describe('AltinnAppHeader', () => { }); const render = async (props: Partial = {}) => { - const profile = getProfileStateMock(); + const profile = getProfileMock(); const allProps = { - profile: profile.profile, + profile, ...props, }; diff --git a/src/components/form/Form.tsx b/src/components/form/Form.tsx index 207580b5fb..f17f60420f 100644 --- a/src/components/form/Form.tsx +++ b/src/components/form/Form.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { Navigate, useLocation, useSearchParams } from 'react-router-dom'; +import { Navigate, useLocation } from 'react-router-dom'; import Grid from '@material-ui/core/Grid'; import deepEqual from 'fast-deep-equal'; @@ -14,12 +14,20 @@ import { useExpandedWidthLayouts } from 'src/features/form/layout/LayoutsContext import { useNavigateToNode, useRegisterNodeNavigationHandler } from 'src/features/form/layout/NavigateToNode'; import { useUiConfigContext } from 'src/features/form/layout/UiConfigContext'; import { usePageSettings } from 'src/features/form/layoutSettings/LayoutSettingsContext'; +import { + useNavigate, + useNavigationParam, + useQueryKey, + useQueryKeysAsStringAsRef, +} from 'src/features/routing/AppRoutingContext'; import { FrontendValidationSource } from 'src/features/validation'; import { useTaskErrors } from 'src/features/validation/selectors/taskErrors'; -import { SearchParams, useCurrentView, useNavigatePage } from 'src/hooks/useNavigatePage'; +import { SearchParams, useCurrentView, useNavigatePage, useStartUrl } from 'src/hooks/useNavigatePage'; import { GenericComponentById } from 'src/layout/GenericComponent'; -import { extractBottomButtons, hasRequiredFields } from 'src/utils/formLayout'; -import { useNodesMemoSelector, useResolvedNode } from 'src/utils/layout/NodesContext'; +import { extractBottomButtons } from 'src/utils/formLayout'; +import { useNode } from 'src/utils/layout/NodesContext'; +import { useNodeTraversal } from 'src/utils/layout/useNodeTraversal'; +import type { NodeData } from 'src/utils/layout/types'; interface FormState { hasRequired: boolean; @@ -42,11 +50,12 @@ export function Form() { useRedirectToStoredPage(); useSetExpandedWidth(); - useRegisterNodeNavigationHandler((targetNode) => { - const targetView = targetNode?.top.top.myKey; + useRegisterNodeNavigationHandler(async (targetNode, options) => { + const targetView = targetNode?.pageKey; if (targetView && targetView !== currentPageId) { - navigateToPage(targetView, { - shouldFocusComponent: true, + await navigateToPage(targetView, { + ...options?.pageNavOptions, + shouldFocusComponent: options?.shouldFocus ?? options?.pageNavOptions?.shouldFocusComponent ?? true, replace: window.location.href.includes(SearchParams.FocusComponentId), }); return true; @@ -106,10 +115,10 @@ export function Form() { } export function FormFirstPage() { - const { startUrl, queryKeys } = useNavigatePage(); + const startUrl = useStartUrl(); return ( ); @@ -121,7 +130,10 @@ export function FormFirstPage() { * it is no longer needed. */ function useRedirectToStoredPage() { - const { currentPageId, partyId, instanceGuid, isValidPageId, navigateToPage } = useNavigatePage(); + const pageKey = useCurrentView(); + const partyId = useNavigationParam('partyId'); + const instanceGuid = useNavigationParam('instanceGuid'); + const { isValidPageId, navigateToPage } = useNavigatePage(); const applicationMetadataId = useApplicationMetadata()?.id; const location = useLocation().pathname; @@ -129,14 +141,14 @@ function useRedirectToStoredPage() { const currentViewCacheKey = instanceId || applicationMetadataId; useEffect(() => { - if (!currentPageId && !!currentViewCacheKey) { + if (!pageKey && !!currentViewCacheKey) { const lastVisitedPage = localStorage.getItem(currentViewCacheKey); if (lastVisitedPage !== null && isValidPageId(lastVisitedPage)) { localStorage.removeItem(currentViewCacheKey); navigateToPage(lastVisitedPage, { replace: true }); } } - }, [currentPageId, currentViewCacheKey, isValidPageId, location, navigateToPage]); + }, [pageKey, currentViewCacheKey, isValidPageId, location, navigateToPage]); } /** @@ -164,35 +176,45 @@ interface ErrorProcessingProps { setFormState: React.Dispatch>; } +function nodeDataIsRequired(n: NodeData) { + const item = n.item; + return !!(item && 'required' in item && item.required === true); +} + /** * Instead of re-rendering the entire Form component when any of this changes, we just report the * state to the parent component. */ function ErrorProcessing({ setFormState }: ErrorProcessingProps) { const currentPageId = useCurrentView(); - const topLevelNodeIds = useNodesMemoSelector( - (nodes) => - nodes - .findLayout(currentPageId) - ?.children() - .map((n) => n.item.id) || emptyArray, - ); - const hasRequired = useNodesMemoSelector((nodes) => { - const page = nodes.findLayout(currentPageId); - return page ? hasRequiredFields(page) : false; + const page = useNodeTraversal((traverser) => traverser.findPage(currentPageId)); + + const topLevelNodeIds = useNodeTraversal((traverser) => { + if (!page) { + return emptyArray; + } + + const all = traverser.with(page).children(); + return all.map((n) => n.id); + }); + + const hasRequired = useNodeTraversal((traverser) => { + if (!page) { + return false; + } + return traverser.with(page).flat((n) => n.type === 'node' && nodeDataIsRequired(n)).length > 0; }); const { formErrors, taskErrors } = useTaskErrors(); const hasErrors = Boolean(formErrors.length) || Boolean(taskErrors.length); - const [mainIds, errorReportIds] = useNodesMemoSelector((nodes) => { - const page = nodes.findLayout(currentPageId); + const [mainIds, errorReportIds] = useNodeTraversal((traverser) => { if (!hasErrors || !page) { return [topLevelNodeIds, []]; } - return extractBottomButtons(page); + return extractBottomButtons(traverser.with(page).children()); }); const requiredFieldsMissing = formErrors.some( - (error) => error.source === FrontendValidationSource.EmptyField && error.pageKey === currentPageId, + (error) => error.source === FrontendValidationSource.EmptyField && error.node.pageKey === currentPageId, ); useEffect(() => { @@ -219,22 +241,24 @@ function ErrorProcessing({ setFormState }: ErrorProcessingProps) { } function HandleNavigationFocusComponent() { - const [searchParams, setSearchParams] = useSearchParams(); - - const componentId = searchParams.get(SearchParams.FocusComponentId); - const focusNode = useResolvedNode(componentId); + const searchStringRef = useQueryKeysAsStringAsRef(); + const componentId = useQueryKey(SearchParams.FocusComponentId); + const focusNode = useNode(componentId ?? undefined); const navigateTo = useNavigateToNode(); + const navigate = useNavigate(); React.useEffect(() => { - searchParams.delete(SearchParams.FocusComponentId); - setSearchParams(searchParams, { replace: true, preventScrollReset: true }); - }, [searchParams, setSearchParams]); - - React.useEffect(() => { - if (focusNode != null) { - navigateTo(focusNode); - } - }, [navigateTo, focusNode]); + (async () => { + if (focusNode) { + await navigateTo(focusNode, { shouldFocus: true }); + const location = new URLSearchParams(searchStringRef.current); + location.delete(SearchParams.FocusComponentId); + const baseHash = window.location.hash.slice(1).split('?')[0]; + const nextLocation = location.size > 0 ? `${baseHash}?${location.toString()}` : baseHash; + navigate(nextLocation, { replace: true }); + } + })(); + }, [navigateTo, focusNode, navigate, searchStringRef]); return null; } diff --git a/src/components/form/LinkToPotentialNode.tsx b/src/components/form/LinkToPotentialNode.tsx index 8e5814fd61..7c4e55328a 100644 --- a/src/components/form/LinkToPotentialNode.tsx +++ b/src/components/form/LinkToPotentialNode.tsx @@ -3,7 +3,7 @@ import { Link } from 'react-router-dom'; import type { LinkProps } from 'react-router-dom'; import { SearchParams } from 'src/hooks/useNavigatePage'; -import { useResolvedNode } from 'src/utils/layout/NodesContext'; +import { Hidden, useNode } from 'src/utils/layout/NodesContext'; type Props = LinkProps & { children?: React.ReactNode }; @@ -19,10 +19,10 @@ export const LinkToPotentialNode = (props: Props) => { const searchParams = typeof to === 'string' ? to.split('?').at(1) : to.search; const componentId = new URLSearchParams(searchParams).get(SearchParams.FocusComponentId); - const resolvedNode = useResolvedNode(componentId); + const resolvedNode = useNode(componentId ?? undefined); const nodeExists = resolvedNode != null; - const isNodeHidden = resolvedNode?.isHidden(); + const isNodeHidden = Hidden.useIsHidden(resolvedNode); const shouldShowLink = nodeExists && !isNodeHidden; if (shouldShowLink) { diff --git a/src/components/form/LinkToPotentialPage.tsx b/src/components/form/LinkToPotentialPage.tsx index 27994f53d8..a8d72e5fe6 100644 --- a/src/components/form/LinkToPotentialPage.tsx +++ b/src/components/form/LinkToPotentialPage.tsx @@ -2,31 +2,28 @@ import React from 'react'; import { Link } from 'react-router-dom'; import type { LinkProps } from 'react-router-dom'; -import { useIsHiddenPage } from 'src/features/form/layout/PageNavigationContext'; import { useNavigatePage } from 'src/hooks/useNavigatePage'; +import { Hidden } from 'src/utils/layout/NodesContext'; type Props = LinkProps & { children?: React.ReactNode }; /** * This component is used to navigate to a potential page. If the page it is supposed * to navigate to does not exist or the page is hidden, the link will turn into pure text. - * @param props - * @constructor */ export const LinkToPotentialPage = (props: Props) => { const parts = props.to.toString().split('/') ?? []; const page = parts[parts.length - 1]; - const isHiddenPage = useIsHiddenPage(); + const isHiddenPage = Hidden.useIsHiddenPage(page); const { isValidPageId } = useNavigatePage(); - const shouldShowLink = isValidPageId(page) && !isHiddenPage(page); - + const shouldShowLink = isValidPageId(page) && !isHiddenPage; if (shouldShowLink) { return ; } - if (isHiddenPage(page)) { + if (isHiddenPage) { window.logWarnOnce( `linkToPage points to a page that is hidden. The link is therefore rendered as pure text. Page you tried to link to: ${page}`, ); diff --git a/src/components/form/RadioButton.tsx b/src/components/form/RadioButton.tsx index fae10f00d1..33b7343bba 100644 --- a/src/components/form/RadioButton.tsx +++ b/src/components/form/RadioButton.tsx @@ -5,9 +5,9 @@ import type { RadioProps } from '@digdir/designsystemet-react'; import { ConditionalWrapper } from 'src/components/ConditionalWrapper'; import classes from 'src/components/form/RadioButton.module.css'; -import { DeleteWarningPopover } from 'src/components/molecules/DeleteWarningPopover'; +import { DeleteWarningPopover } from 'src/features/alertOnChange/DeleteWarningPopover'; +import { useAlertOnChange } from 'src/features/alertOnChange/useAlertOnChange'; import { useLanguage } from 'src/features/language/useLanguage'; -import { useAlertOnChange } from 'src/hooks/useAlertOnChange'; export interface IRadioButtonProps extends Omit { showAsCard?: boolean; diff --git a/src/components/label/Label.tsx b/src/components/label/Label.tsx index c5010bfe25..e4ffdd0ea1 100644 --- a/src/components/label/Label.tsx +++ b/src/components/label/Label.tsx @@ -8,46 +8,53 @@ import type { LabelProps as DesignsystemetLabelProps } from '@digdir/designsyste import classes from 'src/components/label/Label.module.css'; import { LabelContent } from 'src/components/label/LabelContent'; +import { useFormComponentCtx } from 'src/layout/FormComponentContext'; import { gridBreakpoints } from 'src/utils/formComponentUtils'; +import { useNodeItem } from 'src/utils/layout/useNodeItem'; import type { LabelContentProps } from 'src/components/label/LabelContent'; -import type { IGridStyling, ILabelSettings } from 'src/layout/common.generated'; +import type { ExprResolved } from 'src/features/expressions/types'; +import type { IGridStyling, TRBLabel } from 'src/layout/common.generated'; +import type { LayoutNode } from 'src/utils/layout/LayoutNode'; type LabelType = 'legend' | 'span' | 'label' | 'plainLabel'; export type LabelProps = PropsWithChildren<{ - id: string; + node: LayoutNode; renderLabelAs: LabelType; - required?: boolean; - readOnly?: boolean; - labelSettings?: ILabelSettings; - grid?: { labelGrid?: IGridStyling }; - textResourceBindings?: { - title?: string; - description?: string; - help?: string; - }; className?: string; + + id?: string; + textResourceBindings?: ExprResolved; }> & DesignsystemetLabelProps; export function Label(props: LabelProps) { - if (!props.textResourceBindings?.title) { - return <>{props.children}; - } - const { children, ...propsWithoutChildren } = props; const { - id, + node, renderLabelAs, - required, - readOnly, - labelSettings, - grid, - textResourceBindings, className, + id: overriddenId, + textResourceBindings: overriddenTrb, ...designsystemetLabelProps } = props; + const overrideItemProps = useFormComponentCtx()?.overrideItemProps; + const _item = useNodeItem(node); + const item = { ..._item, ...overrideItemProps }; + const { id: nodeId, grid, textResourceBindings: _trb } = item; + const required = 'required' in item && item.required; + const readOnly = 'readOnly' in item && item.readOnly; + const labelSettings = 'labelSettings' in item ? item.labelSettings : undefined; + + // These can be overridden by props, but are otherwise retrieved from the node item + const id = overriddenId ?? nodeId; + const textResourceBindings = (overriddenTrb ?? _trb) as ExprResolved | undefined; + + if (!textResourceBindings?.title) { + return <>{children}; + } + const labelId = `label-${id}`; const labelContentProps: Omit = { label: textResourceBindings.title, diff --git a/src/components/message/ErrorReport.tsx b/src/components/message/ErrorReport.tsx index de16b1051d..6aedf7d483 100644 --- a/src/components/message/ErrorReport.tsx +++ b/src/components/message/ErrorReport.tsx @@ -9,7 +9,7 @@ import { useNavigateToNode } from 'src/features/form/layout/NavigateToNode'; import { Lang } from 'src/features/language/Lang'; import { useTaskErrors } from 'src/features/validation/selectors/taskErrors'; import { GenericComponentById } from 'src/layout/GenericComponent'; -import { useNodesAsRef } from 'src/utils/layout/NodesContext'; +import { Hidden } from 'src/utils/layout/NodesContext'; import { useGetUniqueKeyFromObject } from 'src/utils/useGetKeyFromObject'; import type { NodeValidation } from 'src/features/validation'; @@ -23,26 +23,28 @@ const ArrowForwardSvg = ` { - const allNodesRef = useNodesAsRef(); const { formErrors, taskErrors } = useTaskErrors(); const hasErrors = Boolean(formErrors.length) || Boolean(taskErrors.length); const navigateTo = useNavigateToNode(); const getUniqueKeyFromObject = useGetUniqueKeyFromObject(); + const isHidden = Hidden.useIsHiddenSelector(); + if (!hasErrors) { return null; } const handleErrorClick = (error: NodeValidation) => async (ev: React.KeyboardEvent | React.MouseEvent) => { + const { node } = error; if (ev.type === 'keydown' && (ev as React.KeyboardEvent).key !== 'Enter') { return; } ev.preventDefault(); - const componentNode = allNodesRef.current.findById(error.componentId); - if (!componentNode || componentNode.isHidden()) { + if (isHidden(node)) { // No point in trying to focus on a hidden component return; } - await navigateTo(componentNode, true, error); + + await navigateTo(node, { shouldFocus: true, error }); }; return ( @@ -88,7 +90,7 @@ export const ErrorReport = ({ renderIds }: IErrorReportProps) => { diff --git a/src/components/presentation/NavBar.tsx b/src/components/presentation/NavBar.tsx index 65c73707c4..7e5a56bdc4 100644 --- a/src/components/presentation/NavBar.tsx +++ b/src/components/presentation/NavBar.tsx @@ -11,7 +11,7 @@ import { useUiConfigContext } from 'src/features/form/layout/UiConfigContext'; import { usePageSettings } from 'src/features/form/layoutSettings/LayoutSettingsContext'; import { useLanguage } from 'src/features/language/useLanguage'; import { useCurrentParty } from 'src/features/party/PartiesProvider'; -import { useNavigatePage } from 'src/hooks/useNavigatePage'; +import { useNavigatePage, usePreviousPageKey } from 'src/hooks/useNavigatePage'; import { PresentationType, ProcessTaskType } from 'src/types'; import { httpGet } from 'src/utils/network/networking'; import { getRedirectUrl } from 'src/utils/urls/appUrlHelper'; @@ -25,7 +25,8 @@ const expandIconStyle = { transform: 'rotate(45deg)' }; export const NavBar = ({ type }: INavBarProps) => { const { langAsString } = useLanguage(); - const { navigateToPage, previous } = useNavigatePage(); + const previous = usePreviousPageKey(); + const { navigateToPage } = useNavigatePage(); const returnToView = useReturnToView(); const party = useCurrentParty(); const { expandedWidth, toggleExpandedWidth } = useUiConfigContext(); diff --git a/src/components/presentation/Presentation.tsx b/src/components/presentation/Presentation.tsx index e16e4aa336..48f036feb4 100644 --- a/src/components/presentation/Presentation.tsx +++ b/src/components/presentation/Presentation.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import type { PropsWithChildren } from 'react'; import Grid from '@material-ui/core/Grid'; @@ -19,7 +19,6 @@ import { useLaxInstanceData } from 'src/features/instance/InstanceContext'; import { Lang } from 'src/features/language/Lang'; import { useCurrentParty } from 'src/features/party/PartiesProvider'; import { useProfile } from 'src/features/profile/ProfileProvider'; -import { useNavigationEffectStore, useNavigationParams } from 'src/hooks/useNavigatePage'; import { AltinnAppTheme } from 'src/theme/altinnAppTheme'; import { ProcessTaskType } from 'src/types'; import type { PresentationType } from 'src/types'; @@ -28,22 +27,13 @@ export interface IPresentationProvidedProps extends PropsWithChildren { header?: React.ReactNode; type: ProcessTaskType | PresentationType; renderNavBar?: boolean; - runNavigationEffect?: boolean; } -export const PresentationComponent = ({ - header, - type, - children, - renderNavBar = true, - runNavigationEffect = true, -}: IPresentationProvidedProps) => { +export const PresentationComponent = ({ header, type, children, renderNavBar = true }: IPresentationProvidedProps) => { const party = useCurrentParty(); const instance = useLaxInstanceData(); const userParty = useProfile()?.party; const { expandedWidth } = useUiConfigContext(); - const { pageKey } = useNavigationParams(); - const navigationEffect = useNavigationEffectStore((state) => state.callback); const realHeader = header || (type === ProcessTaskType.Archived ? : undefined); @@ -53,13 +43,6 @@ export const PresentationComponent = ({ : AltinnAppTheme.altinnPalette.primary.greyLight; document.body.style.background = backgroundColor; - useEffect(() => { - if (!runNavigationEffect) { - return; - } - navigationEffect?.(); - }, [pageKey, navigationEffect, runNavigationEffect]); - return (

{ - const { currentPageId, order } = useNavigatePage(); + const currentPageId = useNavigationParam('pageKey'); + const { order } = useNavigatePage(); const { langAsString } = useLanguage(); const currentPageIndex = order?.findIndex((page) => page === currentPageId) || 0; diff --git a/src/components/wrappers/ProcessWrapper.tsx b/src/components/wrappers/ProcessWrapper.tsx index 0831f98387..febf05e686 100644 --- a/src/components/wrappers/ProcessWrapper.tsx +++ b/src/components/wrappers/ProcessWrapper.tsx @@ -9,7 +9,6 @@ import { Form, FormFirstPage } from 'src/components/form/Form'; import { PresentationComponent } from 'src/components/presentation/Presentation'; import classes from 'src/components/wrappers/ProcessWrapper.module.css'; import { useCurrentDataModelGuid } from 'src/features/datamodel/useBindingSchema'; -import { LayoutValidationProvider } from 'src/features/devtools/layoutValidation/useLayoutValidation'; import { FormProvider } from 'src/features/form/FormContext'; import { useLayoutSets } from 'src/features/form/layoutSets/LayoutSetsProvider'; import { useLaxProcessData, useRealTaskType, useTaskType } from 'src/features/instance/ProcessContext'; @@ -19,7 +18,8 @@ import { PDFWrapper } from 'src/features/pdf/PDFWrapper'; import { Confirm } from 'src/features/processEnd/confirm/containers/Confirm'; import { Feedback } from 'src/features/processEnd/feedback/Feedback'; import { ReceiptContainer } from 'src/features/receipt/ReceiptContainer'; -import { TaskKeys, useNavigatePage, useNavigationParams } from 'src/hooks/useNavigatePage'; +import { useNavigationParam } from 'src/features/routing/AppRoutingContext'; +import { TaskKeys, useIsCurrentTask, useNavigatePage, useStartUrl } from 'src/hooks/useNavigatePage'; import { ProcessTaskType } from 'src/types'; import { behavesLikeDataTask } from 'src/utils/formLayout'; @@ -60,16 +60,11 @@ export function InvalidTaskIdPage() { } export function ProcessWrapperWrapper() { - const { taskId, startUrl, queryKeys } = useNavigatePage(); + const taskId = useNavigationParam('taskId'); const currentTaskId = useLaxProcessData()?.currentTask?.elementId; if (taskId === undefined && currentTaskId !== undefined) { - return ( - - ); + return ; } return ( @@ -82,9 +77,21 @@ export function ProcessWrapperWrapper() { ); } +function NavigateToStartUrl() { + const currentTaskId = useLaxProcessData()?.currentTask?.elementId; + const startUrl = useStartUrl(currentTaskId); + return ( + + ); +} + export const ProcessWrapper = () => { - const { isCurrentTask, isValidTaskId } = useNavigatePage(); - const { taskId } = useNavigationParams(); + const isCurrentTask = useIsCurrentTask(); + const { isValidTaskId } = useNavigatePage(); + const taskId = useNavigationParam('taskId'); const taskType = useTaskType(taskId); const realTaskType = useRealTaskType(); const layoutSets = useLayoutSets(); @@ -149,24 +156,22 @@ export const ProcessWrapper = () => { if (taskType === ProcessTaskType.Data) { return ( - - - - -
- - - } - /> - } - /> - - + + + + + + + } + /> + } + /> + ); } diff --git a/src/core/contexts/context.tsx b/src/core/contexts/context.tsx index e0d89efea9..c5b5b1c97a 100644 --- a/src/core/contexts/context.tsx +++ b/src/core/contexts/context.tsx @@ -65,9 +65,9 @@ export function createContext({ name, required, ...rest }: CreateContextProps }; const useLaxCtx = (): T | typeof ContextNotProvided => { - const hasProvider = useHasProvider(); - const value = useContext(Context).innerValue; - if (!hasProvider) { + const ctx = useContext(Context); + const value = ctx.innerValue; + if (!ctx.provided) { return ContextNotProvided; } return value as T; diff --git a/src/core/contexts/zustandContext.tsx b/src/core/contexts/zustandContext.tsx index 49ae7d5698..cf643ca2ab 100644 --- a/src/core/contexts/zustandContext.tsx +++ b/src/core/contexts/zustandContext.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef } from 'react'; import type { PropsWithChildren } from 'react'; import deepEqual from 'fast-deep-equal'; @@ -6,7 +6,9 @@ import { createStore, useStore } from 'zustand'; import type { StoreApi } from 'zustand'; import { ContextNotProvided, createContext } from 'src/core/contexts/context'; +import { SelectorStrictness, useDelayedSelector } from 'src/hooks/delayedSelectors'; import type { CreateContextProps } from 'src/core/contexts/context'; +import type { DSConfig, DSMode, DSReturn } from 'src/hooks/delayedSelectors'; type ExtractFromStoreApi = T extends StoreApi ? Exclude : never; @@ -16,17 +18,8 @@ type Selector = (state: T) => U; type SelectorFunc = (selector: Selector) => U; type SelectorRefFunc = (selector: Selector) => { current: U }; type SelectorRefFuncLax = (selector: Selector) => { current: U | typeof ContextNotProvided }; -type DelayedSelectorFunc = (selector: Selector, postProcessor?: (data: unknown) => U) => U; -type DelayedSelectorFuncWithArg = (lookup: Arg, postProcessor?: (data: unknown) => U) => U; type SelectorFuncLax = (selector: Selector) => U | typeof ContextNotProvided; -type DelayedSelectorState = Map, { selector: Selector; rawValue: any; value: any }>; - -interface DelayedSelectorFactory { - selector: (lookup: Param) => Selector; - makeCacheKey: (lookup: Param) => string; -} - export function createZustandContext, Type = ExtractFromStoreApi, Props = any>( props: CreateContextProps & { initialCreateStore: (props: Props) => Store; @@ -111,146 +104,6 @@ export function createZustandContext, Type = Extrac }); }; - /** - * A complex hook that returns a function you can use to select a value at some point in the future. If you never - * select any values from the store, the store will not be subscribed to, and the component will not re-render when - * the store changes. If you do select a value, the store will be subscribed to, and the component will only re-render - * if the selected value(s) change when compared with the previous value. - * - * An important note when using this hook: The selector functions you pass must also be memoized (i.e. created with - * useMemo or useCallback), or the component will fall back to re-rendering every time the store changes. This is - * because the function itself will be recreated every time the component re-renders, and the function - * will not be able to be used as a cache key. - */ - const useDelayedMemoSelectorProto = (store: Store | typeof ContextNotProvided): DelayedSelectorFunc => { - const selectorsCalled = useRef>(new Map()); - const [renderCount, forceRerender] = useState(0); - - useEffect(() => { - if (store === ContextNotProvided) { - return; - } - - return store.subscribe((state) => { - // When the state changes, we run all the known selectors again to figure out if anything changed. If it - // did change, we'll clear the list of selectors to force a re-render. - const selectors = selectorsCalled.current; - let changed = false; - for (const { selector, rawValue } of selectors.values()) { - if (!deepEqual(rawValue, selector(state))) { - changed = true; - break; - } - } - if (changed) { - selectorsCalled.current = new Map(); - forceRerender((prev) => prev + 1); - } - }); - }, [store]); - - return useCallback( - (selector, postProcessor) => { - if (store === ContextNotProvided) { - return undefined; - } - if (isNaN(renderCount)) { - // This should not happen, and this piece of code looks a bit out of place. This really is only here - // to make sure the callback is re-created and the component re-renders when the store changes. - throw new Error('useDelayedMemoSelector: renderCount is NaN'); - } - - const state = store.getState(); - const rawValue = selector(state); - const value = postProcessor ? postProcessor(rawValue) : rawValue; - - // Check if this function has been called before, and if the value has not changed since the last time it - // was called we can return the previous value and prevent re-rendering. - const prev = selectorsCalled.current.get(selector); - if (prev && deepEqual(prev.value, value)) { - return prev.value; - } - - // The value has changed, or the callback is new to us. No need to re-render the component now, because - // this is always the first render where this value is referenced, and we're always selecting from fresh state. - selectorsCalled.current.set(selector, { selector, rawValue, value }); - return value; - }, - [store, renderCount], - ); - }; - - const useDelayedMemoSelector = () => { - const store = useCtx(); - return useDelayedMemoSelectorProto(store); - }; - - /** - * The same as useDelayedMemoSelector, but will also work if the context provider is not present. - * If the context provider is not present, the hook will return the ContextNotProvided value instead. - */ - const useLaxDelayedMemoSelector = (): DelayedSelectorFunc | typeof ContextNotProvided => { - const _store = useLaxCtx(); - const delayedSelector = useDelayedMemoSelectorProto(_store as any); - return _store === ContextNotProvided ? ContextNotProvided : delayedSelector; - }; - - /** - * Even more abstraction on top of useDelayedMemoSelector. This hook expects a callback factory that will create - * the selector function for you, along with a cache key. - */ - const useDelayedMemoSelectorFactory = ({ - selector, - makeCacheKey, - }: DelayedSelectorFactory): DelayedSelectorFuncWithArg => { - const delayedSelector = useDelayedMemoSelector(); - const callbacks = useRef>>({}); - - useEffect(() => { - callbacks.current = {}; - }, [delayedSelector]); - - return useCallback( - (arg: Arg, postProcessor) => { - const cacheKey = makeCacheKey(arg); - if (!callbacks.current[cacheKey]) { - callbacks.current[cacheKey] = selector(arg); - } - return delayedSelector(callbacks.current[cacheKey], postProcessor) as RetVal; - }, - [delayedSelector, selector, makeCacheKey], - ); - }; - - const useLaxDelayedMemoSelectorFactory = ({ - selector, - makeCacheKey, - }: DelayedSelectorFactory) => { - const delayedSelector = useLaxDelayedMemoSelector(); - const callbacks = useRef>>({}); - - useEffect(() => { - callbacks.current = {}; - }, [delayedSelector]); - - const callback: DelayedSelectorFuncWithArg = useCallback( - (arg: Arg, postProcessor) => { - if (delayedSelector === ContextNotProvided) { - return ContextNotProvided as RetVal; - } - - const cacheKey = makeCacheKey(arg); - if (!callbacks.current[cacheKey]) { - callbacks.current[cacheKey] = selector(arg); - } - return delayedSelector(callbacks.current[cacheKey], postProcessor) as RetVal; - }, - [delayedSelector, selector, makeCacheKey], - ); - - return delayedSelector === ContextNotProvided ? ContextNotProvided : callback; - }; - /** * A hook much like useSelector(), but will also work if the context provider is not present. If the context provider * is not present, the hook will return the ContextNotProvided value instead. @@ -277,6 +130,28 @@ export function createZustandContext, Type = Extrac return {children}; } + const useLaxDS = >( + mode: Mode, + deps?: any[], + ): DSReturn> => + useDelayedSelector({ + store: useLaxCtx(), + strictness: SelectorStrictness.returnWhenNotProvided, + mode, + deps, + }); + + const useDS = >( + mode: Mode, + deps?: any[], + ): DSReturn> => + useDelayedSelector({ + store: useCtx(), + strictness: SelectorStrictness.throwWhenNotProvided, + mode, + deps, + }); + return { Provider: MyProvider, useSelector, @@ -285,10 +160,8 @@ export function createZustandContext, Type = Extrac useMemoSelector, useLaxMemoSelector, useLaxSelector, - useDelayedMemoSelector, - useDelayedMemoSelectorFactory, - useLaxDelayedMemoSelector, - useLaxDelayedMemoSelectorFactory, + useDelayedSelector: useDS, + useLaxDelayedSelector: useLaxDS, useHasProvider, useStore: useCtx, useLaxStore: useLaxCtx, diff --git a/src/core/loading/Loader.tsx b/src/core/loading/Loader.tsx index a60fbc73bc..94e69eeb8b 100644 --- a/src/core/loading/Loader.tsx +++ b/src/core/loading/Loader.tsx @@ -3,6 +3,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 { LoadingProvider } from 'src/core/loading/LoadingContext'; import { Lang } from 'src/features/language/Lang'; import { useTaskStore } from 'src/layout/Summary2/taskIdStore'; import { ProcessTaskType } from 'src/types'; @@ -13,7 +14,7 @@ interface LoaderProps { renderPresentation?: boolean; } -export const Loader = ({ reason, details, renderPresentation = true }: LoaderProps) => { +export const Loader = ({ renderPresentation = true, ...rest }: LoaderProps) => { const { overriddenTaskId } = useTaskStore(({ overriddenTaskId }) => ({ overriddenTaskId, })); @@ -21,35 +22,34 @@ export const Loader = ({ reason, details, renderPresentation = true }: LoaderPro if (overriddenTaskId) { return null; } - if (renderPresentation) { return ( - } - type={ProcessTaskType.Unknown} - renderNavBar={false} - runNavigationEffect={false} - > - + } + type={ProcessTaskType.Unknown} + renderNavBar={false} > - - - + + + ); } return ( - - - + + + ); }; + +const InnerLoader = ({ reason, details }: LoaderProps) => ( + + + +); diff --git a/src/core/loading/LoadingContext.tsx b/src/core/loading/LoadingContext.tsx new file mode 100644 index 0000000000..70a736057e --- /dev/null +++ b/src/core/loading/LoadingContext.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import type { PropsWithChildren } from 'react'; + +import { createContext } from 'src/core/contexts/context'; + +interface Context { + reason: string; +} + +const { Provider, useCtx } = createContext({ + name: 'Loading', + required: false, + default: undefined, +}); + +export function LoadingProvider({ children, ...rest }: PropsWithChildren) { + return {children}; +} + +export const useIsLoading = () => useCtx() !== undefined; +export const useLoadingReason = () => useCtx()?.reason; diff --git a/src/core/queries/usePrefetchQuery.ts b/src/core/queries/usePrefetchQuery.ts index 6f2816f737..9ca6e54f34 100644 --- a/src/core/queries/usePrefetchQuery.ts +++ b/src/core/queries/usePrefetchQuery.ts @@ -1,11 +1,13 @@ import { useQuery } from '@tanstack/react-query'; -import type { QueryFunction, QueryKey, SkipToken } from '@tanstack/react-query'; +import type { QueryFunction, QueryKey, SkipToken, UseQueryOptions } from '@tanstack/react-query'; export type QueryDefinition = { queryKey: QueryKey; queryFn: QueryFunction | SkipToken; enabled?: boolean; - gcTime?: number; + gcTime?: UseQueryOptions['gcTime']; + staleTime?: UseQueryOptions['staleTime']; + refetchInterval?: UseQueryOptions['refetchInterval']; }; // @see https://tanstack.com/query/v5/docs/framework/react/guides/prefetching diff --git a/src/core/structures/ShallowArrayMap.test.ts b/src/core/structures/ShallowArrayMap.test.ts new file mode 100644 index 0000000000..f6adc4f816 --- /dev/null +++ b/src/core/structures/ShallowArrayMap.test.ts @@ -0,0 +1,79 @@ +import { ShallowArrayMap } from 'src/core/structures/ShallowArrayMap'; + +describe('ShallowArrayMap', () => { + it('should work with a simple one-item array', () => { + const map = new ShallowArrayMap(); + + expect(map.has([1])).toBe(false); + map.set([1], 'a'); + expect(map.has([1])).toBe(true); + expect(map.get([1])).toBe('a'); + map.delete([1]); + expect(map.has([1])).toBe(false); + expect(map.get([1])).toBe(undefined); + }); + + it('should work with zero-length arrays', () => { + const map = new ShallowArrayMap(); + + expect(map.has([])).toBe(false); + map.set([], 'a'); + expect(map.has([])).toBe(true); + expect(map.get([])).toBe('a'); + map.delete([]); + expect(map.has([])).toBe(false); + expect(map.get([])).toBe(undefined); + }); + + it('should work with a two-item array', () => { + const map = new ShallowArrayMap(); + + expect(map.has([1, 2])).toBe(false); + map.set([1, 2], 'a'); + expect(map.has([1, 2])).toBe(true); + expect(map.get([1, 2])).toBe('a'); + map.delete([1, 2]); + expect(map.has([1, 2])).toBe(false); + expect(map.get([1, 2])).toBe(undefined); + }); + + it('should work with complex object as keys', () => { + const map = new ShallowArrayMap(); + + const key1 = { a: 1 }; + + expect(map.has([key1])).toBe(false); + map.set([key1], 'a'); + expect(map.has([key1])).toBe(true); + + // Looking it up with a new object reference should not work + expect(map.has([{ a: 1 }])).toBe(false); + expect(map.get([{ a: 1 }])).toBe(undefined); + + map.delete([key1]); + expect(map.has([key1])).toBe(false); + expect(map.get([key1])).toBe(undefined); + }); + + it('should return a flat values[] array even if the keys have multiple lengths', () => { + const map = new ShallowArrayMap(); + map.set([1, 2], 'a'); + map.set([3, new Date()], 'b'); + map.set([4, 5, 6], 'c'); + map.set([4, 8, 9, 10], 'd'); + + expect(map.values()).toEqual(['a', 'b', 'c', 'd']); + }); + + it('should be possible to store a Map as a value', () => { + const map = new ShallowArrayMap>(); + const value = new Map(); + value.set('a', 'b'); + map.set([1], value); + + expect(map.get([1])).toBe(value); + expect(map.get([1])?.get('a')).toBe('b'); + + expect(map.entries()).toEqual([[[1], value]]); + }); +}); diff --git a/src/core/structures/ShallowArrayMap.ts b/src/core/structures/ShallowArrayMap.ts new file mode 100644 index 0000000000..7b53e2b8b5 --- /dev/null +++ b/src/core/structures/ShallowArrayMap.ts @@ -0,0 +1,136 @@ +/** + * A shallow array map is a map with key-lookups close to O(1) time complexity. The keys here are expected to be + * arrays containing any data type and of any length, and the items inside the array will be shallow-compared by + * reference. + * + * Given an array [1, 2, 3], we can, with minimal time complexity, find the value associated with that array key. + * Even if the array contains object like [null, new Date(), { a: 1 }], the time complexity will still be close + * to O(1). However, as items are compared by reference, a `new Date()` object will not be equal to + * another `new Date()`. + * + * To achieve this, we construct a recursive map of maps, where the first level is a simple array where the index + * points to the map containing N number of items. + * + * The data structure inside here is approximately: + * [0] -> Value // The value with zero-length array key (if any) + * [1] -> Map -> Value // Map of values with one-length array key + * [2] -> Map -> Map -> Value // Map of values with two-length array key + * ... and so on + */ +export class ShallowArrayMap { + private data: Map[] = []; + + public has(key: any[]): boolean { + const keyLength = key.length; + if (this.data[keyLength] === undefined) { + return false; + } + + let map = this.data[keyLength]; + for (let i = 0; i < keyLength - 1; i++) { + map = map.get(key[i]); + if (!map) { + return false; + } + } + + return map.has(key[keyLength - 1]); + } + + public get(key: any[]): T | undefined { + const keyLength = key.length; + if (this.data[keyLength] === undefined) { + return undefined; + } + + let map = this.data[keyLength]; + for (let i = 0; i < keyLength - 1; i++) { + map = map.get(key[i]); + if (!map) { + return undefined; + } + } + + return map.get(key[keyLength - 1]); + } + + public set(key: any[], value: T): void { + const keyLength = key.length; + if (this.data[keyLength] === undefined) { + this.data[keyLength] = this.newMap(); + } + + let map = this.data[keyLength]; + for (let i = 0; i < keyLength - 1; i++) { + if (!map.has(key[i])) { + map.set(key[i], this.newMap()); + } + map = map.get(key[i]); + } + + map.set(key[keyLength - 1], value); + } + + public delete(key: any[]): void { + const keyLength = key.length; + if (this.data[keyLength] === undefined) { + return; + } + + let map = this.data[keyLength]; + for (let i = 0; i < keyLength - 1; i++) { + map = map.get(key[i]); + if (!map) { + return; + } + } + + map.delete(key[keyLength - 1]); + } + + public values(): T[] { + return this.entries().map(([, value]) => value); + } + + public keys(): any[][] { + return this.entries().map(([key]) => key); + } + + public entries(): [any[], T][] { + const out: [any[], T][] = []; + for (const map of this.data) { + if (!map) { + continue; + } + + this.recurseMap(map, (key, value: T) => { + out.push([key, value]); + }); + } + + return out; + } + + private newMap(): Map { + const map = new Map(); + + // Stamp our map with a unique symbol to prevent it from being mistaken for a value + (map as any).__shallowArrayMap = true; + + return map; + } + + private isShallowArrayMap(map: any): map is Map { + return map instanceof Map && (map as any).__shallowArrayMap === true; + } + + private recurseMap(map: Map, callback: (key: any[], value: any) => void, parentKey: any[] = []): void { + for (const [key, value] of map.entries()) { + if (this.isShallowArrayMap(value)) { + this.recurseMap(value, callback, [...parentKey, key]); + } else { + callback([...parentKey, key], value); + } + } + } +} diff --git a/src/core/ui/RenderStart.tsx b/src/core/ui/RenderStart.tsx index d10daf2463..68908d69fa 100644 --- a/src/core/ui/RenderStart.tsx +++ b/src/core/ui/RenderStart.tsx @@ -1,9 +1,11 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import type { PropsWithChildren } from 'react'; +import { useIsLoading } from 'src/core/loading/LoadingContext'; import { DevTools } from 'src/features/devtools/DevTools'; import { DataModelFetcher } from 'src/features/formData/FormDataReaders'; import { LangDataSourcesProvider } from 'src/features/language/LangDataSourcesProvider'; +import { useNavigationEffect, useNavigationParam } from 'src/features/routing/AppRoutingContext'; interface Props extends PropsWithChildren { devTools?: boolean; @@ -18,9 +20,24 @@ interface Props extends PropsWithChildren { export function RenderStart({ children, devTools = true, dataModelFetcher = true }: Props) { return ( + {children} {devTools && } {dataModelFetcher && } ); } + +function RunNavigationEffect() { + const isLoading = useIsLoading(); + const pageKey = useNavigationParam('pageKey'); + const navigationEffect = useNavigationEffect(); + + useEffect(() => { + if (!isLoading && navigationEffect) { + navigationEffect(); + } + }, [isLoading, navigationEffect, pageKey]); + + return null; +} diff --git a/src/core/ui/useResetScrollPosition.ts b/src/core/ui/useResetScrollPosition.ts new file mode 100644 index 0000000000..2089c5f54c --- /dev/null +++ b/src/core/ui/useResetScrollPosition.ts @@ -0,0 +1,33 @@ +export function useResetScrollPosition(getScrollPosition: () => number | undefined, whenSelectorVisible?: string) { + return (prevScrollPosition: number | undefined) => { + if (prevScrollPosition === undefined) { + return; + } + let attemptsLeft = 10; + const check = () => { + attemptsLeft--; + if (attemptsLeft <= 0) { + return; + } + + // If the whenSelectorVisible is set, we should only reset the scroll position if the element is visible + if (whenSelectorVisible) { + const element = document.querySelector(whenSelectorVisible); + if (!element || !element.getBoundingClientRect().y) { + requestAnimationFrame(check); + return; + } + } + + const newScrollPosition = getScrollPosition(); + const scrollBy = newScrollPosition !== undefined ? newScrollPosition - prevScrollPosition : undefined; + + if (newScrollPosition !== undefined && scrollBy !== undefined && Math.abs(scrollBy) > 1) { + window.scrollBy({ top: newScrollPosition - prevScrollPosition }); + } else { + requestAnimationFrame(check); + } + }; + requestAnimationFrame(check); + }; +} diff --git a/src/features/alertOnChange/AlertOnChangePlugin.tsx b/src/features/alertOnChange/AlertOnChangePlugin.tsx new file mode 100644 index 0000000000..63828cdc01 --- /dev/null +++ b/src/features/alertOnChange/AlertOnChangePlugin.tsx @@ -0,0 +1,66 @@ +import { CG } from 'src/codegen/CG'; +import { ExprVal } from 'src/features/expressions/types'; +import { NodeDefPlugin } from 'src/utils/layout/plugins/NodeDefPlugin'; +import type { ComponentConfig } from 'src/codegen/ComponentConfig'; +import type { ExprValToActualOrExpr } from 'src/features/expressions/types'; +import type { CompTypes } from 'src/layout/layout'; +import type { DefPluginExprResolver, DefPluginExtraInItem } from 'src/utils/layout/plugins/NodeDefPlugin'; + +interface Config { + componentType: CompTypes; + expectedFromExternal: { + [key in PropName]: ExprValToActualOrExpr; + }; + settings: { + propName: PropName; + }; + extraInItem: { + [key in PropName]: boolean; + }; +} + +interface ExternalConfig { + propName: string; + title: string; + description: string; +} + +type ToInternal = Config; + +/** + * Add this to your component to configure support for a alertOnDelete/alertOnChange property + */ +export class AlertOnChangePlugin extends NodeDefPlugin> { + constructor(protected settings: E) { + super(); + } + + getKey(): string { + return [this.constructor.name, this.settings.propName].join('/'); + } + + makeImport() { + return new CG.import({ + import: 'AlertOnChangePlugin', + from: 'src/features/alertOnChange/AlertOnChangePlugin', + }); + } + + addToComponent(component: ComponentConfig): void { + component.addProperty( + new CG.prop( + this.settings.propName, + new CG.expr(ExprVal.Boolean) + .optional({ default: false }) + .setTitle(this.settings.title) + .setDescription(this.settings.description), + ), + ); + } + + evalDefaultExpressions(props: DefPluginExprResolver>): DefPluginExtraInItem> { + return { + [this.settings.propName]: props.evalBool(props.item[this.settings.propName], false), + } as DefPluginExtraInItem>; + } +} diff --git a/src/components/molecules/DeleteWarningPopover.module.css b/src/features/alertOnChange/DeleteWarningPopover.module.css similarity index 100% rename from src/components/molecules/DeleteWarningPopover.module.css rename to src/features/alertOnChange/DeleteWarningPopover.module.css diff --git a/src/components/molecules/DeleteWarningPopover.tsx b/src/features/alertOnChange/DeleteWarningPopover.tsx similarity index 95% rename from src/components/molecules/DeleteWarningPopover.tsx rename to src/features/alertOnChange/DeleteWarningPopover.tsx index a0bc6e9efe..25fc3c50d3 100644 --- a/src/components/molecules/DeleteWarningPopover.tsx +++ b/src/features/alertOnChange/DeleteWarningPopover.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Button, Popover } from '@digdir/designsystemet-react'; -import classes from 'src/components/molecules/DeleteWarningPopover.module.css'; +import classes from 'src/features/alertOnChange/DeleteWarningPopover.module.css'; import { Lang } from 'src/features/language/Lang'; export interface IDeleteWarningPopover { diff --git a/src/hooks/useAlertOnChange.ts b/src/features/alertOnChange/useAlertOnChange.ts similarity index 100% rename from src/hooks/useAlertOnChange.ts rename to src/features/alertOnChange/useAlertOnChange.ts diff --git a/src/features/applicationMetadata/ApplicationMetadataProvider.tsx b/src/features/applicationMetadata/ApplicationMetadataProvider.tsx index 2122221beb..3427aa31cb 100644 --- a/src/features/applicationMetadata/ApplicationMetadataProvider.tsx +++ b/src/features/applicationMetadata/ApplicationMetadataProvider.tsx @@ -7,17 +7,13 @@ import type { UseQueryOptions } from '@tanstack/react-query'; import { delayedContext } from 'src/core/contexts/delayedContext'; import { createQueryContext } from 'src/core/contexts/queryContext'; import { onEntryValuesThatHaveState } from 'src/features/applicationMetadata/appMetadataUtils'; +import { MINIMUM_APPLICATION_VERSION } from 'src/features/applicationMetadata/minVersion'; import { VersionErrorOrChildren } from 'src/features/applicationMetadata/VersionErrorOrChildren'; import { fetchApplicationMetadata } from 'src/queries/queries'; import { getInstanceIdRegExp } from 'src/utils/instanceIdRegExp'; import { isAtLeastVersion } from 'src/utils/versionCompare'; import type { ApplicationMetadata, IncomingApplicationMetadata } from 'src/features/applicationMetadata/types'; -export const MINIMUM_APPLICATION_VERSION = { - build: '8.0.0.108', - name: 'v8.0.0', -}; - // Also used for prefetching @see appPrefetcher.ts export function getApplicationMetadataQueryDef() { return { diff --git a/src/features/applicationMetadata/VersionErrorOrChildren.tsx b/src/features/applicationMetadata/VersionErrorOrChildren.tsx index c0a10d3c51..72ffd316c6 100644 --- a/src/features/applicationMetadata/VersionErrorOrChildren.tsx +++ b/src/features/applicationMetadata/VersionErrorOrChildren.tsx @@ -1,10 +1,8 @@ import React from 'react'; import type { PropsWithChildren } from 'react'; -import { - MINIMUM_APPLICATION_VERSION, - useApplicationMetadata, -} from 'src/features/applicationMetadata/ApplicationMetadataProvider'; +import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; +import { MINIMUM_APPLICATION_VERSION } from 'src/features/applicationMetadata/minVersion'; import { InstantiationErrorPage } from 'src/features/instantiate/containers/InstantiationErrorPage'; import { Lang } from 'src/features/language/Lang'; diff --git a/src/features/applicationMetadata/minVersion.ts b/src/features/applicationMetadata/minVersion.ts new file mode 100644 index 0000000000..d36f6c123d --- /dev/null +++ b/src/features/applicationMetadata/minVersion.ts @@ -0,0 +1,4 @@ +export const MINIMUM_APPLICATION_VERSION = { + build: '8.0.0.108', + name: 'v8.0.0', +}; diff --git a/src/features/applicationMetadata/types.ts b/src/features/applicationMetadata/types.ts index a54702b789..6d5bf8d6a7 100644 --- a/src/features/applicationMetadata/types.ts +++ b/src/features/applicationMetadata/types.ts @@ -55,10 +55,6 @@ interface IPartyTypesAllowed { subUnit: boolean; } -export interface IGetApplicationMetadataFulfilled { - applicationMetadata: ApplicationMetadata; -} - export interface IBackendFeaturesState { jsonObjectInDataResponse: boolean; // Extended attachment validation } diff --git a/src/features/attachments/AttachmentsContext.tsx b/src/features/attachments/AttachmentsContext.tsx deleted file mode 100644 index fd92bbd3a6..0000000000 --- a/src/features/attachments/AttachmentsContext.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import React, { useEffect, useMemo } from 'react'; -import type { PropsWithChildren } from 'react'; - -import deepEqual from 'fast-deep-equal'; -import { createStore } from 'zustand'; - -import { ContextNotProvided } from 'src/core/contexts/context'; -import { createZustandContext } from 'src/core/contexts/zustandContext'; -import { Loader } from 'src/core/loading/Loader'; -import { usePostUpload } from 'src/features/attachments/utils/postUpload'; -import { usePreUpload } from 'src/features/attachments/utils/preUpload'; -import { mergeAndSort } from 'src/features/attachments/utils/sorting'; -import type { - AttachmentActionRemove, - AttachmentActionUpdate, - AttachmentActionUpload, - IAttachments, - RawAttachmentAction, - TemporaryAttachment, -} from 'src/features/attachments/index'; -import type { IData } from 'src/types/shared'; -import type { LayoutNode } from 'src/utils/layout/LayoutNode'; - -interface IAttachmentsMethods { - upload(action: RawAttachmentAction): Promise; - update(action: RawAttachmentAction): Promise; - remove(action: RawAttachmentAction): Promise; - awaitUpload(attachment: TemporaryAttachment): Promise; -} - -interface IAttachmentsStoreCtx { - attachments: IAttachments; - setAttachments: (newAttachments: IAttachments) => void; - methods: Partial; - setMethods: (newMethods: IAttachmentsMethods) => void; -} - -function initialCreateStore() { - return createStore((set) => ({ - attachments: {}, - methods: {}, - setAttachments: (attachments) => - set((state) => { - if (deepEqual(state.attachments, attachments)) { - return state; - } - return { attachments }; - }), - setMethods: (methods) => set({ methods }), - })); -} - -const { Provider, useSelector, useLaxMemoSelector } = createZustandContext({ - name: 'Attachments', - required: true, - initialCreateStore, -}); - -/** - * The attachments provider is split into two parts: - * - This AttachmentsProvider, which is responsible for generating the attachments object and providing the methods - * for manipulating it. - * - The AttachmentsStoreProvider, which is responsible for storing the attachments object, and giving it to the - * NodesProvider. The node hierarchy needs to know about the attachments, but the cyclical dependency between - * AttachmentsProvider and NodesProvider makes it impossible to do this in a single provider. - */ -export const AttachmentsProvider = ({ children }: PropsWithChildren) => ( - <> - - {children} - -); - -export const AttachmentsStoreProvider = ({ children }: PropsWithChildren) => ( - - {window.Cypress && } - {children} - -); - -function UpdateAttachmentsForCypress() { - const attachments = useAttachments(); - - useEffect(() => { - if (window.Cypress) { - window.CypressState = { ...window.CypressState, attachments }; - } - }, [attachments]); - - return null; -} - -function ProvideAttachmentsAndMethods() { - const setAttachments = useSelector((store) => store.setAttachments); - const setMethods = useSelector((store) => store.setMethods); - const { state: preUpload, upload, awaitUpload } = usePreUpload(); - const { state: postUpload, update, remove } = usePostUpload(); - - const attachments = useMemo(() => mergeAndSort(preUpload, postUpload), [preUpload, postUpload]); - - const methods = useMemo( - () => ({ - upload, - update, - remove, - awaitUpload, - }), - [upload, update, remove, awaitUpload], - ); - - useEffect(() => { - setAttachments(attachments); - }, [attachments, setAttachments]); - - useEffect(() => { - setMethods(methods); - }, [methods, setMethods]); - - return null; -} - -function LoadingUntilMethodsProvided({ children }: PropsWithChildren) { - const methods = useSelector((store) => store.methods); - - if (Object.keys(methods).length === 0) { - return ; - } - - return children; -} - -export const useAttachments = () => useSelector((state) => state.attachments); -export const useAttachmentsUploader = () => useSelector((state) => state.methods.upload!); -export const useAttachmentsUpdater = () => useSelector((state) => state.methods.update!); -export const useAttachmentsRemover = () => useSelector((state) => state.methods.remove!); -export const useAttachmentsAwaiter = () => useSelector((state) => state.methods.awaitUpload!); - -const emptyArray = []; -export const useAttachmentsFor = (node: LayoutNode<'FileUploadWithTag' | 'FileUpload'>) => - useSelector((store) => store.attachments[node.item.id]) || emptyArray; - -export const useHasPendingAttachments = () => { - const out = useLaxMemoSelector((store) => { - const { attachments } = store; - return Object.values(attachments).some((fileUploader) => - fileUploader?.some((attachment) => !attachment.uploaded || attachment.updating || attachment.deleting), - ); - }); - - return out === ContextNotProvided ? false : out; -}; diff --git a/src/features/attachments/AttachmentsPlugin.tsx b/src/features/attachments/AttachmentsPlugin.tsx new file mode 100644 index 0000000000..bcd0cbb3a9 --- /dev/null +++ b/src/features/attachments/AttachmentsPlugin.tsx @@ -0,0 +1,47 @@ +import { CG } from 'src/codegen/CG'; +import { NodeDefPlugin } from 'src/utils/layout/plugins/NodeDefPlugin'; +import type { ComponentConfig } from 'src/codegen/ComponentConfig'; +import type { IAttachment } from 'src/features/attachments/index'; +import type { CompTypes } from 'src/layout/layout'; +import type { DefPluginStateFactoryProps } from 'src/utils/layout/plugins/NodeDefPlugin'; + +interface Config { + componentType: CompTypes; + extraState: { + attachments: Record; + attachmentsFailedToUpload: Record; // Maps temporary attachment ID to error message + }; +} + +export class AttachmentsPlugin extends NodeDefPlugin { + addToComponent(component: ComponentConfig): void { + if (!component.isFormLike()) { + throw new Error('AttachmentsPlugin can only be used with container or form components'); + } + + component.behaviors.canHaveAttachments = true; + } + + makeImport() { + return new CG.import({ + import: 'AttachmentsPlugin', + from: 'src/features/attachments/AttachmentsPlugin', + }); + } + + stateFactory(_props: DefPluginStateFactoryProps): Config['extraState'] { + return { + attachments: {}, + attachmentsFailedToUpload: {}, + }; + } + + extraNodeGeneratorChildren(): string { + const StoreAttachmentsInNode = new CG.import({ + import: 'StoreAttachmentsInNode', + from: 'src/features/attachments/StoreAttachmentsInNode', + }); + + return `<${StoreAttachmentsInNode} />`; + } +} diff --git a/src/features/attachments/AttachmentsStorePlugin.tsx b/src/features/attachments/AttachmentsStorePlugin.tsx new file mode 100644 index 0000000000..d543ef7c01 --- /dev/null +++ b/src/features/attachments/AttachmentsStorePlugin.tsx @@ -0,0 +1,554 @@ +import { useCallback } from 'react'; +import { toast } from 'react-toastify'; + +import { useMutation } from '@tanstack/react-query'; +import { v4 as uuidv4 } from 'uuid'; +import type { UseMutationOptions } from '@tanstack/react-query'; +import type { AxiosError } from 'axios'; + +import { useAppMutations } from 'src/core/contexts/AppQueriesProvider'; +import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; +import { isAttachmentUploaded } from 'src/features/attachments/index'; +import { sortAttachmentsByName } from 'src/features/attachments/sortAttachments'; +import { FD } from 'src/features/formData/FormDataWrite'; +import { useLaxInstance, useLaxInstanceData } from 'src/features/instance/InstanceContext'; +import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; +import { useLanguage } from 'src/features/language/useLanguage'; +import { getValidationIssueMessage } from 'src/features/validation/backendValidation/backendValidationUtils'; +import { useWaitForState } from 'src/hooks/useWaitForState'; +import { isAxiosError } from 'src/utils/isAxiosError'; +import { nodesProduce } from 'src/utils/layout/NodesContext'; +import { NodeDataPlugin } from 'src/utils/layout/plugins/NodeDataPlugin'; +import type { ContextNotProvided } from 'src/core/contexts/context'; +import type { + FileUploaderNode, + IAttachment, + TemporaryAttachment, + UploadedAttachment, +} from 'src/features/attachments/index'; +import type { BackendValidationIssue } from 'src/features/validation'; +import type { DSMode } from 'src/hooks/delayedSelectors'; +import type { IDataModelBindingsList, IDataModelBindingsSimple } from 'src/layout/common.generated'; +import type { CompWithBehavior } from 'src/layout/layout'; +import type { IData } from 'src/types/shared'; +import type { LayoutNode } from 'src/utils/layout/LayoutNode'; +import type { NodesContext, NodesStoreFull } from 'src/utils/layout/NodesContext'; +import type { NodeDataPluginSetState } from 'src/utils/layout/plugins/NodeDataPlugin'; +import type { NodeData } from 'src/utils/layout/types'; + +export interface AttachmentActionUpload { + temporaryId: string; + file: File; + node: FileUploaderNode; + dataModelBindings: IDataModelBindingsSimple | IDataModelBindingsList | undefined; +} + +export interface AttachmentActionUpdate { + tags: string[]; + node: FileUploaderNode; + attachment: UploadedAttachment; +} + +export interface AttachmentActionRemove { + node: FileUploaderNode; + attachment: UploadedAttachment; + dataModelBindings: IDataModelBindingsSimple | IDataModelBindingsList | undefined; +} + +export type AttachmentsSelector = (node: FileUploaderNode) => IAttachment[]; + +export interface AttachmentsStorePluginConfig { + extraFunctions: { + attachmentUpload: (action: AttachmentActionUpload) => void; + attachmentUploadFulfilled: (action: AttachmentActionUpload, result: IData) => void; + attachmentUploadRejected: (action: AttachmentActionUpload, error: string) => void; + + attachmentUpdate: (action: AttachmentActionUpdate) => void; + attachmentUpdateFulfilled: (action: AttachmentActionUpdate) => void; + attachmentUpdateRejected: (action: AttachmentActionUpdate, error: AxiosError) => void; + + attachmentRemove: (action: AttachmentActionRemove) => void; + attachmentRemoveFulfilled: (action: AttachmentActionRemove) => void; + attachmentRemoveRejected: (action: AttachmentActionRemove, error: AxiosError) => void; + }; + extraHooks: { + useAttachmentsUpload: () => (action: Omit) => Promise; + useAttachmentsUpdate: () => (action: AttachmentActionUpdate) => Promise; + useAttachmentsRemove: () => (action: AttachmentActionRemove) => Promise; + + useAttachments: (node: FileUploaderNode) => IAttachment[]; + useAttachmentsSelector: () => AttachmentsSelector; + useLaxAttachmentsSelector: () => typeof ContextNotProvided | AttachmentsSelector; + useWaitUntilUploaded: () => (node: FileUploaderNode, attachment: TemporaryAttachment) => Promise; + }; +} + +const emptyArray: IAttachment[] = []; + +type ProperData = NodeData>; + +export class AttachmentsStorePlugin extends NodeDataPlugin { + extraFunctions(set: NodeDataPluginSetState): AttachmentsStorePluginConfig['extraFunctions'] { + return { + attachmentUpload: ({ file, node, temporaryId }) => { + set( + nodesProduce((draft) => { + const data = draft.nodeData[node.id] as ProperData; + data.attachments[temporaryId] = { + uploaded: false, + updating: false, + deleting: false, + data: { + temporaryId, + filename: file.name, + size: file.size, + }, + } satisfies TemporaryAttachment; + }), + ); + }, + attachmentUploadFulfilled: ({ temporaryId, node }, data) => { + set( + nodesProduce((draft) => { + const nodeData = draft.nodeData[node.id] as ProperData; + delete nodeData.attachments[temporaryId]; + nodeData.attachments[data.id] = { + temporaryId, + uploaded: true, + updating: false, + deleting: false, + data, + } satisfies UploadedAttachment; + }), + ); + }, + attachmentUploadRejected: ({ node, temporaryId }, error) => { + set( + nodesProduce((draft) => { + const nodeData = draft.nodeData[node.id] as ProperData; + delete nodeData.attachments[temporaryId]; + nodeData.attachmentsFailedToUpload[temporaryId] = error; + }), + ); + }, + attachmentUpdate: ({ node, attachment, tags }) => { + set( + nodesProduce((draft) => { + const nodeData = draft.nodeData[node.id] as ProperData; + const attachmentData = nodeData.attachments[attachment.data.id]; + if (isAttachmentUploaded(attachmentData)) { + attachmentData.updating = true; + attachmentData.data.tags = tags; + } else { + throw new Error('Cannot update a temporary attachment'); + } + }), + ); + }, + attachmentUpdateFulfilled: ({ node, attachment }) => { + set( + nodesProduce((draft) => { + const nodeData = draft.nodeData[node.id] as ProperData; + const attachmentData = nodeData.attachments[attachment.data.id]; + if (isAttachmentUploaded(attachmentData)) { + attachmentData.updating = false; + } else { + throw new Error('Cannot update a temporary attachment'); + } + }), + ); + }, + attachmentUpdateRejected: ({ node, attachment }, error) => { + set( + nodesProduce((draft) => { + const nodeData = draft.nodeData[node.id] as ProperData; + const attachmentData = nodeData.attachments[attachment.data.id]; + if (isAttachmentUploaded(attachmentData)) { + attachmentData.updating = false; + attachmentData.error = error; + } else { + throw new Error('Cannot update a temporary attachment'); + } + }), + ); + }, + attachmentRemove: ({ node, attachment }) => { + set( + nodesProduce((draft) => { + const nodeData = draft.nodeData[node.id] as ProperData; + const attachmentData = nodeData.attachments[attachment.data.id]; + if (isAttachmentUploaded(attachmentData)) { + attachmentData.deleting = true; + } else { + throw new Error('Cannot remove a temporary attachment'); + } + }), + ); + }, + attachmentRemoveFulfilled: ({ node, attachment }) => { + set( + nodesProduce((draft) => { + const nodeData = draft.nodeData[node.id] as ProperData; + delete nodeData.attachments[attachment.data.id]; + }), + ); + }, + attachmentRemoveRejected: ({ node, attachment }, error) => { + set( + nodesProduce((draft) => { + const nodeData = draft.nodeData[node.id] as ProperData; + const attachmentData = nodeData.attachments[attachment.data.id]; + if (isAttachmentUploaded(attachmentData)) { + attachmentData.deleting = false; + attachmentData.error = error; + } else { + throw new Error('Cannot remove a temporary attachment'); + } + }), + ); + }, + }; + } + extraHooks(store: NodesStoreFull): AttachmentsStorePluginConfig['extraHooks'] { + const selectorArg: DSMode = { + mode: 'simple', + selector: (node: LayoutNode) => (state) => { + const nodeData = state.nodeData[node.id]; + if (!nodeData) { + return emptyArray; + } + if ('attachments' in nodeData) { + return Object.values(nodeData.attachments).sort(sortAttachmentsByName); + } + return emptyArray; + }, + }; + + return { + useAttachmentsUpload() { + const { changeData: changeInstanceData } = useLaxInstance() || {}; + const upload = store.useSelector((state) => state.attachmentUpload); + const fulfill = store.useSelector((state) => state.attachmentUploadFulfilled); + const reject = store.useSelector((state) => state.attachmentUploadRejected); + const { mutateAsync } = useAttachmentsUploadMutation(); + const backendFeatures = useApplicationMetadata().features || {}; + const { langAsString, lang } = useLanguage(); + const setLeafValue = FD.useSetLeafValue(); + const appendToListUnique = FD.useAppendToListUnique(); + + return useCallback( + async (action: Omit) => { + const temporaryId = uuidv4(); + const fullAction: AttachmentActionUpload = { ...action, temporaryId }; + upload(fullAction); + + try { + const reply = await mutateAsync({ + dataTypeId: action.node.baseId, + file: action.file, + }); + if (!reply || !reply.blobStoragePath) { + throw new Error('Failed to upload attachment'); + } + if (action.dataModelBindings && 'list' in action.dataModelBindings) { + appendToListUnique({ + path: action.dataModelBindings.list, + newValue: reply.id, + }); + } else if (action.dataModelBindings && 'simpleBinding' in action.dataModelBindings) { + setLeafValue({ + path: action.dataModelBindings.simpleBinding, + newValue: reply.id, + }); + } + fulfill(fullAction, reply); + + changeInstanceData && + changeInstanceData((instance) => { + if (instance?.data && reply) { + return { + ...instance, + data: [...instance.data, reply], + }; + } + + return instance; + }); + + return reply.id; + } catch (err) { + reject(fullAction, err); + + if (backendFeatures.jsonObjectInDataResponse && isAxiosError(err) && Array.isArray(err.response?.data)) { + const validationIssues: BackendValidationIssue[] = err.response.data; + const message = validationIssues + .map((issue) => getValidationIssueMessage(issue)) + .map(({ key, params }) => `- ${langAsString(key, params)}`) + .join('\n'); + toast(message, { type: 'error' }); + } else { + toast(lang('form_filler.file_uploader_validation_error_upload'), { type: 'error' }); + } + + return undefined; + } + }, + [ + appendToListUnique, + backendFeatures.jsonObjectInDataResponse, + changeInstanceData, + fulfill, + lang, + langAsString, + mutateAsync, + reject, + setLeafValue, + upload, + ], + ); + }, + useAttachmentsUpdate() { + const { mutateAsync: removeTag } = useAttachmentsRemoveTagMutation(); + const { mutateAsync: addTag } = useAttachmentsAddTagMutation(); + const { changeData: changeInstanceData } = useLaxInstance() || {}; + const { lang } = useLanguage(); + const update = store.useSelector((state) => state.attachmentUpdate); + const fulfill = store.useSelector((state) => state.attachmentUpdateFulfilled); + const reject = store.useSelector((state) => state.attachmentUpdateRejected); + + return useCallback( + async (action: AttachmentActionUpdate) => { + const { tags, attachment } = action; + const tagToAdd = tags.filter((t) => !attachment.data.tags?.includes(t)); + const tagToRemove = attachment.data.tags?.filter((t) => !tags.includes(t)) || []; + const areEqual = tagToAdd.length && tagToRemove.length && tagToAdd[0] === tagToRemove[0]; + + // If there are no tags to add or remove, or if the tags are the same, do nothing. + if ((!tagToAdd.length && !tagToRemove.length) || areEqual) { + return; + } + + update(action); + try { + if (tagToAdd.length) { + await Promise.all(tagToAdd.map((tag) => addTag({ dataGuid: attachment.data.id, tagToAdd: tag }))); + } + if (tagToRemove.length) { + await Promise.all( + tagToRemove.map((tag) => removeTag({ dataGuid: attachment.data.id, tagToRemove: tag })), + ); + } + fulfill(action); + + changeInstanceData && + changeInstanceData((instance) => { + if (instance?.data) { + return { + ...instance, + data: instance.data.map((dataElement) => { + if (dataElement.id === attachment.data.id) { + return { + ...dataElement, + tags, + }; + } + return dataElement; + }), + }; + } + }); + } catch (error) { + reject(action, error); + toast(lang('form_filler.file_uploader_validation_error_update'), { type: 'error' }); + } + }, + [addTag, changeInstanceData, fulfill, lang, reject, removeTag, update], + ); + }, + useAttachmentsRemove() { + const { mutateAsync: removeAttachment } = useAttachmentsRemoveMutation(); + const { changeData: changeInstanceData } = useLaxInstance() || {}; + const { lang } = useLanguage(); + const remove = store.useSelector((state) => state.attachmentRemove); + const fulfill = store.useSelector((state) => state.attachmentRemoveFulfilled); + const reject = store.useSelector((state) => state.attachmentRemoveRejected); + const setLeafValue = FD.useSetLeafValue(); + const removeValueFromList = FD.useRemoveValueFromList(); + + return useCallback( + async (action: AttachmentActionRemove) => { + remove(action); + try { + await removeAttachment(action.attachment.data.id); + if (action.dataModelBindings && 'list' in action.dataModelBindings) { + removeValueFromList({ + path: action.dataModelBindings.list, + value: action.attachment.data.id, + }); + } else if (action.dataModelBindings && 'simpleBinding' in action.dataModelBindings) { + setLeafValue({ + path: action.dataModelBindings.simpleBinding, + newValue: undefined, + }); + } + + fulfill(action); + + changeInstanceData && + changeInstanceData((instance) => { + if (instance?.data) { + return { + ...instance, + data: instance.data.filter((d) => d.id !== action.attachment.data.id), + }; + } + }); + + return true; + } catch (error) { + reject(action, error); + toast(lang('form_filler.file_uploader_validation_error_delete'), { type: 'error' }); + return false; + } + }, + [changeInstanceData, fulfill, lang, reject, remove, removeAttachment, removeValueFromList, setLeafValue], + ); + }, + useAttachments(node) { + return store.useSelector((state) => { + if (!node) { + return emptyArray; + } + + const nodeData = state.nodeData[node.id]; + if ('attachments' in nodeData) { + return Object.values(nodeData.attachments).sort(sortAttachmentsByName); + } + + return emptyArray; + }); + }, + useAttachmentsSelector() { + return store.useDelayedSelector(selectorArg) as AttachmentsSelector; + }, + useLaxAttachmentsSelector() { + return store.useLaxDelayedSelector(selectorArg) as AttachmentsSelector; + }, + useWaitUntilUploaded() { + const zustandStore = store.useStore(); + const waitFor = useWaitForState(zustandStore); + + return useCallback( + (node, attachment) => + waitFor((state, setReturnValue) => { + const nodeData = state.nodeData[node.id]; + if (!nodeData || !('attachments' in nodeData) || !('attachmentsFailedToUpload' in nodeData)) { + setReturnValue(false); + return true; + } + const stillUploading = nodeData.attachments[attachment.data.temporaryId]; + if (stillUploading) { + return false; + } + const errorMessage = nodeData.attachmentsFailedToUpload[attachment.data.temporaryId]; + if (errorMessage !== undefined) { + setReturnValue(false); + return true; + } + + const uploaded = Object.values(nodeData.attachments).find( + (a) => isAttachmentUploaded(a) && a.temporaryId === attachment.data.temporaryId, + ) as UploadedAttachment | undefined; + if (uploaded) { + setReturnValue(uploaded.data); + return true; + } + + throw new Error('Given attachment not found in node'); + }), + [waitFor], + ); + }, + }; + } +} + +interface MutationVariables { + dataTypeId: string; + file: File; +} + +function useAttachmentsUploadMutation() { + const { doAttachmentUpload } = useAppMutations(); + const instanceId = useLaxInstanceData()?.id; + + const options: UseMutationOptions = { + mutationFn: ({ dataTypeId, file }: MutationVariables) => { + if (!instanceId) { + throw new Error('Missing instanceId, cannot upload attachment'); + } + + return doAttachmentUpload(instanceId, dataTypeId, file); + }, + onError: (error: AxiosError) => { + window.logError('Failed to upload attachment:\n', error.message); + }, + }; + + return useMutation(options); +} + +function useAttachmentsAddTagMutation() { + const { doAttachmentAddTag } = useAppMutations(); + const instanceId = useLaxInstanceData()?.id; + + return useMutation({ + mutationFn: ({ dataGuid, tagToAdd }: { dataGuid: string; tagToAdd: string }) => { + if (!instanceId) { + throw new Error('Missing instanceId, cannot add attachment'); + } + + return doAttachmentAddTag(instanceId, dataGuid, tagToAdd); + }, + onError: (error: AxiosError) => { + window.logError('Failed to add tag to attachment:\n', error); + }, + }); +} + +function useAttachmentsRemoveTagMutation() { + const { doAttachmentRemoveTag } = useAppMutations(); + const instanceId = useLaxInstanceData()?.id; + + return useMutation({ + mutationFn: ({ dataGuid, tagToRemove }: { dataGuid: string; tagToRemove: string }) => { + if (!instanceId) { + throw new Error('Missing instanceId, cannot remove attachment'); + } + + return doAttachmentRemoveTag(instanceId, dataGuid, tagToRemove); + }, + onError: (error: AxiosError) => { + window.logError('Failed to remove tag from attachment:\n', error); + }, + }); +} + +function useAttachmentsRemoveMutation() { + const { doAttachmentRemove } = useAppMutations(); + const instanceId = useLaxInstanceData()?.id; + const language = useCurrentLanguage(); + + return useMutation({ + mutationFn: (dataGuid: string) => { + if (!instanceId) { + throw new Error('Missing instanceId, cannot remove attachment'); + } + + return doAttachmentRemove(instanceId, dataGuid, language); + }, + onError: (error: AxiosError) => { + window.logError('Failed to delete attachment:\n', error); + }, + }); +} diff --git a/src/features/attachments/StoreAttachmentsInNode.tsx b/src/features/attachments/StoreAttachmentsInNode.tsx new file mode 100644 index 0000000000..56f6c98a6a --- /dev/null +++ b/src/features/attachments/StoreAttachmentsInNode.tsx @@ -0,0 +1,131 @@ +import React, { useRef } from 'react'; + +import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; +import { useLaxInstanceData } from 'src/features/instance/InstanceContext'; +import { useLaxProcessData } from 'src/features/instance/ProcessContext'; +import { useMemoDeepEqual } from 'src/hooks/useStateDeepEqual'; +import { GeneratorInternal } from 'src/utils/layout/generator/GeneratorContext'; +import { + GeneratorCondition, + GeneratorStages, + NodesStateQueue, + StageEvaluateExpressions, +} from 'src/utils/layout/generator/GeneratorStages'; +import { useNodeFormData } from 'src/utils/layout/useNodeItem'; +import type { ApplicationMetadata } from 'src/features/applicationMetadata/types'; +import type { IAttachment } from 'src/features/attachments/index'; +import type { CompWithBehavior } from 'src/layout/layout'; +import type { IData } from 'src/types/shared'; +import type { IComponentFormData } from 'src/utils/formComponentUtils'; +import type { LayoutNode } from 'src/utils/layout/LayoutNode'; + +export function StoreAttachmentsInNode() { + return ( + + + + ); +} + +function PerformWork() { + const node = GeneratorInternal.useParent() as LayoutNode>; + const setNodeProp = NodesStateQueue.useSetNodeProp(); + const attachments = useNodeAttachments(); + + GeneratorStages.EvaluateExpressions.useEffect(() => { + setNodeProp({ node, prop: 'attachments', value: attachments }); + }, [node, setNodeProp, attachments]); + + return null; +} + +function useNodeAttachments(): Record { + const node = GeneratorInternal.useParent() as LayoutNode>; + const nodeData = useNodeFormData(node); + + const application = useApplicationMetadata(); + const currentTask = useLaxProcessData()?.currentTask?.elementId; + const data = useLaxInstanceData()?.data; + + const mappedAttachments = useMemoDeepEqual( + () => mapAttachments(node, data ?? [], application, currentTask, nodeData), + [node, data, application, currentTask, nodeData], + ); + + const prevAttachments = useRef>({}); + return useMemoDeepEqual(() => { + const prevResult = prevAttachments.current ?? new Map(); + const result: Record = {}; + + for (const attachment of mappedAttachments) { + result[attachment.id] = { + uploaded: true, + updating: prevResult[attachment.id]?.updating ?? false, + deleting: prevResult[attachment.id]?.deleting ?? false, + data: attachment, + }; + } + + prevAttachments.current = result; + return result; + }, [mappedAttachments]); +} + +function mapAttachments( + node: LayoutNode, + dataElements: IData[], + application: ApplicationMetadata, + currentTask: string | undefined, + formData: IComponentFormData>, +): IData[] { + const attachments: IData[] = []; + for (const data of dataElements) { + if (data.dataType && node.baseId !== data.dataType) { + // The attachment does not belong to this node + continue; + } + + const dataType = application.dataTypes.find((dt) => dt.id === data.dataType); + if (!dataType) { + continue; + } + + if (dataType.taskId && dataType.taskId !== currentTask) { + continue; + } + + if (dataType.appLogic?.classRef) { + // Data models are not attachments + continue; + } + + if (dataType.id === 'ref-data-as-pdf') { + // Generated PDF receipts are not attachments + continue; + } + + const simpleValue = formData && 'simpleBinding' in formData ? formData.simpleBinding : undefined; + const listValue = formData && 'list' in formData ? formData.list : undefined; + + if (simpleValue && simpleValue === data.id) { + attachments.push(data); + continue; + } + + if (listValue && Array.isArray(listValue) && listValue.some((binding) => binding === data.id)) { + attachments.push(data); + continue; + } + + const nodeIsInRepeatingGroup = node.id !== node.baseId; + if (!simpleValue && !listValue && !nodeIsInRepeatingGroup) { + // We can safely assume the attachment belongs to this node. + attachments.push(data); + } + } + + return attachments; +} diff --git a/src/features/attachments/UpdateAttachmentsForCypress.tsx b/src/features/attachments/UpdateAttachmentsForCypress.tsx new file mode 100644 index 0000000000..f229bda06d --- /dev/null +++ b/src/features/attachments/UpdateAttachmentsForCypress.tsx @@ -0,0 +1,15 @@ +import { useEffect } from 'react'; + +import { useAllAttachments } from 'src/features/attachments/hooks'; + +export function UpdateAttachmentsForCypress() { + const attachments = useAllAttachments(); + + useEffect(() => { + if (window.Cypress) { + window.CypressState = { ...window.CypressState, attachments }; + } + }, [attachments]); + + return null; +} diff --git a/src/features/attachments/hooks.ts b/src/features/attachments/hooks.ts new file mode 100644 index 0000000000..29c1035138 --- /dev/null +++ b/src/features/attachments/hooks.ts @@ -0,0 +1,49 @@ +import { ContextNotProvided } from 'src/core/contexts/context'; +import { AttachmentsPlugin } from 'src/features/attachments/AttachmentsPlugin'; +import { NodesInternal } from 'src/utils/layout/NodesContext'; +import { useNodeTraversalSilent } from 'src/utils/layout/useNodeTraversal'; +import type { FileUploaderNode, IAttachmentsMap } from 'src/features/attachments/index'; + +export const useAttachmentsUploader = () => NodesInternal.useAttachmentsUpload(); +export const useAttachmentsUpdater = () => NodesInternal.useAttachmentsUpdate(); +export const useAttachmentsRemover = () => NodesInternal.useAttachmentsRemove(); +export const useAttachmentsAwaiter = () => NodesInternal.useWaitUntilUploaded(); + +export const useAttachmentsFor = (node: FileUploaderNode) => NodesInternal.useAttachments(node); + +export const useAttachmentsSelector = () => NodesInternal.useAttachmentsSelector(); +export const useLaxAttachmentsSelector = () => NodesInternal.useLaxAttachmentsSelector(); + +export function useHasPendingAttachments() { + const selector = useLaxAttachmentsSelector(); + return ( + useNodeTraversalSilent((t) => { + if (selector === ContextNotProvided) { + return false; + } + + const withAttachments = t.allNodes().filter((node) => node.def.hasPlugin(AttachmentsPlugin)); + return withAttachments.some((node: FileUploaderNode) => { + const attachments = selector(node); + return attachments.some((attachment) => !attachment.uploaded || attachment.updating || attachment.deleting); + }); + }) ?? false + ); +} + +const emptyMap: IAttachmentsMap = {}; +export function useAllAttachments(): IAttachmentsMap { + const selector = useAttachmentsSelector(); + return ( + useNodeTraversalSilent((t) => { + const out: IAttachmentsMap = {}; + const withAttachments = t + .allNodes() + .filter((node) => node.def.hasPlugin(AttachmentsPlugin)) as FileUploaderNode[]; + for (const node of withAttachments) { + out[node.id] = selector(node); + } + return out; + }) ?? emptyMap + ); +} diff --git a/src/features/attachments/index.ts b/src/features/attachments/index.ts index 94f4f2825e..fcd5406739 100644 --- a/src/features/attachments/index.ts +++ b/src/features/attachments/index.ts @@ -1,32 +1,9 @@ import type { AxiosError } from 'axios'; +import type { CompWithBehavior } from 'src/layout/layout'; import type { IData } from 'src/types/shared'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; -export type FileUploaderNode = LayoutNode<'FileUpload' | 'FileUploadWithTag'>; - -export interface AttachmentActionUpload { - action: 'upload'; - file: File; - node: FileUploaderNode; -} - -export interface AttachmentActionUpdate { - action: 'update'; - tags: string[]; - node: FileUploaderNode; - attachment: UploadedAttachment; -} - -export interface AttachmentActionRemove { - action: 'remove'; - node: FileUploaderNode; - attachment: UploadedAttachment; -} - -export type RawAttachmentAction = - Omit; - interface IAttachmentTemporary { temporaryId: string; filename: string; @@ -40,14 +17,16 @@ interface Metadata { error?: AxiosError; } -export type UploadedAttachment = { uploaded: true; data: IData } & Metadata; +export type UploadedAttachment = { uploaded: true; data: IData; temporaryId?: string } & Metadata; export type TemporaryAttachment = { uploaded: false; data: IAttachmentTemporary } & Metadata; export type IAttachment = UploadedAttachment | TemporaryAttachment; -export interface IAttachments { +export interface IAttachmentsMap { [attachmentComponentId: string]: T[] | undefined; } export function isAttachmentUploaded(attachment: IAttachment): attachment is UploadedAttachment { return attachment.uploaded; } + +export type FileUploaderNode = LayoutNode>; diff --git a/src/features/attachments/sortAttachments.ts b/src/features/attachments/sortAttachments.ts new file mode 100644 index 0000000000..ffdcf36846 --- /dev/null +++ b/src/features/attachments/sortAttachments.ts @@ -0,0 +1,8 @@ +import type { IAttachment } from 'src/features/attachments/index'; + +export function sortAttachmentsByName(a: IAttachment, b: IAttachment) { + if (a.data.filename && b.data.filename) { + return a.data.filename.localeCompare(b.data.filename); + } + return 0; +} diff --git a/src/features/attachments/useAttachmentDeletionInRepGroups.ts b/src/features/attachments/useAttachmentDeletionInRepGroups.ts index 3754ffd197..132bcfb5b8 100644 --- a/src/features/attachments/useAttachmentDeletionInRepGroups.ts +++ b/src/features/attachments/useAttachmentDeletionInRepGroups.ts @@ -1,20 +1,16 @@ import { useCallback } from 'react'; -import { - useAttachments, - useAttachmentsAwaiter, - useAttachmentsRemover, -} from 'src/features/attachments/AttachmentsContext'; +import { AttachmentsPlugin } from 'src/features/attachments/AttachmentsPlugin'; +import { useAttachmentsAwaiter, useAttachmentsRemover, useAttachmentsSelector } from 'src/features/attachments/hooks'; import { isAttachmentUploaded } from 'src/features/attachments/index'; import { useAsRef } from 'src/hooks/useAsRef'; +import { NodesInternal } from 'src/utils/layout/NodesContext'; +import { useNodeTraversalSelector } from 'src/utils/layout/useNodeTraversal'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; +import type { TraversalRestriction } from 'src/utils/layout/useNodeTraversal'; type UploaderNode = LayoutNode<'FileUpload' | 'FileUploadWithTag'>; -function isUploaderNode(node: LayoutNode): node is UploaderNode { - return node.item.type === 'FileUpload' || node.item.type === 'FileUploadWithTag'; -} - /** * When deleting a row in a repeating group, we need to find any attachments that are uploaded * in that row (or any of its children) and remove them from the instance. @@ -27,29 +23,39 @@ function isUploaderNode(node: LayoutNode): node is UploaderNode { */ export function useAttachmentDeletionInRepGroups(node: LayoutNode<'RepeatingGroup'>) { const remove = useAsRef(useAttachmentsRemover()); - const awaiter = useAsRef(useAttachmentsAwaiter()); + const awaiter = useAttachmentsAwaiter(); const nodeRef = useAsRef(node); - const attachments = useAsRef(useAttachments()); + const attachmentsSelector = useAttachmentsSelector(); + const traversalSelector = useNodeTraversalSelector(); + const nodeItemSelector = NodesInternal.useNodeDataSelector(); return useCallback( - async (uuid: string): Promise => { - const uploaders = nodeRef.current.flat(true, { onlyInRowUuid: uuid }).filter(isUploaderNode); + async (restriction: TraversalRestriction): Promise => { + const uploaders = traversalSelector( + (t) => + t + .with(nodeRef.current) + .flat(undefined, restriction) + .filter((n) => n.def.hasPlugin(AttachmentsPlugin)), + [nodeRef.current, restriction], + ) as UploaderNode[]; // This code is intentionally not parallelized, as especially LocalTest can't handle parallel requests to // delete attachments. It might return a 500 if you try. To be safe, we do them one by one. for (const uploader of uploaders) { - const files = attachments.current[uploader.item.id] ?? []; + const files = attachmentsSelector(uploader); for (const file of files) { if (isAttachmentUploaded(file)) { const result = await remove.current({ attachment: file, node: uploader, + dataModelBindings: nodeItemSelector((picker) => picker(uploader)?.layout.dataModelBindings, [uploader]), }); if (!result) { return false; } } else { - const uploaded = await awaiter.current(file); + const uploaded = await awaiter(uploader, file); if (uploaded) { const result = await remove.current({ attachment: { @@ -59,6 +65,7 @@ export function useAttachmentDeletionInRepGroups(node: LayoutNode<'RepeatingGrou data: uploaded, }, node: uploader, + dataModelBindings: nodeItemSelector((picker) => picker(uploader)?.layout.dataModelBindings, [uploader]), }); if (!result) { return false; @@ -73,6 +80,6 @@ export function useAttachmentDeletionInRepGroups(node: LayoutNode<'RepeatingGrou return true; }, - [attachments, awaiter, remove, nodeRef], + [traversalSelector, nodeRef, attachmentsSelector, remove, nodeItemSelector, awaiter], ); } diff --git a/src/features/attachments/useAttachmentsMappedToFormData.tsx b/src/features/attachments/useAttachmentsMappedToFormData.tsx deleted file mode 100644 index ec9d4892b1..0000000000 --- a/src/features/attachments/useAttachmentsMappedToFormData.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import React from 'react'; - -import { createContext } from 'src/core/contexts/context'; -import { FD } from 'src/features/formData/FormDataWrite'; -import { useDataModelBindings } from 'src/features/formData/useDataModelBindings'; -import { type LayoutNode } from 'src/utils/layout/LayoutNode'; -import { BaseLayoutNode } from 'src/utils/layout/LayoutNode'; -import type { IDataModelBindingsSimple } from 'src/layout/common.generated'; -import type { IDataModelBindingsForList } from 'src/layout/List/config.generated'; - -interface MappingTools { - addAttachment: (uuid: string) => void; - removeAttachment: (uuid: string) => void; -} - -const noop = (node: LayoutNode<'FileUpload' | 'FileUploadWithTag'>): MappingTools => ({ - addAttachment: () => { - if (node.parent instanceof BaseLayoutNode && node.parent.isType('RepeatingGroup')) { - window.logError( - 'No valid data model binding for file uploader, cannot add attachment to form data. This is required ' + - 'when using a file uploader inside a repeating group.', - ); - } - }, - removeAttachment: () => { - if (node.parent instanceof BaseLayoutNode && node.parent.isType('RepeatingGroup')) { - window.logError( - 'No valid data model binding for file uploader, cannot remove attachment from form data. This is required ' + - 'when using a file uploader inside a repeating group.', - ); - } - }, -}); - -/** - * This hook is used to provide functionality for the FileUpload and FileUploadWithTag components, where uploading - * attachments into components in repeating groups need to map the attachment IDs to the form data. - * - * This is because repeating groups will create repeating structures (object[]) in the form data, but attachments - * are not part of the form data, so it would be unclear which row in a repeating group the attachment belongs to. - * Adding the attachment ID to the form data in that repeating group makes that clear, and this hook provides the - * functionality to call after uploading/removing attachments to update the form data. - */ -export function useAttachmentsMappedToFormData(node: LayoutNode<'FileUpload' | 'FileUploadWithTag'>): MappingTools { - const forList = useMappingToolsForList(node); - const forSimple = useMappingToolsForSimple(node); - const bindings = node.item.dataModelBindings; - if (!bindings) { - return noop(node); - } - - if ('list' in bindings) { - return forList; - } - - return forSimple; -} - -function useMappingToolsForList(node: LayoutNode<'FileUpload' | 'FileUploadWithTag'>): MappingTools { - const appendToListUnique = FD.useAppendToListUnique(); - const removeValueFromList = FD.useRemoveValueFromList(); - const field = ((node.item.dataModelBindings || {}) as IDataModelBindingsForList).list; - return { - addAttachment: (uuid: string) => { - appendToListUnique({ - path: field, - newValue: uuid, - }); - }, - removeAttachment: (uuid: string) => { - removeValueFromList({ - path: field, - value: uuid, - }); - }, - }; -} - -function useMappingToolsForSimple(node: LayoutNode<'FileUpload' | 'FileUploadWithTag'>): MappingTools { - const bindings = (node.item.dataModelBindings || {}) as IDataModelBindingsSimple; - const { setValue } = useDataModelBindings(bindings); - return { - addAttachment: (uuid: string) => { - setValue('simpleBinding', uuid); - }, - removeAttachment: () => { - setValue('simpleBinding', undefined); - }, - }; -} - -type ContextData = { mappingTools: MappingTools }; - -const { Provider, useCtx } = createContext({ name: 'AttachmentsMappedToFormDataContext', required: true }); - -/** - * If you need to provide the functionality of the useAttachmentsMappedToFormData hook deep in the component tree, - * you can use this context provider to do so. - */ -export function AttachmentsMappedToFormDataProvider({ children, mappingTools }: React.PropsWithChildren) { - return {children}; -} - -export const useAttachmentsMappedToFormDataProvider = () => useCtx().mappingTools; diff --git a/src/features/attachments/utils/mapping.ts b/src/features/attachments/utils/mapping.ts deleted file mode 100644 index 5f7d6d5755..0000000000 --- a/src/features/attachments/utils/mapping.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { ContextNotProvided } from 'src/core/contexts/context'; -import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; -import { FD } from 'src/features/formData/FormDataWrite'; -import { useLaxInstanceData } from 'src/features/instance/InstanceContext'; -import { useLaxProcessData } from 'src/features/instance/ProcessContext'; -import { useMemoDeepEqual } from 'src/hooks/useStateDeepEqual'; -import { useTaskStore } from 'src/layout/Summary2/taskIdStore'; -import { BaseLayoutNode } from 'src/utils/layout/LayoutNode'; -import { useNodes } from 'src/utils/layout/NodesContext'; -import type { ApplicationMetadata } from 'src/features/applicationMetadata/types'; -import type { FormDataSelector } from 'src/layout'; -import type { IData, IDataType } from 'src/types/shared'; -import type { LayoutNode } from 'src/utils/layout/LayoutNode'; -import type { LayoutPages } from 'src/utils/layout/LayoutPages'; - -export interface SimpleAttachments { - [attachmentComponentId: string]: IData[] | undefined; -} - -function validNodeType(node: LayoutNode): node is LayoutNode<'FileUpload' | 'FileUploadWithTag'> { - return node.item.type === 'FileUpload' || node.item.type === 'FileUploadWithTag'; -} - -function addAttachment(attachments: SimpleAttachments, node: LayoutNode, data: IData) { - if (!attachments[node.item.id]) { - attachments[node.item.id] = []; - } - attachments[node.item.id]?.push(data); -} - -function mapAttachments( - dataElements: IData[], - nodes: LayoutPages, - application: ApplicationMetadata, - currentTask: string | undefined, - formDataSelector: FormDataSelector | typeof ContextNotProvided, -): SimpleAttachments { - const attachments: SimpleAttachments = {}; - const dataTypeMap: { [key: string]: IDataType | undefined } = {}; - - for (const dataType of application.dataTypes) { - dataTypeMap[dataType.id] = dataType; - } - - for (const data of dataElements) { - const dataType = dataTypeMap[data.dataType]; - if (!dataType) { - window.logWarnOnce(`Attachment with id ${data.id} has an unknown dataType: ${data.dataType}`); - continue; - } - - if (dataType.taskId && dataType.taskId !== currentTask) { - continue; - } - - if (dataType.appLogic?.classRef) { - // Data models are not attachments - continue; - } - - if (dataType.id === 'ref-data-as-pdf') { - // Generated PDF receipts are not attachments - continue; - } - - const matchingNodes = nodes.findAllById(data.dataType).filter((node) => { - if (!validNodeType(node)) { - window.logWarnOnce( - `Attachment with id ${data.id} indicates it may belong to the component ${node.item.id}, which is ` + - `not a FileUpload or FileUploadWithTag (it is a ${node.item.type})`, - ); - return false; - } - return true; - }); - - // If there are multiple matching nodes, we need to find the one that has formData matching the attachment ID. - let found = false; - for (const node of matchingNodes) { - const bindings = node.item.dataModelBindings; - const simpleBinding = bindings && 'simpleBinding' in bindings ? bindings.simpleBinding : undefined; - const listBinding = bindings && 'list' in bindings ? bindings.list : undefined; - const simpleValue = - simpleBinding && formDataSelector !== ContextNotProvided ? formDataSelector(simpleBinding) : undefined; - const listValue = - listBinding && formDataSelector !== ContextNotProvided ? formDataSelector(listBinding) : undefined; - - const nodeIsInRepeatingGroup = node - .parents() - .some((parent) => parent instanceof BaseLayoutNode && parent.isType('RepeatingGroup')); - - if (simpleValue && simpleValue === data.id) { - addAttachment(attachments, node, data); - found = true; - break; - } - - if (listValue && Array.isArray(listValue) && listValue.some((binding) => binding === data.id)) { - addAttachment(attachments, node, data); - found = true; - break; - } - - if (!simpleBinding && !listBinding && !nodeIsInRepeatingGroup && matchingNodes.length === 1) { - // We can safely assume the attachment belongs to this node. - addAttachment(attachments, node, data); - found = true; - break; - } - } - - !found && - window.logErrorOnce( - `Could not find matching component/node for attachment ${data.dataType}/${data.id} (there may be a ` + - `problem with the mapping of attachments to form data in a repeating group). ` + - `Traversed ${matchingNodes.length} nodes with id ${data.dataType}`, - ); - } - - return attachments; -} - -/** - * This hook will map all attachments in the instance data to the nodes in the layout. - * It will however, not do anything with new attachments that are not yet uploaded as of loading the instance data. - * Use the `useAttachments` hook for that. - * - * @see useAttachments - */ -export function useMappedAttachments() { - const application = useApplicationMetadata(); - const currentTask = useLaxProcessData()?.currentTask?.elementId; - const data = useLaxInstanceData()?.data; - const nodes = useNodes(); - const formDataSelector = FD.useLaxDebouncedSelector(); - - const { overriddenTaskId } = useTaskStore(({ overriddenTaskId }) => ({ - overriddenTaskId, - })); - - const currentTaskId = overriddenTaskId || currentTask; - - return useMemoDeepEqual(() => { - if (data && nodes && application) { - return mapAttachments(data, nodes, application, currentTaskId, formDataSelector); - } - - return undefined; - }, [data, nodes, application, currentTaskId, formDataSelector]); -} diff --git a/src/features/attachments/utils/postUpload.ts b/src/features/attachments/utils/postUpload.ts deleted file mode 100644 index 0f4dfb2dce..0000000000 --- a/src/features/attachments/utils/postUpload.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { useEffect } from 'react'; -import { toast } from 'react-toastify'; -import type React from 'react'; - -import { useMutation } from '@tanstack/react-query'; -import { useImmerReducer } from 'use-immer'; -import type { AxiosError } from 'axios'; -import type { ImmerReducer } from 'use-immer'; - -import { useAppMutations } from 'src/core/contexts/AppQueriesProvider'; -import { useMappedAttachments } from 'src/features/attachments/utils/mapping'; -import { useLaxInstance, useLaxInstanceData } from 'src/features/instance/InstanceContext'; -import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; -import { useLanguage } from 'src/features/language/useLanguage'; -import type { - AttachmentActionRemove, - AttachmentActionUpdate, - IAttachments, - RawAttachmentAction, - UploadedAttachment, -} from 'src/features/attachments'; -import type { SimpleAttachments } from 'src/features/attachments/utils/mapping'; -import type { HttpClientError } from 'src/utils/network/sharedNetworking'; - -type Update = AttachmentActionUpdate & { success: undefined }; -type UpdateFulfilled = AttachmentActionUpdate & { success: true }; -type UpdateRejected = AttachmentActionUpdate & { success: false; error: AxiosError }; - -type Remove = AttachmentActionRemove & { success: undefined }; -type RemoveFulfilled = AttachmentActionRemove & { success: true }; -type RemoveRejected = AttachmentActionRemove & { success: false; error: AxiosError }; - -type ActionReplaceAll = { action: 'replaceAll'; attachments: SimpleAttachments }; - -type Actions = Update | UpdateFulfilled | UpdateRejected | Remove | RemoveFulfilled | RemoveRejected | ActionReplaceAll; -type Dispatch = React.Dispatch; - -const reducer: ImmerReducer, Actions> = (draft, action) => { - if (action.action === 'replaceAll') { - const { attachments } = action; - const out: IAttachments = {}; - - for (const nodeId in attachments) { - for (const attachment of attachments[nodeId]!) { - out[nodeId] = out[nodeId] || []; - out[nodeId]?.push({ - uploaded: true, - updating: false, - deleting: false, - data: attachment, - }); - } - } - - return out; - } - if (action.action === 'update' && action.success === undefined) { - const { tags, attachment, node } = action; - - const attachments = draft[node.item.id]; - if (attachments) { - const index = attachments.findIndex((a) => a.data.id === attachment.data.id); - if (index !== -1) { - attachments[index].updating = true; - attachments[index].data.tags = tags; - } - } - return draft; - } - if (action.action === 'update' && action.success) { - const { attachment, node } = action; - - const attachments = draft[node.item.id]; - if (attachments) { - const index = attachments.findIndex((a) => a.data.id === attachment.data.id); - if (index !== -1) { - attachments[index].updating = false; - } - } - return draft; - } - if (action.action === 'update' && !action.success) { - const { attachment, node, error } = action; - - const attachments = draft[node.item.id]; - if (attachments) { - const index = attachments.findIndex((a) => a.data.id === attachment.data.id); - if (index !== -1) { - attachments[index].updating = false; - attachments[index].error = error; - } - } - return draft; - } - if (action.action === 'remove' && action.success === undefined) { - const { attachment, node } = action; - - const attachments = draft[node.item.id]; - if (attachments) { - const index = attachments.findIndex((a) => a.data.id === attachment.data.id); - if (index !== -1) { - attachments[index].deleting = true; - } - } - return draft; - } - if (action.action === 'remove' && action.success) { - const { attachment, node } = action; - - const attachments = draft[node.item.id]; - if (attachments) { - const index = attachments.findIndex((a) => a.data.id === attachment.data.id); - if (index !== -1) { - attachments.splice(index, 1); - } - } - return draft; - } - if (action.action === 'remove' && !action.success) { - const { attachment, node, error } = action; - - const attachments = draft[node.item.id]; - if (attachments) { - const index = attachments.findIndex((a) => a.data.id === attachment.data.id); - if (index !== -1) { - attachments[index].deleting = false; - attachments[index].error = error; - } - } - return draft; - } - - throw new Error('Invalid action'); -}; - -const initialState: IAttachments = {}; - -export const usePostUpload = () => { - const fromInstance = useMappedAttachments(); - - const [state, dispatch] = useImmerReducer(reducer, initialState); - const update = useUpdate(dispatch); - const remove = useRemove(dispatch); - - useEffect(() => { - dispatch({ action: 'replaceAll', attachments: fromInstance || {} }); - }, [dispatch, fromInstance]); - - return { - state, - update, - remove, - }; -}; - -const useUpdate = (dispatch: Dispatch) => { - const { mutateAsync: removeTag } = useAttachmentsRemoveTagMutation(); - const { mutateAsync: addTag } = useAttachmentsAddTagMutation(); - const { changeData: changeInstanceData } = useLaxInstance() || {}; - const { lang } = useLanguage(); - - return async (action: RawAttachmentAction) => { - const { tags, attachment } = action; - const tagToAdd = tags.filter((t) => !attachment.data.tags?.includes(t)); - const tagToRemove = attachment.data.tags?.filter((t) => !tags.includes(t)) || []; - const areEqual = tagToAdd.length && tagToRemove.length && tagToAdd[0] === tagToRemove[0]; - - // If there are no tags to add or remove, or if the tags are the same, do nothing. - if ((!tagToAdd.length && !tagToRemove.length) || areEqual) { - return; - } - - dispatch({ ...action, action: 'update', success: undefined }); - try { - if (tagToAdd.length) { - await Promise.all(tagToAdd.map((tag) => addTag({ dataGuid: attachment.data.id, tagToAdd: tag }))); - } - if (tagToRemove.length) { - await Promise.all(tagToRemove.map((tag) => removeTag({ dataGuid: attachment.data.id, tagToRemove: tag }))); - } - dispatch({ ...action, action: 'update', success: true }); - - changeInstanceData && - changeInstanceData((instance) => { - if (instance?.data) { - return { - ...instance, - data: instance.data.map((dataElement) => { - if (dataElement.id === attachment.data.id) { - return { - ...dataElement, - tags, - }; - } - return dataElement; - }), - }; - } - }); - } catch (error) { - dispatch({ ...action, action: 'update', success: false, error }); - toast(lang('form_filler.file_uploader_validation_error_update'), { type: 'error' }); - } - }; -}; - -const useRemove = (dispatch: Dispatch) => { - const { mutateAsync: removeAttachment } = useAttachmentsRemoveMutation(); - const { changeData: changeInstanceData } = useLaxInstance() || {}; - const { lang } = useLanguage(); - - return async (action: RawAttachmentAction) => { - dispatch({ ...action, action: 'remove', success: undefined }); - try { - await removeAttachment(action.attachment.data.id); - dispatch({ ...action, action: 'remove', success: true }); - - changeInstanceData && - changeInstanceData((instance) => { - if (instance?.data) { - return { - ...instance, - data: instance.data.filter((d) => d.id !== action.attachment.data.id), - }; - } - }); - - return true; - } catch (error) { - dispatch({ ...action, action: 'remove', success: false, error }); - toast(lang('form_filler.file_uploader_validation_error_delete'), { type: 'error' }); - return false; - } - }; -}; - -function useAttachmentsAddTagMutation() { - const { doAttachmentAddTag } = useAppMutations(); - const instanceId = useLaxInstanceData()?.id; - - return useMutation({ - mutationFn: ({ dataGuid, tagToAdd }: { dataGuid: string; tagToAdd: string }) => { - if (!instanceId) { - throw new Error('Missing instanceId, cannot add attachment'); - } - - return doAttachmentAddTag(instanceId, dataGuid, tagToAdd); - }, - onError: (error: HttpClientError) => { - window.logError('Failed to add tag to attachment:\n', error); - }, - }); -} - -function useAttachmentsRemoveTagMutation() { - const { doAttachmentRemoveTag } = useAppMutations(); - const instanceId = useLaxInstanceData()?.id; - - return useMutation({ - mutationFn: ({ dataGuid, tagToRemove }: { dataGuid: string; tagToRemove: string }) => { - if (!instanceId) { - throw new Error('Missing instanceId, cannot remove attachment'); - } - - return doAttachmentRemoveTag(instanceId, dataGuid, tagToRemove); - }, - onError: (error: HttpClientError) => { - window.logError('Failed to remove tag from attachment:\n', error); - }, - }); -} - -function useAttachmentsRemoveMutation() { - const { doAttachmentRemove } = useAppMutations(); - const instanceId = useLaxInstanceData()?.id; - const language = useCurrentLanguage(); - - return useMutation({ - mutationFn: (dataGuid: string) => { - if (!instanceId) { - throw new Error('Missing instanceId, cannot remove attachment'); - } - - return doAttachmentRemove(instanceId, dataGuid, language); - }, - onError: (error: HttpClientError) => { - window.logError('Failed to delete attachment:\n', error); - }, - }); -} diff --git a/src/features/attachments/utils/preUpload.ts b/src/features/attachments/utils/preUpload.ts deleted file mode 100644 index 7fde4d97b5..0000000000 --- a/src/features/attachments/utils/preUpload.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { useCallback } from 'react'; -import { toast } from 'react-toastify'; -import type React from 'react'; - -import { useMutation } from '@tanstack/react-query'; -import { isAxiosError } from 'axios'; -import { useImmerReducer } from 'use-immer'; -import { v4 as uuidv4 } from 'uuid'; -import type { UseMutationOptions } from '@tanstack/react-query'; -import type { ImmerReducer } from 'use-immer'; - -import { useAppMutations } from 'src/core/contexts/AppQueriesProvider'; -import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; -import { useLaxInstance, useLaxInstanceData } from 'src/features/instance/InstanceContext'; -import { useLanguage } from 'src/features/language/useLanguage'; -import { type BackendValidationIssue } from 'src/features/validation'; -import { getValidationIssueMessage } from 'src/features/validation/backendValidation/backendValidationUtils'; -import { useAsRef } from 'src/hooks/useAsRef'; -import { useWaitForState } from 'src/hooks/useWaitForState'; -import type { - AttachmentActionUpload, - IAttachment, - IAttachments, - RawAttachmentAction, - TemporaryAttachment, -} from 'src/features/attachments'; -import type { IData } from 'src/types/shared'; -import type { LayoutNode } from 'src/utils/layout/LayoutNode'; -import type { HttpClientError } from 'src/utils/network/sharedNetworking'; - -interface ActionUpload extends AttachmentActionUpload { - temporaryId: string; -} - -interface ActionRemove { - action: 'remove'; - node: LayoutNode<'FileUploadWithTag' | 'FileUpload'>; - temporaryId: string; - result: IData | false; -} - -type Actions = ActionUpload | ActionRemove; - -interface State { - uploading: IAttachments; - uploadedResults: { - [temporaryId: string]: IData | undefined; - }; - failedTmpIds: string[]; -} - -const reducer: ImmerReducer = (draft, action) => { - const { node, temporaryId } = action; - const { id } = node.item; - - if (action.action === 'upload') { - const { file } = action; - draft.uploading[id] = draft.uploading[id] || []; - (draft.uploading[id] as IAttachment[]).push({ - uploaded: false, - updating: false, - deleting: false, - data: { - temporaryId, - filename: file.name, - size: file.size, - }, - }); - } else if (action.action === 'remove') { - const attachments = draft.uploading[id]; - if (attachments) { - const index = attachments.findIndex((a) => a.data.temporaryId === temporaryId); - if (index !== -1) { - attachments.splice(index, 1); - } - } - if (action.result) { - draft.uploadedResults[temporaryId] = action.result; - } else { - draft.failedTmpIds.push(temporaryId); - } - } -}; - -type Dispatch = React.Dispatch; -const initialState: State = { - uploading: {}, - uploadedResults: {}, - failedTmpIds: [], -}; -export const usePreUpload = () => { - const [state, dispatch] = useImmerReducer(reducer, initialState); - const upload = useUpload(dispatch); - const stateRef = useAsRef(state); - const waitFor = useWaitForState(stateRef); - - const awaitUpload = useCallback( - (attachment: TemporaryAttachment) => - waitFor((s, setRetVal) => { - const { uploadedResults, failedTmpIds } = s; - const { temporaryId } = attachment.data; - const result = uploadedResults[temporaryId]; - if (result) { - setRetVal(result); - return true; - } - if (failedTmpIds.includes(temporaryId)) { - setRetVal(false); - return true; - } - return false; - }), - [waitFor], - ); - - return { state: state.uploading, upload, awaitUpload }; -}; - -/** - * Do not use this directly, use the `useAttachmentsUploader` hook instead. - * @see useAttachmentsUploader - */ -const useUpload = (dispatch: Dispatch) => { - const { changeData: changeInstanceData } = useLaxInstance() ?? {}; - const { mutateAsync } = useAttachmentsUploadMutation(); - const { langAsString, lang } = useLanguage(); - const backendFeatures = useApplicationMetadata().features; - - return async (action: RawAttachmentAction) => { - const { node, file } = action; - const temporaryId = uuidv4(); - dispatch({ ...action, temporaryId, action: 'upload' }); - - try { - const reply = await mutateAsync({ - dataTypeId: node.item.baseComponentId || node.item.id, - file, - }); - if (!reply || !reply.blobStoragePath) { - throw new Error('Failed to upload attachment'); - } - - dispatch({ action: 'remove', node, temporaryId, result: reply }); - changeInstanceData && - changeInstanceData((instance) => { - if (instance?.data && reply) { - return { - ...instance, - data: [...instance.data, reply], - }; - } - - return instance; - }); - - return reply.id; - } catch (err) { - dispatch({ action: 'remove', node, temporaryId, result: false }); - - if (backendFeatures?.jsonObjectInDataResponse && isAxiosError(err) && err.response?.data) { - const validationIssues: BackendValidationIssue[] = err.response.data; - const message = validationIssues - .map((issue) => getValidationIssueMessage(issue)) - .map(({ key, params }) => `- ${langAsString(key, params)}`) - .join('\n'); - toast(message, { type: 'error' }); - } else { - toast(lang('form_filler.file_uploader_validation_error_upload'), { type: 'error' }); - } - } - - return undefined; - }; -}; - -interface MutationVariables { - dataTypeId: string; - file: File; -} - -function useAttachmentsUploadMutation() { - const { doAttachmentUpload } = useAppMutations(); - const instanceId = useLaxInstanceData()?.id; - - const options: UseMutationOptions = { - mutationFn: ({ dataTypeId, file }: MutationVariables) => { - if (!instanceId) { - throw new Error('Missing instanceId, cannot upload attachment'); - } - - return doAttachmentUpload(instanceId, dataTypeId, file); - }, - onError: (error: HttpClientError) => { - window.logError('Failed to upload attachment:\n', error.message); - }, - }; - - return useMutation(options); -} diff --git a/src/features/attachments/utils/sorting.test.ts b/src/features/attachments/utils/sorting.test.ts deleted file mode 100644 index c4285c3617..0000000000 --- a/src/features/attachments/utils/sorting.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { getAttachmentDataMock, getAttachmentMock } from 'src/__mocks__/getAttachmentsMock'; -import { mergeAndSort } from 'src/features/attachments/utils/sorting'; - -const multiInSecondRow1 = getAttachmentMock({ data: getAttachmentDataMock({ filename: 'multiInSecondRow1.pdf' }) }); -const multiInSecondRow2 = getAttachmentMock({ data: getAttachmentDataMock({ filename: 'multiInSecondRow2.pdf' }) }); -const multiInSecondRow3 = getAttachmentMock({ data: getAttachmentDataMock({ filename: 'multiInSecondRow3.pdf' }) }); -const multiInSecondRow4 = getAttachmentMock({ data: getAttachmentDataMock({ filename: 'multiInSecondRow4.pdf' }) }); - -const nestedRow1Sub01 = getAttachmentMock({ data: getAttachmentDataMock({ filename: 'nested-row1-sub0-1.pdf' }) }); -const nestedRow1Sub02 = getAttachmentMock({ data: getAttachmentDataMock({ filename: 'nested-row1-sub0-2.pdf' }) }); -const nestedRow1Sub03 = getAttachmentMock({ data: getAttachmentDataMock({ filename: 'nested-row1-sub0-3.pdf' }) }); -const nestedRow1Sub11 = getAttachmentMock({ data: getAttachmentDataMock({ filename: 'nested-row1-sub1-1.pdf' }) }); -const nestedRow1Sub13 = getAttachmentMock({ data: getAttachmentDataMock({ filename: 'nested-row1-sub1-3.pdf' }) }); - -const singeFileInSndRow = getAttachmentMock({ data: getAttachmentDataMock({ filename: 'singleFileInSecondRow.pdf' }) }); - -describe('mergeAndSort', () => { - it('should sort attachments', () => { - const postUpload = { - 'mainUploaderMulti-0': [multiInSecondRow3, multiInSecondRow1, multiInSecondRow4, multiInSecondRow2], - 'subUploader-0-1': [nestedRow1Sub13, nestedRow1Sub11], - 'mainUploaderSingle-0': [singeFileInSndRow], - 'subUploader-0-0': [nestedRow1Sub01, nestedRow1Sub03, nestedRow1Sub02], - }; - - const expected = { - 'mainUploaderMulti-0': [multiInSecondRow1, multiInSecondRow2, multiInSecondRow3, multiInSecondRow4], - 'mainUploaderSingle-0': [singeFileInSndRow], - 'subUploader-0-1': [nestedRow1Sub11, nestedRow1Sub13], - 'subUploader-0-0': [nestedRow1Sub01, nestedRow1Sub02, nestedRow1Sub03], - }; - - const result = mergeAndSort({}, postUpload); - expect(result).toEqual(expected); - }); -}); diff --git a/src/features/attachments/utils/sorting.ts b/src/features/attachments/utils/sorting.ts deleted file mode 100644 index 64476ca814..0000000000 --- a/src/features/attachments/utils/sorting.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { IAttachment, IAttachments } from 'src/features/attachments/index'; - -export function mergeAndSort(...args: IAttachments[]) { - const result: IAttachments = {}; - for (const map of args) { - for (const nodeId of Object.keys(map)) { - const next = map[nodeId]; - const existing = result[nodeId]; - if (existing && next) { - existing.push(...next); - } else if (!existing && next) { - result[nodeId] = [...next]; - } - } - } - - // Sort all attachments by name - Object.keys(result).forEach((nodeId) => { - const attachments = result[nodeId]; - if (attachments) { - attachments.sort(sortAttachmentsByName); - } - }); - - return result; -} - -export function sortAttachmentsByName(a: IAttachment, b: IAttachment) { - if (a.data.filename && b.data.filename) { - return a.data.filename.localeCompare(b.data.filename); - } - return 0; -} diff --git a/src/features/customValidation/customValidationUtils.ts b/src/features/customValidation/customValidationUtils.ts index 1d5906340a..84d68d3ed2 100644 --- a/src/features/customValidation/customValidationUtils.ts +++ b/src/features/customValidation/customValidationUtils.ts @@ -1,6 +1,5 @@ import { ExprVal } from 'src/features/expressions/types'; -import { asExpression } from 'src/features/expressions/validation'; -import type { ExprConfig } from 'src/features/expressions/types'; +import { ExprValidation } from 'src/features/expressions/validation'; import type { IExpressionValidation, IExpressionValidationConfig, @@ -9,12 +8,6 @@ import type { IExpressionValidations, } from 'src/features/validation'; -const EXPR_CONFIG: ExprConfig = { - defaultValue: false, - returnType: ExprVal.Boolean, - resolvePerRow: false, -}; - /** * Resolves a reusable expression validation definition. */ @@ -38,11 +31,11 @@ function resolveExpressionValidationDefinition( resolvedDefinition = { ...reference, ...definitionWithoutRef }; } - resolvedDefinition.condition = asExpression( - resolvedDefinition.condition, - EXPR_CONFIG, - `Custom validation:\nDefinition for ${name} has an invalid condition.`, - ); + const errorMessage = `Custom validation:\nDefinition for ${name} has an invalid condition.`; + const condition = resolvedDefinition.condition; + if (!ExprValidation.isValidOrScalar(condition, ExprVal.Boolean, errorMessage)) { + resolvedDefinition.condition = false; + } if (!('message' in resolvedDefinition)) { window.logWarn(`Custom validation:\nDefinition for ${name} is missing a message.`); @@ -102,11 +95,11 @@ function resolveExpressionValidation( } as IExpressionValidation; } - expressionValidation.condition = asExpression( - expressionValidation.condition, - EXPR_CONFIG, - `Custom validation:\nValidation for ${field} has an invalid condition.`, - ) as typeof expressionValidation.condition; + const errorMessage = `Custom validation:\nValidation for ${field} has an invalid condition.`; + const condition = expressionValidation.condition; + if (!ExprValidation.isValidOrScalar(condition, ExprVal.Boolean, errorMessage)) { + expressionValidation.condition = false; + } if (!('message' in expressionValidation)) { window.logWarn(`Custom validation:\nValidation for ${field} is missing a message.`); @@ -122,7 +115,7 @@ function resolveExpressionValidation( } /** - * Takes an expression validation config and returnes an object with the field validation definitions resolved. + * Takes an expression validation config and returns an object with the field validation definitions resolved. */ export function resolveExpressionValidationConfig(config: IExpressionValidationConfig): IExpressionValidations { const resolvedDefinitions: { [name: string]: IExpressionValidationRefResolved } = {}; diff --git a/src/features/dataLists/index.d.ts b/src/features/dataLists/index.d.ts deleted file mode 100644 index 5d02c88650..0000000000 --- a/src/features/dataLists/index.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { IDataListsMetaData } from 'src/types'; - -export interface IDataList { - listItems: Record[]; - _metaData: IDataListsMetaData; -} diff --git a/src/features/dataLists/index.ts b/src/features/dataLists/index.ts new file mode 100644 index 0000000000..f9dd3061ba --- /dev/null +++ b/src/features/dataLists/index.ts @@ -0,0 +1,14 @@ +export interface IDataList { + listItems: Record[]; + _metaData: IDataListsMetaData; +} + +export interface IDataListsMetaData { + page: number; + pageCount: number; + pageSize: number; + totaltItemsCount: number; + + // Used for manual navigation of pages when looking at the API response + links: string[]; +} diff --git a/src/features/datamodel/dataModelLookups.test.ts b/src/features/datamodel/dataModelLookups.test.ts deleted file mode 100644 index 0c6b47e32d..0000000000 --- a/src/features/datamodel/dataModelLookups.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import fs from 'node:fs'; - -import { getHierarchyDataSourcesMock } from 'src/__mocks__/getHierarchyDataSourcesMock'; -import { dotNotationToPointer } from 'src/features/datamodel/notations'; -import { lookupBindingInSchema } from 'src/features/datamodel/SimpleSchemaTraversal'; -import { getLayoutComponentObject } from 'src/layout'; -import { ensureAppsDirIsSet, getAllLayoutSetsWithDataModelSchema, parseJsonTolerantly } from 'src/test/allApps'; -import { generateEntireHierarchy } from 'src/utils/layout/HierarchyGenerator'; -import { getRootElementPath } from 'src/utils/schemaUtils'; -import type { LayoutValidationCtx } from 'src/features/devtools/layoutValidation/types'; -import type { ILayouts } from 'src/layout/layout'; - -describe('Data model lookups in real apps', () => { - const dir = ensureAppsDirIsSet(); - if (!dir) { - return; - } - - const all = getAllLayoutSetsWithDataModelSchema(dir); - const { out: allLayoutSets, notFound } = all; - it.each(allLayoutSets)('$appName/$setName', ({ layouts, modelPath, dataTypeDef }) => { - const firstKey = Object.keys(layouts)[0]; - // TODO: We should generate some sensible form data for repeating groups (and their nodes) to work, so that - // we can test those as well. It could be as simple as analyzing the layout and generating a form data object - // with one entry for each repeating group. - const processedLayouts: ILayouts = {}; - for (const page of Object.keys(layouts)) { - processedLayouts[page] = layouts[page].data.layout; - } - - const nodes = generateEntireHierarchy( - processedLayouts, - firstKey, - getHierarchyDataSourcesMock(), - getLayoutComponentObject, - ); - - const schema = parseJsonTolerantly(fs.readFileSync(modelPath, 'utf-8')); - const rootPath = getRootElementPath(schema, dataTypeDef); - const failures: any[] = []; - - for (const [pageKey, layout] of Object.entries(nodes.all())) { - for (const node of layout.flat(true)) { - const ctx: LayoutValidationCtx = { - node, - lookupBinding(binding: string) { - const schemaPath = dotNotationToPointer(binding); - return lookupBindingInSchema({ - schema, - targetPointer: schemaPath, - rootElementPath: rootPath, - }); - }, - }; - - if ('validateDataModelBindings' in node.def) { - const errors = node.def.validateDataModelBindings(ctx); - if (errors.length) { - failures.push({ - pageKey, - component: node.item.baseComponentId || node.item.id, - errors, - }); - } - } - } - } - - expect(JSON.stringify(failures, null, 2)).toEqual('[]'); - }); - - it('expected to find data model schema for all apps/sets (do not expect this to pass, broken apps exist)', () => { - expect(notFound).toEqual([]); - }); -}); diff --git a/src/features/devtools/components/DevHiddenFunctionality/DevHiddenFunctionality.tsx b/src/features/devtools/components/DevHiddenFunctionality/DevHiddenFunctionality.tsx index 5c4d1f45cf..7afd9c10e3 100644 --- a/src/features/devtools/components/DevHiddenFunctionality/DevHiddenFunctionality.tsx +++ b/src/features/devtools/components/DevHiddenFunctionality/DevHiddenFunctionality.tsx @@ -5,8 +5,10 @@ import { Fieldset, ToggleGroup } from '@digdir/designsystemet-react'; import { useDevToolsStore } from 'src/features/devtools/data/DevToolsStore'; import { useComponentRefs } from 'src/features/devtools/hooks/useComponentRefs'; import { useIsInFormContext } from 'src/features/form/FormContext'; -import { useNodes } from 'src/utils/layout/NodesContext'; +import { Hidden } from 'src/utils/layout/NodesContext'; +import { useNodeTraversalSelector } from 'src/utils/layout/useNodeTraversal'; import type { IDevToolsState } from 'src/features/devtools/data/types'; +import type { IsHiddenOptions } from 'src/utils/layout/NodesContext'; const pseudoHiddenCssFilter = 'contrast(0.75)'; @@ -22,28 +24,10 @@ export function DevHiddenFunctionality() { function InnerDevHiddenFunctionality() { const state = useDevToolsStore((state) => state.hiddenComponents); const setShowHiddenComponents = useDevToolsStore((state) => state.actions.setShowHiddenComponents); - const hierarchy = useNodes(); - - useComponentRefs({ - callback: (id, ref) => { - const node = hierarchy?.findById(id); - if (node) { - if (ref.style.filter === pseudoHiddenCssFilter && state !== 'disabled') { - ref.style.filter = ''; - } else if (state === 'disabled' && node.isHidden({ respectDevTools: false })) { - ref.style.filter = pseudoHiddenCssFilter; - } - } - }, - cleanupCallback: (_, ref) => { - if (ref.style.filter === pseudoHiddenCssFilter) { - ref.style.filter = ''; - } - }, - }); return (
+
); } + +const isHiddenOptions: IsHiddenOptions = { respectDevTools: false }; +function MarkHiddenComponents() { + const state = useDevToolsStore((state) => state.hiddenComponents); + const isHiddenSelector = Hidden.useIsHiddenSelector(); + const traversalSelector = useNodeTraversalSelector(); + + useComponentRefs({ + callback: (id, ref) => { + if (ref.style.filter === pseudoHiddenCssFilter && state !== 'disabled') { + ref.style.filter = ''; + } else if (state === 'disabled') { + const node = traversalSelector((t) => t.findById(id), [id]); + const isHidden = node ? isHiddenSelector(node, isHiddenOptions) : true; + if (isHidden) { + ref.style.filter = pseudoHiddenCssFilter; + } + } + }, + cleanupCallback: (_, ref) => { + if (ref.style.filter === pseudoHiddenCssFilter) { + ref.style.filter = ''; + } + }, + }); + + return null; +} diff --git a/src/features/devtools/components/DevNavigationButtons/DevNavigationButtons.tsx b/src/features/devtools/components/DevNavigationButtons/DevNavigationButtons.tsx index 3b6065c5dc..56f6c2f6a9 100644 --- a/src/features/devtools/components/DevNavigationButtons/DevNavigationButtons.tsx +++ b/src/features/devtools/components/DevNavigationButtons/DevNavigationButtons.tsx @@ -5,11 +5,12 @@ import cn from 'classnames'; import classes from 'src/features/devtools/components/DevNavigationButtons/DevNavigationButtons.module.css'; import { useIsInFormContext } from 'src/features/form/FormContext'; -import { useIsHiddenPage } from 'src/features/form/layout/PageNavigationContext'; import { useLayoutSettings } from 'src/features/form/layoutSettings/LayoutSettingsContext'; +import { useNavigationParam } from 'src/features/routing/AppRoutingContext'; import { useNavigatePage } from 'src/hooks/useNavigatePage'; import comboboxClasses from 'src/styles/combobox.module.css'; -import { useNodes } from 'src/utils/layout/NodesContext'; +import { Hidden } from 'src/utils/layout/NodesContext'; +import { useNodeTraversal } from 'src/utils/layout/useNodeTraversal'; export function DevNavigationButtons() { const isInForm = useIsInFormContext(); @@ -21,12 +22,12 @@ export function DevNavigationButtons() { } const InnerDevNavigationButtons = () => { - const { navigateToPage, currentPageId } = useNavigatePage(); - const isHiddenPage = useIsHiddenPage(); + const pageKey = useNavigationParam('pageKey'); + const { navigateToPage } = useNavigatePage(); + const isHiddenPage = Hidden.useIsHiddenPageSelector(); const orderWithHidden = useLayoutSettings().pages.order; - const ctx = useNodes(); const order = orderWithHidden ?? []; - const allPages = ctx?.allPageKeys() || []; + const allPages = useNodeTraversal((t) => t.children().map((p) => p.pageKey)); function handleChange(values: string[]) { const newView = values.at(0); @@ -85,7 +86,7 @@ const InnerDevNavigationButtons = () => { // TODO(DevTools): Navigate to hidden pages is not working disabled={isHidden(page)} onClick={() => handleChange([page])} - selected={currentPageId == page} + selected={pageKey == page} > {page} @@ -95,7 +96,7 @@ const InnerDevNavigationButtons = () => {
diff --git a/src/features/devtools/components/ExpressionPlayground/ExpressionPlayground.tsx b/src/features/devtools/components/ExpressionPlayground/ExpressionPlayground.tsx index 4ab6b584c5..c2b2592e3b 100644 --- a/src/features/devtools/components/ExpressionPlayground/ExpressionPlayground.tsx +++ b/src/features/devtools/components/ExpressionPlayground/ExpressionPlayground.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { Checkbox, Combobox, Fieldset, Tabs } from '@digdir/designsystemet-react'; import cn from 'classnames'; @@ -9,11 +9,12 @@ import { useDevToolsStore } from 'src/features/devtools/data/DevToolsStore'; import { DevToolsTab } from 'src/features/devtools/data/types'; import { evalExpr } from 'src/features/expressions'; import { ExprVal } from 'src/features/expressions/types'; -import { asExpression } from 'src/features/expressions/validation'; -import { useNavigatePage } from 'src/hooks/useNavigatePage'; +import { ExprValidation } from 'src/features/expressions/validation'; +import { useNavigationParam } from 'src/features/routing/AppRoutingContext'; import comboboxClasses from 'src/styles/combobox.module.css'; -import { useExpressionDataSources } from 'src/utils/layout/hierarchy'; -import { useIsHiddenComponent, useNodes } from 'src/utils/layout/NodesContext'; +import { useNodes } from 'src/utils/layout/NodesContext'; +import { useExpressionDataSources } from 'src/utils/layout/useExpressionDataSources'; +import { useNodeTraversal, useNodeTraversalSelector } from 'src/utils/layout/useNodeTraversal'; import type { ExprConfig, Expression, ExprFunction } from 'src/features/expressions/types'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; import type { LayoutPage } from 'src/utils/layout/LayoutPage'; @@ -47,19 +48,10 @@ export const ExpressionPlayground = () => { }, ]); const nodes = useNodes(); - const { currentPageId } = useNavigatePage(); + const currentPageId = useNavigationParam('pageKey'); const selectedContext = forPage && forComponentId ? [`${forPage}|${forComponentId}`] : []; - - const isHidden = useIsHiddenComponent(); - const _dataSources = useExpressionDataSources(isHidden); - const dataSources = useMemo( - () => ({ - ..._dataSources, - formDataSelector: (path: string) => _dataSources.formDataSelector(path), - }), - [_dataSources], - ); + const dataSources = useExpressionDataSources(); const setOutputWithHistory = useCallback( (newValue: string, isError: boolean): boolean => { @@ -82,6 +74,12 @@ export const ExpressionPlayground = () => { // populating the output history with a fresh value. const resetOutputHistory = () => setOutputs([]); + const traversalSelector = useNodeTraversalSelector(); + + const componentOptions = useNodeTraversal((t) => + t.allNodes().map((n) => ({ label: n.id, value: `${n.page.pageKey}|${n.id}` })), + ); + useEffect(() => { if (!input || input.length <= 0) { if (!outputs[0] || outputs[0]?.value !== '') { @@ -91,7 +89,7 @@ export const ExpressionPlayground = () => { } try { - let maybeExpression: string; + let maybeExpression: unknown; try { maybeExpression = JSON.parse(input); } catch (e) { @@ -104,22 +102,28 @@ export const ExpressionPlayground = () => { const config: ExprConfig = { returnType: ExprVal.Any, defaultValue: null, - resolvePerRow: false, - errorAsException: true, }; - const expr = asExpression(maybeExpression, config); - if (!expr) { - throw new Error('Ugyldig uttrykk'); - } + ExprValidation.throwIfInvalid(maybeExpression); - let evalContext: LayoutPage | LayoutNode | undefined = nodes?.current(); + let evalContext: LayoutPage | LayoutNode | undefined = traversalSelector( + (t) => t.findPage(currentPageId), + [currentPageId], + ); if (!evalContext) { throw new Error('Fant ikke nåværende side/layout'); } if (forPage && forComponentId) { - const foundNode = nodes?.findLayout(forPage)?.findById(forComponentId); + const foundNode = traversalSelector( + (t) => { + const page = t.findPage(forPage); + return page + ? t.with(page).children((i) => i.type === 'node' && i.item?.id === forComponentId)[0] + : undefined; + }, + [forPage, forComponentId], + ); if (foundNode) { evalContext = foundNode; } @@ -131,7 +135,7 @@ export const ExpressionPlayground = () => { calls.push(`${indent}${JSON.stringify([func, ...args])} => ${JSON.stringify(result)}`); }; - const out = evalExpr(expr as Expression, evalContext, dataSources, { config, onAfterFunctionCall }); + const out = evalExpr(maybeExpression as Expression, evalContext, dataSources, { config, onAfterFunctionCall }); if (showAllSteps) { setOutputWithHistory(calls.join('\n'), false); @@ -143,7 +147,18 @@ export const ExpressionPlayground = () => { setOutputs([{ value: e.message, isError: true }]); } } - }, [input, forPage, forComponentId, dataSources, nodes, showAllSteps, outputs, setOutputWithHistory]); + }, [ + input, + forPage, + forComponentId, + dataSources, + nodes, + showAllSteps, + outputs, + setOutputWithHistory, + currentPageId, + traversalSelector, + ]); return (
@@ -224,18 +239,15 @@ export const ExpressionPlayground = () => { }} className={comboboxClasses.container} > - {Object.values(nodes?.all() || []) - .map((page) => page.flat(true)) - .flat() - .map((n) => ( - - {n.item.id} - - ))} + {componentOptions.map(({ value, label }) => ( + + {label} + + ))} {forComponentId && forPage === currentPageId && ( // eslint-disable-next-line jsx-a11y/anchor-is-valid diff --git a/src/features/devtools/components/LayoutInspector/LayoutInspector.tsx b/src/features/devtools/components/LayoutInspector/LayoutInspector.tsx index af1ba43de8..4bb3eb8b49 100644 --- a/src/features/devtools/components/LayoutInspector/LayoutInspector.tsx +++ b/src/features/devtools/components/LayoutInspector/LayoutInspector.tsx @@ -12,7 +12,7 @@ import { useLayoutValidationForPage } from 'src/features/devtools/layoutValidati import { useLayouts, useLayoutSetId } from 'src/features/form/layout/LayoutsContext'; import { useCurrentView } from 'src/hooks/useNavigatePage'; import { parseAndCleanText } from 'src/language/sharedLanguage'; -import { useNodes } from 'src/utils/layout/NodesContext'; +import { useNodeTraversal } from 'src/utils/layout/useNodeTraversal'; import type { LayoutContextValue } from 'src/features/form/layout/LayoutsContext'; export const LayoutInspector = () => { @@ -24,7 +24,6 @@ export const LayoutInspector = () => { const [componentProperties, setComponentProperties] = useState(null); const [propertiesHaveChanged, setPropertiesHaveChanged] = useState(false); const [error, setError] = useState(false); - const nodes = useNodes(); const focusNodeInspector = useDevToolsStore((state) => state.actions.focusNodeInspector); const textAreaRef = useRef(null); @@ -38,7 +37,7 @@ export const LayoutInspector = () => { }, [componentProperties]); const currentLayout = currentView ? layouts?.[currentView] : undefined; - const matchingNodes = selectedComponent ? nodes?.findAllById(selectedComponent) || [] : []; + const matchingNodes = useNodeTraversal((t) => t.findAllById(selectedComponent)); const validationErrorsForPage = useLayoutValidationForPage() || {}; useEffect(() => { @@ -143,8 +142,8 @@ export const LayoutInspector = () => { {matchingNodes.length === 0 && 'Ingen aktive komponenter funnet'} {matchingNodes.map((node) => ( ))}
diff --git a/src/features/devtools/components/LayoutInspector/LayoutInspectorItem.tsx b/src/features/devtools/components/LayoutInspector/LayoutInspectorItem.tsx index ee8c483ac4..d7acf83742 100644 --- a/src/features/devtools/components/LayoutInspector/LayoutInspectorItem.tsx +++ b/src/features/devtools/components/LayoutInspector/LayoutInspectorItem.tsx @@ -7,10 +7,10 @@ import cn from 'classnames'; import classes from 'src/features/devtools/components/LayoutInspector/LayoutInspector.module.css'; import { useComponentHighlighter } from 'src/features/devtools/hooks/useComponentHighlighter'; -import type { CompOrGroupExternal } from 'src/layout/layout'; +import type { CompExternal } from 'src/layout/layout'; interface ILayoutInspectorItemProps { - component: CompOrGroupExternal; + component: CompExternal; hasErrors: boolean; selected: boolean; onClick: () => void; diff --git a/src/features/devtools/components/NodeInspector/DefaultNodeInspector.tsx b/src/features/devtools/components/NodeInspector/DefaultNodeInspector.tsx index 90a402fa9a..3ccd1d7a3e 100644 --- a/src/features/devtools/components/NodeInspector/DefaultNodeInspector.tsx +++ b/src/features/devtools/components/NodeInspector/DefaultNodeInspector.tsx @@ -6,6 +6,8 @@ import classes from 'src/features/devtools/components/NodeInspector/NodeInspecto import { NodeInspectorDataField } from 'src/features/devtools/components/NodeInspector/NodeInspectorDataField'; import { NodeInspectorDataModelBindings } from 'src/features/devtools/components/NodeInspector/NodeInspectorDataModelBindings'; import { NodeInspectorTextResourceBindings } from 'src/features/devtools/components/NodeInspector/NodeInspectorTextResourceBindings'; +import { Hidden, NodesInternal } from 'src/utils/layout/NodesContext'; +import { useNodeItem } from 'src/utils/layout/useNodeItem'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; interface DefaultNodeInspectorParams { @@ -14,18 +16,25 @@ interface DefaultNodeInspectorParams { } export function DefaultNodeInspector({ node, ignoredProperties }: DefaultNodeInspectorParams) { + // Hidden state is removed from the item by the hierarchy generator, but we simulate adding it back here (but only + // if it's an expression). This allows app developers to inspect this as well. + const _item = useNodeItem(node); + const hidden = Hidden.useIsHidden(node); + const hiddenIsExpression = NodesInternal.useNodeData(node, (s) => Array.isArray(s.layout.hidden)); + const item = hiddenIsExpression ? { ..._item, hidden } : _item; + const ignoredPropertiesFinal = new Set( ['id', 'type', 'multiPageIndex', 'baseComponentId'].concat(ignoredProperties ?? []), ); return (
- {Object.keys(node.item).map((key) => { + {Object.keys(item).map((key) => { if (ignoredPropertiesFinal.has(key)) { return null; } - const value = node.item[key]; + const value = item[key]; if (key === 'dataModelBindings' && typeof value === 'object' && Object.keys(value).length > 0) { return (