@svgrid/enterprise 2.4.0 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cdn/svgrid-enterprise.svelte-external.js +5990 -5612
- package/dist/designer/assets/{index-okIcRP9P.js → index-OwdzSsmo.js} +3 -3
- package/dist/designer/assets/{src-BvsJ00AO.css → src-D-O2F805.css} +1 -1
- package/dist/designer/assets/{src-CIla2aVG.js → src-LRVqg7Sf.js} +259 -228
- package/dist/designer/index.html +3 -3
- package/dist/node/studio.js +681 -84
- package/package.json +3 -3
- package/src/SvGridEditPanel.dom.test.ts +174 -0
- package/src/SvGridEditPanel.svelte +209 -46
- package/src/edit-panel.test.ts +145 -36
- package/src/edit-panel.ts +167 -16
- package/src/expressions/evaluate.ts +1 -1
- package/src/expressions/parse.ts +3 -3
- package/src/index.ts +14 -0
- package/src/license-core.ts +77 -5
- package/src/license-expiry.test.ts +91 -0
- package/src/license.ts +31 -2
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/_svelte_metadata.json +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/_metadata.json +178 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-ZrPsKxob.js +3875 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-ZrPsKxob.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-ACqvWGE2.js +21 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-ACqvWGE2.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-D3w5jt92.js +1938 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-D3w5jt92.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-CC2V3H0y.js +205 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-CC2V3H0y.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-BXP37tAa.js +18 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-BXP37tAa.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-DwubnU5b.js +264 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-DwubnU5b.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-hD7f6PUz.js +697 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-hD7f6PUz.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/package.json +3 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js +134 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js +55 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_attachments.js +2 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js +46843 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js +237 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_events.js +2 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js +4 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_client.js +7 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js +4 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js +5 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js +5 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js +5 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_server.js +6 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js +83 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js +559 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js +24 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js +122 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_server.js +2 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js +89 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js +241 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DBx1SCN6.js +4938 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DBx1SCN6.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-gjfpSTFS.js +495 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-gjfpSTFS.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-DKoOKg8S.js +190 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-DKoOKg8S.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-C0MiR7eR.js +8 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-C0MiR7eR.js.map +1 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-B6Y7P0Q-.js +138 -0
- package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-B6Y7P0Q-.js.map +1 -0
- package/src/schema.ts +63 -0
- package/src/studio/emit-project.test.ts +191 -0
- package/src/studio/emit-project.ts +237 -27
- package/src/studio/emit-schema.ts +4 -1
- package/src/studio/index.ts +2 -0
- package/src/studio/init-flow.test.ts +150 -0
- package/src/studio/init-flow.ts +169 -22
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/_svelte_metadata.json +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/_metadata.json +178 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-BOv1rBKp.js +3875 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-BOv1rBKp.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-B9vgIdCd.js +21 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-B9vgIdCd.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-T1qbW_qD.js +1938 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-T1qbW_qD.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-Bo11SfVK.js +205 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-Bo11SfVK.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-X7D4vfrZ.js +18 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-X7D4vfrZ.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-oyUje5i1.js +264 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-oyUje5i1.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-DeSQgSFm.js +697 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-DeSQgSFm.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/package.json +3 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js +134 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js +55 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_attachments.js +2 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js +46843 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js +237 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_events.js +2 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js +4 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_client.js +7 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js +4 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js +5 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js +5 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js +5 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_server.js +6 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js +83 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js +559 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js +24 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js +122 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_server.js +2 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js +89 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js +241 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-BbWyXNk1.js +4938 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-BbWyXNk1.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DI_fC7vt.js +495 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DI_fC7vt.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-Dyr3lQv9.js +190 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-Dyr3lQv9.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-_9FHH-LF.js +8 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-_9FHH-LF.js.map +1 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-CUbSPnyP.js +138 -0
- package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-CUbSPnyP.js.map +1 -0
- package/src/studio/project.test.ts +30 -0
- package/src/studio/project.ts +72 -8
- package/src/studio/samples/crm.ts +11 -2
- package/src/studio/samples/insurance.ts +10 -4
- package/src/studio/samples/samples.test.ts +36 -0
- package/src/studio/samples/shared.ts +14 -0
- package/src/studio/samples/support.ts +12 -5
- package/src/studio/screen-suites.test.ts +43 -0
- package/src/studio/screen-suites.ts +5 -2
- package/src/studio/user-error.ts +31 -0
- package/src/upgrade-prompt.ts +23 -10
- package/src/version.test.ts +17 -0
- package/src/version.ts +13 -0
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"type": "commercial",
|
|
5
5
|
"url": "https://svgrid.com/pricing"
|
|
6
6
|
},
|
|
7
|
-
"version": "2.
|
|
7
|
+
"version": "2.5.1",
|
|
8
8
|
"description": "Enterprise feature pack for SvGrid: Excel (xlsx), PDF, CSV, TSV and HTML export, Excel/CSV/JSON import, pivot tables with a drag-and-drop designer, paginated print, Kanban board and scheduler views. Requires a paid license key.",
|
|
9
9
|
"license": "SEE LICENSE IN LICENSE",
|
|
10
10
|
"author": "jQWidgets <sales@jqwidgets.com>",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"jszip": "^3.10.0",
|
|
66
66
|
"pdfmake": "^0.2.0",
|
|
67
67
|
"svelte": "^5.0.0",
|
|
68
|
-
"@svgrid/grid": "^2.
|
|
68
|
+
"@svgrid/grid": "^2.5.0"
|
|
69
69
|
},
|
|
70
70
|
"peerDependenciesMeta": {
|
|
71
71
|
"jszip": {
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"svelte": "^5.55.5",
|
|
83
83
|
"typescript": "6.0.3",
|
|
84
84
|
"vite": "^8.2.1",
|
|
85
|
-
"@svgrid/grid": "^2.
|
|
85
|
+
"@svgrid/grid": "^2.5.0"
|
|
86
86
|
},
|
|
87
87
|
"repository": {
|
|
88
88
|
"type": "git",
|
|
@@ -33,6 +33,11 @@ afterEach(() => {
|
|
|
33
33
|
})
|
|
34
34
|
|
|
35
35
|
function render(props: Record<string, unknown>): HTMLElement {
|
|
36
|
+
// Tear down a previous mount first: a test that renders more than once used to
|
|
37
|
+
// leak every panel but the last, leaving live components (and duplicate field
|
|
38
|
+
// ids) in the document for whatever ran next.
|
|
39
|
+
if (comp) { unmount(comp); comp = null }
|
|
40
|
+
if (host) { host.remove(); host = null }
|
|
36
41
|
host = document.createElement('div')
|
|
37
42
|
document.body.appendChild(host)
|
|
38
43
|
comp = mount(SvGridEditPanel, { target: host, props })
|
|
@@ -143,4 +148,173 @@ describe('SvGridEditPanel (DOM)', () => {
|
|
|
143
148
|
expect(payload.values).toMatchObject({ name: 'Ada', mrr: 1200, active: true, tier: 'pro' })
|
|
144
149
|
expect(payload.values).not.toHaveProperty('id') // readonly PK is never submitted
|
|
145
150
|
})
|
|
151
|
+
|
|
152
|
+
it('lays the form out from the schema, so a built form travels with the entity', () => {
|
|
153
|
+
const laidOut: EntitySchema = {
|
|
154
|
+
name: 'people',
|
|
155
|
+
idField: 'id',
|
|
156
|
+
fields: [
|
|
157
|
+
{ field: 'id', type: 'text', primaryKey: true, readonly: true },
|
|
158
|
+
{ field: 'name', type: 'text' },
|
|
159
|
+
{ field: 'email', type: 'text' },
|
|
160
|
+
{ field: 'street', type: 'text' },
|
|
161
|
+
{ field: 'city', type: 'text' },
|
|
162
|
+
],
|
|
163
|
+
form: {
|
|
164
|
+
columns: 2,
|
|
165
|
+
sections: [
|
|
166
|
+
{ title: 'Who', description: 'How we reach them.', fields: ['name', 'email'] },
|
|
167
|
+
{ title: 'Where', columns: 1, fields: ['street', 'city'] },
|
|
168
|
+
],
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
const el = render({ schema: laidOut, presentation: 'inline', onSubmit: vi.fn() })
|
|
172
|
+
|
|
173
|
+
const titles = [...el.querySelectorAll('.sv-ep__section-title')].map((n) => n.textContent)
|
|
174
|
+
expect(titles).toEqual(['Who', 'Where'])
|
|
175
|
+
expect(el.querySelector('.sv-ep__section-desc')?.textContent).toBe('How we reach them.')
|
|
176
|
+
|
|
177
|
+
// Per-section column count wins over the form's.
|
|
178
|
+
const bodies = [...el.querySelectorAll<HTMLElement>('.sv-ep__body')]
|
|
179
|
+
expect(bodies[0]!.style.getPropertyValue('--sv-ep-cols')).toBe('2')
|
|
180
|
+
expect(bodies[1]!.style.getPropertyValue('--sv-ep-cols')).toBe('1')
|
|
181
|
+
|
|
182
|
+
// The unassigned id field is not dropped - it lands in a trailing group.
|
|
183
|
+
expect(el.querySelector('#sv-ef-id')).toBeTruthy()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('waits until you leave a field before complaining, then clears as you fix it', async () => {
|
|
187
|
+
const required: EntitySchema = {
|
|
188
|
+
name: 'people',
|
|
189
|
+
idField: 'id',
|
|
190
|
+
fields: [
|
|
191
|
+
{ field: 'id', type: 'text', primaryKey: true, readonly: true },
|
|
192
|
+
{ field: 'email', type: 'text', required: true, format: 'email' },
|
|
193
|
+
],
|
|
194
|
+
}
|
|
195
|
+
const el = render({ schema: required, presentation: 'inline', onSubmit: vi.fn() })
|
|
196
|
+
const input = el.querySelector<HTMLInputElement>('#sv-ef-email')!
|
|
197
|
+
|
|
198
|
+
// Typing something invalid says nothing yet - the user is mid-thought.
|
|
199
|
+
input.value = 'nope'
|
|
200
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
201
|
+
flushSync()
|
|
202
|
+
expect(el.querySelector('.sv-ep-field__err')).toBeNull()
|
|
203
|
+
expect(input.getAttribute('aria-invalid')).toBe('false')
|
|
204
|
+
|
|
205
|
+
// Leaving it is the moment to speak up.
|
|
206
|
+
input.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
|
|
207
|
+
await vi.waitFor(() => expect(el.querySelector('.sv-ep-field__err')?.textContent).toMatch(/valid email/))
|
|
208
|
+
expect(input.getAttribute('aria-invalid')).toBe('true')
|
|
209
|
+
// The message is wired to the input for screen readers.
|
|
210
|
+
expect(input.getAttribute('aria-describedby')).toBe('sv-ee-email')
|
|
211
|
+
expect(el.querySelector('#sv-ee-email')).toBeTruthy()
|
|
212
|
+
|
|
213
|
+
// Correcting it clears the message without another submit.
|
|
214
|
+
input.value = 'ada@example.com'
|
|
215
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
216
|
+
await vi.waitFor(() => expect(el.querySelector('.sv-ep-field__err')).toBeNull())
|
|
217
|
+
expect(input.getAttribute('aria-invalid')).toBe('false')
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('summarises a failed submit and focuses the first field that needs fixing', async () => {
|
|
221
|
+
const required: EntitySchema = {
|
|
222
|
+
name: 'people',
|
|
223
|
+
idField: 'id',
|
|
224
|
+
fields: [
|
|
225
|
+
{ field: 'id', type: 'text', primaryKey: true, readonly: true },
|
|
226
|
+
{ field: 'name', type: 'text', required: true },
|
|
227
|
+
{ field: 'email', type: 'text', required: true },
|
|
228
|
+
],
|
|
229
|
+
}
|
|
230
|
+
const onSubmit = vi.fn()
|
|
231
|
+
const el = render({ schema: required, presentation: 'inline', onSubmit })
|
|
232
|
+
|
|
233
|
+
el.querySelector<HTMLButtonElement>('button[type="submit"]')!.click()
|
|
234
|
+
|
|
235
|
+
await vi.waitFor(() => expect(el.querySelector('.sv-ep__summary')).toBeTruthy())
|
|
236
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
237
|
+
const summary = el.querySelector('.sv-ep__summary')!
|
|
238
|
+
expect(summary.getAttribute('role')).toBe('alert')
|
|
239
|
+
expect(summary.textContent).toContain('Name')
|
|
240
|
+
expect(summary.textContent).toContain('Email')
|
|
241
|
+
// Focus lands on the first problem, so fixing can start immediately.
|
|
242
|
+
expect(document.activeElement?.id).toBe('sv-ef-name')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('asks before throwing away edits, and closes straight away when there are none', () => {
|
|
246
|
+
const onCancel = vi.fn()
|
|
247
|
+
const el = render({
|
|
248
|
+
schema,
|
|
249
|
+
row: { id: '1', name: 'Ada', mrr: 1200, active: true, tier: 'pro' },
|
|
250
|
+
presentation: 'inline',
|
|
251
|
+
onSubmit: vi.fn(),
|
|
252
|
+
onCancel,
|
|
253
|
+
})
|
|
254
|
+
const cancel = () => [...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.trim() === 'Cancel')!
|
|
255
|
+
|
|
256
|
+
// Untouched: cancelling just closes.
|
|
257
|
+
cancel().click()
|
|
258
|
+
flushSync()
|
|
259
|
+
expect(onCancel).toHaveBeenCalledTimes(1)
|
|
260
|
+
|
|
261
|
+
// Now dirty the form; cancelling asks instead of discarding.
|
|
262
|
+
const name = el.querySelector<HTMLInputElement>('#sv-ef-name')!
|
|
263
|
+
name.value = 'Grace'
|
|
264
|
+
name.dispatchEvent(new Event('input', { bubbles: true }))
|
|
265
|
+
flushSync()
|
|
266
|
+
cancel().click()
|
|
267
|
+
flushSync()
|
|
268
|
+
expect(onCancel).toHaveBeenCalledTimes(1) // still not closed
|
|
269
|
+
expect(el.textContent).toContain('Discard your changes?')
|
|
270
|
+
|
|
271
|
+
// "Keep editing" puts you back in the form with the edit intact.
|
|
272
|
+
;[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.trim() === 'Keep editing')!.click()
|
|
273
|
+
flushSync()
|
|
274
|
+
expect(el.textContent).not.toContain('Discard your changes?')
|
|
275
|
+
expect(el.querySelector<HTMLInputElement>('#sv-ef-name')!.value).toBe('Grace')
|
|
276
|
+
|
|
277
|
+
// Confirming discards.
|
|
278
|
+
cancel().click()
|
|
279
|
+
flushSync()
|
|
280
|
+
;[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.trim() === 'Discard')!.click()
|
|
281
|
+
flushSync()
|
|
282
|
+
expect(onCancel).toHaveBeenCalledTimes(2)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('reveals and locks fields live as the answers change', async () => {
|
|
286
|
+
// One answer drives two others: choosing "refund" asks for a justification
|
|
287
|
+
// and unlocks the approver, both without a remount.
|
|
288
|
+
const cmp = (column: string, op: string, value: unknown) => ({ kind: 'cmp', column, op, value })
|
|
289
|
+
const returns = {
|
|
290
|
+
name: 'returns',
|
|
291
|
+
idField: 'id',
|
|
292
|
+
fields: [
|
|
293
|
+
{ field: 'id', type: 'text', primaryKey: true, readonly: true },
|
|
294
|
+
{ field: 'mode', type: 'text' },
|
|
295
|
+
{ field: 'justification', type: 'text', label: 'Justification', when: { visible: cmp('mode', 'equals', 'refund') } },
|
|
296
|
+
{ field: 'approver', type: 'text', when: { disabled: cmp('mode', 'notEquals', 'refund') } },
|
|
297
|
+
],
|
|
298
|
+
} as unknown as EntitySchema<Record<string, unknown>>
|
|
299
|
+
|
|
300
|
+
const el = render({
|
|
301
|
+
schema: returns,
|
|
302
|
+
row: { id: '1', mode: 'exchange', justification: '', approver: '' },
|
|
303
|
+
presentation: 'inline',
|
|
304
|
+
onSubmit: vi.fn(),
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
// An exchange: no justification asked for, and the approver is locked.
|
|
308
|
+
expect(el.textContent).not.toContain('Justification')
|
|
309
|
+
expect(el.querySelector('#sv-ef-approver')!.hasAttribute('disabled')).toBe(true)
|
|
310
|
+
|
|
311
|
+
// Switch to a refund; both fields react to the new value.
|
|
312
|
+
const mode = el.querySelector<HTMLInputElement>('#sv-ef-mode')!
|
|
313
|
+
mode.value = 'refund'
|
|
314
|
+
mode.dispatchEvent(new Event('input', { bubbles: true }))
|
|
315
|
+
flushSync()
|
|
316
|
+
|
|
317
|
+
expect(el.textContent).toContain('Justification')
|
|
318
|
+
expect(el.querySelector('#sv-ef-approver')!.hasAttribute('disabled')).toBe(false)
|
|
319
|
+
})
|
|
146
320
|
})
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
import { fade, fly } from 'svelte/transition'
|
|
26
26
|
import { cubicOut } from 'svelte/easing'
|
|
27
27
|
import { SvGridDropdown, SvNumberInput, SvColorInput, SvPasswordInput, SvSlider, SvSwitchButton, SvPhoneInput, SvCountryInput, SvMaskedInput, SvDateTimePicker, SvTagsInput, type CellEditorOption } from '@svgrid/grid'
|
|
28
|
-
import { schemaToFormFields, type EntitySchema, type FormFieldDescriptor } from './schema'
|
|
28
|
+
import { schemaToFormFields, type EntitySchema, type FormFieldDescriptor, type FormSection } from './schema'
|
|
29
29
|
import SvFileInput from './SvFileInput.svelte'
|
|
30
30
|
import {
|
|
31
31
|
buildInitialValues,
|
|
@@ -40,7 +40,11 @@
|
|
|
40
40
|
fromNumberValue,
|
|
41
41
|
toSliderValue,
|
|
42
42
|
toTags,
|
|
43
|
+
fieldState,
|
|
44
|
+
sectionVisible,
|
|
45
|
+
validateOne,
|
|
43
46
|
type EditMode,
|
|
47
|
+
type FieldState,
|
|
44
48
|
} from './edit-panel'
|
|
45
49
|
import SvLookupInput from './SvLookupInput.svelte'
|
|
46
50
|
import type { RelationLookup } from './sources/relation-lookup'
|
|
@@ -76,13 +80,15 @@
|
|
|
76
80
|
* localStorage under this key, so it reopens where the user left it.
|
|
77
81
|
*/
|
|
78
82
|
persistKey?: string
|
|
79
|
-
/** Form column count
|
|
83
|
+
/** Form column count. Defaults to the schema's `form.columns`, else 2. A field
|
|
84
|
+
* with `span: 2` spans all columns. */
|
|
80
85
|
columns?: 1 | 2 | 3
|
|
81
86
|
/** Restrict + order the form fields (by field name). Default: all schema fields. */
|
|
82
87
|
formFields?: string[]
|
|
83
|
-
/** Group fields into titled fieldsets
|
|
84
|
-
* trailing default group. When set,
|
|
85
|
-
|
|
88
|
+
/** Group fields into titled fieldsets, overriding the schema's `form.sections`.
|
|
89
|
+
* Fields not in any section render in a trailing default group. When set,
|
|
90
|
+
* `formFields` ordering is per-section. */
|
|
91
|
+
sections?: FormSection[]
|
|
86
92
|
/** Dialog width for the modal / drawer presentations. Default 'md'. */
|
|
87
93
|
formSize?: 'sm' | 'md' | 'lg'
|
|
88
94
|
onSubmit: (payload: SubmitPayload) => void | Promise<void>
|
|
@@ -100,7 +106,7 @@
|
|
|
100
106
|
uploads,
|
|
101
107
|
dependentOptions,
|
|
102
108
|
persistKey,
|
|
103
|
-
columns
|
|
109
|
+
columns,
|
|
104
110
|
formFields,
|
|
105
111
|
sections,
|
|
106
112
|
formSize = 'md',
|
|
@@ -108,25 +114,51 @@
|
|
|
108
114
|
onCancel,
|
|
109
115
|
}: Props = $props()
|
|
110
116
|
|
|
117
|
+
let values = $state<Record<string, any>>({})
|
|
118
|
+
let errors = $state<Record<string, string>>({})
|
|
119
|
+
let submitting = $state(false)
|
|
120
|
+
let submitError = $state<string | null>(null)
|
|
121
|
+
// Fields the user has finished with. A field is only allowed to show an error
|
|
122
|
+
// once they have left it, so a form does not scold you for what you have not
|
|
123
|
+
// typed yet; after a failed submit everything counts as visited.
|
|
124
|
+
let touched = $state<Record<string, boolean>>({})
|
|
125
|
+
let submitAttempted = $state(false)
|
|
126
|
+
|
|
111
127
|
const allFields = $derived(schemaToFormFields(schema))
|
|
112
128
|
// Apply the include/order list (formFields), else keep the schema order.
|
|
113
|
-
const
|
|
129
|
+
const listedFields = $derived(
|
|
114
130
|
formFields && formFields.length
|
|
115
131
|
? (formFields.map((name) => allFields.find((f) => f.field === name)).filter(Boolean) as FormFieldDescriptor[])
|
|
116
132
|
: allFields,
|
|
117
133
|
)
|
|
134
|
+
// Live state per field: a `when` condition re-evaluates against the current
|
|
135
|
+
// values, so answering one question can reveal, lock, or demand another.
|
|
136
|
+
const fieldStates = $derived(new Map(allFields.map((f) => [f.field, fieldState(f, values)] as const)))
|
|
137
|
+
const stateOf = (f: FormFieldDescriptor): FieldState =>
|
|
138
|
+
fieldStates.get(f.field) ?? { visible: true, disabled: f.readonly, required: f.required }
|
|
139
|
+
const fields = $derived(listedFields.filter((f) => stateOf(f).visible))
|
|
140
|
+
// The arrangement comes from the schema (so a built form travels with the
|
|
141
|
+
// entity); the props override it for a one-off layout of a shared schema.
|
|
142
|
+
const layoutColumns = $derived(columns ?? schema.form?.columns ?? 2)
|
|
143
|
+
const layoutSections = $derived(sections ?? schema.form?.sections)
|
|
144
|
+
|
|
118
145
|
// Grouped layout: each section's resolvable fields, plus a trailing group for
|
|
119
146
|
// anything not assigned to a section (so no field is ever silently dropped).
|
|
120
|
-
const fieldGroups = $derived.by((): Array<{ title?: string; fields: FormFieldDescriptor[] }> => {
|
|
121
|
-
if (!
|
|
147
|
+
const fieldGroups = $derived.by((): Array<{ title?: string; description?: string; columns?: 1 | 2 | 3; fields: FormFieldDescriptor[] }> => {
|
|
148
|
+
if (!layoutSections || !layoutSections.length) return [{ fields }]
|
|
122
149
|
const assigned = new Set<string>()
|
|
123
|
-
const groups =
|
|
150
|
+
const groups = layoutSections.map((s) => {
|
|
124
151
|
const gf = s.fields.map((name) => fields.find((f) => f.field === name)).filter(Boolean) as FormFieldDescriptor[]
|
|
125
152
|
for (const f of gf) assigned.add(f.field)
|
|
126
|
-
|
|
153
|
+
// A section can be conditional in its own right, the same way a field is.
|
|
154
|
+
const shown = s.visibleWhen ? sectionVisible(s.visibleWhen, values) : true
|
|
155
|
+
return { title: s.title, description: s.description, columns: s.columns, fields: shown ? gf : [] }
|
|
127
156
|
})
|
|
128
157
|
const rest = fields.filter((f) => !assigned.has(f.field))
|
|
129
|
-
|
|
158
|
+
// A section whose fields are all hidden by a condition disappears with them,
|
|
159
|
+
// rather than leaving a heading over nothing.
|
|
160
|
+
const shown = groups.filter((g) => g.fields.length)
|
|
161
|
+
return rest.length ? [...shown, { fields: rest }] : shown
|
|
130
162
|
})
|
|
131
163
|
|
|
132
164
|
// Options for the custom dropdown: prepend a blank "clear" option for
|
|
@@ -139,15 +171,21 @@
|
|
|
139
171
|
)
|
|
140
172
|
const overlayed = $derived(presentation !== 'inline')
|
|
141
173
|
|
|
142
|
-
|
|
143
|
-
let
|
|
144
|
-
let submitting = $state(false)
|
|
145
|
-
let submitError = $state<string | null>(null)
|
|
174
|
+
// The values the form opened with, to tell an edited form from an untouched one.
|
|
175
|
+
let initial = $state<Record<string, any>>({})
|
|
146
176
|
|
|
147
177
|
$effect(() => {
|
|
148
|
-
|
|
178
|
+
// Seed from a local, and copy from that local rather than from `values`:
|
|
179
|
+
// reading the state this effect also writes makes the effect its own
|
|
180
|
+
// dependency, which spins forever.
|
|
181
|
+
const seeded = buildInitialValues(schema, row)
|
|
182
|
+
values = seeded
|
|
183
|
+
initial = { ...seeded }
|
|
149
184
|
errors = {}
|
|
185
|
+
touched = {}
|
|
186
|
+
submitAttempted = false
|
|
150
187
|
submitError = null
|
|
188
|
+
confirmingDiscard = false
|
|
151
189
|
})
|
|
152
190
|
|
|
153
191
|
// Cascade: when a parent field changes, clear a dependent field whose value is
|
|
@@ -171,12 +209,50 @@
|
|
|
171
209
|
}
|
|
172
210
|
})
|
|
173
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Re-check one field as the user leaves it. Once a field has an error it keeps
|
|
214
|
+
* being checked on every keystroke, so a correction clears the message
|
|
215
|
+
* immediately instead of making them submit again to find out.
|
|
216
|
+
*/
|
|
217
|
+
async function checkField(field: string) {
|
|
218
|
+
touched[field] = true
|
|
219
|
+
const message = await validateOne(schema, field, values)
|
|
220
|
+
if (message) errors[field] = message
|
|
221
|
+
else delete errors[field]
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Live re-validation, but only for fields already showing an error.
|
|
225
|
+
$effect(() => {
|
|
226
|
+
void values
|
|
227
|
+
for (const field of Object.keys(errors)) void checkField(field)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
/** Errors the form is allowed to show: visited fields, or everything after a submit. */
|
|
231
|
+
const shownErrors = $derived(
|
|
232
|
+
Object.fromEntries(Object.entries(errors).filter(([field]) => submitAttempted || touched[field])),
|
|
233
|
+
)
|
|
234
|
+
const errorList = $derived(
|
|
235
|
+
fields.filter((f) => shownErrors[f.field]).map((f) => ({ field: f.field, label: f.label, message: shownErrors[f.field]! })),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
/** Send focus to a field from the error summary. */
|
|
239
|
+
function focusField(field: string) {
|
|
240
|
+
const el = document.getElementById(`sv-ef-${field}`)
|
|
241
|
+
if (el instanceof HTMLElement) el.focus()
|
|
242
|
+
}
|
|
243
|
+
|
|
174
244
|
async function handleSubmit(event: SubmitEvent) {
|
|
175
245
|
event.preventDefault()
|
|
176
246
|
submitError = null
|
|
247
|
+
submitAttempted = true
|
|
177
248
|
const found = await validateAll(schema, values)
|
|
178
249
|
errors = found
|
|
179
|
-
if (Object.keys(found).length > 0)
|
|
250
|
+
if (Object.keys(found).length > 0) {
|
|
251
|
+
// Put the user on the first thing they need to fix.
|
|
252
|
+
const first = fields.find((f) => found[f.field])
|
|
253
|
+
if (first) focusField(first.field)
|
|
254
|
+
return
|
|
255
|
+
}
|
|
180
256
|
submitting = true
|
|
181
257
|
try {
|
|
182
258
|
await onSubmit({
|
|
@@ -191,11 +267,28 @@
|
|
|
191
267
|
}
|
|
192
268
|
}
|
|
193
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Guard against throwing away edits. Closing a dirty form asks first, in the
|
|
272
|
+
* footer rather than through a `confirm()` dialog, so the answer appears where
|
|
273
|
+
* the user is already looking and the form stays on screen behind it.
|
|
274
|
+
*/
|
|
275
|
+
let confirmingDiscard = $state(false)
|
|
276
|
+
const isDirty = $derived(
|
|
277
|
+
!submitting && fields.some((f) => !f.readonly && String(values[f.field] ?? '') !== String(initial[f.field] ?? '')),
|
|
278
|
+
)
|
|
279
|
+
|
|
194
280
|
function close() {
|
|
281
|
+
if (isDirty && !confirmingDiscard) {
|
|
282
|
+
confirmingDiscard = true
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
confirmingDiscard = false
|
|
195
286
|
onCancel?.()
|
|
196
287
|
}
|
|
197
288
|
function onKeydown(e: KeyboardEvent) {
|
|
198
|
-
if (e.key
|
|
289
|
+
if (e.key !== 'Escape' || !onCancel) return
|
|
290
|
+
// A second Escape confirms, so the keyboard path is not a dead end.
|
|
291
|
+
close()
|
|
199
292
|
}
|
|
200
293
|
function stop(e: Event) {
|
|
201
294
|
e.stopPropagation()
|
|
@@ -376,17 +469,31 @@
|
|
|
376
469
|
</div>
|
|
377
470
|
</header>
|
|
378
471
|
|
|
472
|
+
{#if errorList.length > 1}
|
|
473
|
+
<!-- One place to see everything that needs fixing, with a jump to each.
|
|
474
|
+
Only worth showing when more than one field failed. -->
|
|
475
|
+
<div class="sv-ep__summary" role="alert">
|
|
476
|
+
<p class="sv-ep__summary-title">Please fix {errorList.length} fields:</p>
|
|
477
|
+
<ul>
|
|
478
|
+
{#each errorList as e (e.field)}
|
|
479
|
+
<li><button type="button" onclick={() => focusField(e.field)}>{e.label}: {e.message}</button></li>
|
|
480
|
+
{/each}
|
|
481
|
+
</ul>
|
|
482
|
+
</div>
|
|
483
|
+
{/if}
|
|
484
|
+
|
|
379
485
|
{#if fieldGroups.length > 1 || fieldGroups[0]?.title}
|
|
380
486
|
{#each fieldGroups as g, gi (gi)}
|
|
381
487
|
<div class="sv-ep__section">
|
|
382
488
|
{#if g.title}<h4 class="sv-ep__section-title">{g.title}</h4>{/if}
|
|
383
|
-
<
|
|
489
|
+
{#if g.description}<p class="sv-ep__section-desc">{g.description}</p>{/if}
|
|
490
|
+
<div class="sv-ep__body" style="--sv-ep-cols: {g.columns ?? layoutColumns}">
|
|
384
491
|
{#each g.fields as f (f.field)}{@render fieldRow(f)}{/each}
|
|
385
492
|
</div>
|
|
386
493
|
</div>
|
|
387
494
|
{/each}
|
|
388
495
|
{:else}
|
|
389
|
-
<div class="sv-ep__body" style="--sv-ep-cols: {
|
|
496
|
+
<div class="sv-ep__body" style="--sv-ep-cols: {layoutColumns}">
|
|
390
497
|
{#each fields as f (f.field)}{@render fieldRow(f)}{/each}
|
|
391
498
|
</div>
|
|
392
499
|
{/if}
|
|
@@ -394,21 +501,38 @@
|
|
|
394
501
|
{#if submitError}<p class="sv-ep__submit-err" role="alert">{submitError}</p>{/if}
|
|
395
502
|
|
|
396
503
|
<footer class="sv-ep__footer">
|
|
397
|
-
{#if
|
|
398
|
-
<
|
|
504
|
+
{#if confirmingDiscard}
|
|
505
|
+
<span class="sv-ep__discard" role="alert">Discard your changes?</span>
|
|
506
|
+
<button type="button" class="sv-ep__btn" onclick={() => (confirmingDiscard = false)}>Keep editing</button>
|
|
507
|
+
<button type="button" class="sv-ep__btn sv-ep__btn--danger" onclick={close}>Discard</button>
|
|
508
|
+
{:else}
|
|
509
|
+
{#if onCancel}
|
|
510
|
+
<button type="button" class="sv-ep__btn" onclick={close} disabled={submitting}>Cancel</button>
|
|
511
|
+
{/if}
|
|
512
|
+
<button type="submit" class="sv-ep__btn sv-ep__btn--primary" disabled={submitting}>
|
|
513
|
+
{submitting ? 'Saving…' : (submitLabel ?? (mode === 'create' ? 'Create' : 'Save'))}
|
|
514
|
+
</button>
|
|
399
515
|
{/if}
|
|
400
|
-
<button type="submit" class="sv-ep__btn sv-ep__btn--primary" disabled={submitting}>
|
|
401
|
-
{submitting ? 'Saving…' : (submitLabel ?? (mode === 'create' ? 'Create' : 'Save'))}
|
|
402
|
-
</button>
|
|
403
516
|
</footer>
|
|
404
517
|
</form>
|
|
405
518
|
{/snippet}
|
|
406
519
|
|
|
407
|
-
{#snippet fieldRow(
|
|
520
|
+
{#snippet fieldRow(spec: FormFieldDescriptor)}
|
|
521
|
+
{@const st = stateOf(spec)}
|
|
522
|
+
<!-- Every control below already reads `readonly` / `required` off the
|
|
523
|
+
descriptor, so folding the live condition state into a shadow copy
|
|
524
|
+
makes all of them honour it without touching each branch. -->
|
|
525
|
+
{@const f = st.disabled === spec.readonly && st.required === spec.required
|
|
526
|
+
? spec
|
|
527
|
+
: { ...spec, readonly: st.disabled, required: st.required }}
|
|
408
528
|
{@const kind = controlKind(f)}
|
|
409
529
|
{@const lk = f.relation ? lookups?.[f.field] : undefined}
|
|
410
530
|
{@const depOptions = dependentOptions?.[f.field]}
|
|
411
|
-
|
|
531
|
+
{@const err = shownErrors[f.field]}
|
|
532
|
+
<!-- Point screen readers at whichever of help / error is on screen. -->
|
|
533
|
+
{@const describedBy = err ? `sv-ee-${f.field}` : f.help ? `sv-eh-${f.field}` : undefined}
|
|
534
|
+
<div class="sv-ep-field" class:sv-ep-field--wide={f.span === 2} class:sv-ep-field--error={!!err}
|
|
535
|
+
onfocusoutcapture={() => { if (!f.readonly) void checkField(f.field) }}>
|
|
412
536
|
<label for={`sv-ef-${f.field}`}>
|
|
413
537
|
{f.label}{#if f.required && !f.readonly}<span class="sv-ep-field__req" aria-hidden="true"> *</span>{/if}
|
|
414
538
|
</label>
|
|
@@ -447,31 +571,31 @@
|
|
|
447
571
|
<!-- Boolean fields render as the suite's switch (nicer than a raw checkbox). -->
|
|
448
572
|
<SvSwitchButton id={`sv-ef-${f.field}`} ariaLabel={f.label} checked={!!values[f.field]} disabled={f.readonly} onChange={(v) => (values[f.field] = v)} />
|
|
449
573
|
{:else if f.editorType === 'number'}
|
|
450
|
-
<SvNumberInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={toNumberValue(values[f.field])} min={f.min} max={f.max} step={f.step} precision={f.precision} prefix={f.prefix} suffix={f.suffix} disabled={f.readonly} invalid={!!
|
|
574
|
+
<SvNumberInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={toNumberValue(values[f.field])} min={f.min} max={f.max} step={f.step} precision={f.precision} prefix={f.prefix} suffix={f.suffix} disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder} onChange={(v) => (values[f.field] = fromNumberValue(v))} />
|
|
451
575
|
{:else if f.editorType === 'color'}
|
|
452
|
-
<SvColorInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] || '#3b82f6'} disabled={f.readonly} invalid={!!
|
|
576
|
+
<SvColorInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] || '#3b82f6'} disabled={f.readonly} invalid={!!err} onChange={(v) => (values[f.field] = v)} />
|
|
453
577
|
{:else if f.editorType === 'password'}
|
|
454
|
-
<SvPasswordInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? ''} disabled={f.readonly} invalid={!!
|
|
578
|
+
<SvPasswordInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? ''} disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder} onChange={(v) => (values[f.field] = v)} />
|
|
455
579
|
{:else if f.editorType === 'rating' || f.editorType === 'slider'}
|
|
456
580
|
{@const rmin = f.min ?? 0}
|
|
457
581
|
{@const rmax = f.max ?? (f.editorType === 'rating' ? 5 : 100)}
|
|
458
582
|
<SvSlider id={`sv-ef-${f.field}`} ariaLabel={f.label} value={toSliderValue(values[f.field], rmin)} min={rmin} max={rmax} step={1} ticks={f.editorType === 'rating' ? rmax - rmin + 1 : undefined} disabled={f.readonly} onChange={(v) => (values[f.field] = v)} />
|
|
459
583
|
{:else if f.editorType === 'phone'}
|
|
460
|
-
<SvPhoneInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? ''} disabled={f.readonly} invalid={!!
|
|
584
|
+
<SvPhoneInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? ''} disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder} onChange={(v) => (values[f.field] = v)} />
|
|
461
585
|
{:else if f.editorType === 'country'}
|
|
462
|
-
<SvCountryInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? null} disabled={f.readonly} invalid={!!
|
|
586
|
+
<SvCountryInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? null} disabled={f.readonly} invalid={!!err} placeholder={f.placeholder} onChange={(v) => (values[f.field] = v)} />
|
|
463
587
|
{:else if f.editorType === 'mask'}
|
|
464
|
-
<SvMaskedInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? ''} mask={f.mask ?? ''} disabled={f.readonly} invalid={!!
|
|
588
|
+
<SvMaskedInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? ''} mask={f.mask ?? ''} disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder} onChange={(masked) => (values[f.field] = masked)} />
|
|
465
589
|
{:else if f.editorType === 'date'}
|
|
466
|
-
<SvDateTimePicker id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? null} dropDownDisplayMode="calendar" formatString="yyyy-MM-dd" nullable disabled={f.readonly} invalid={!!
|
|
590
|
+
<SvDateTimePicker id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? null} dropDownDisplayMode="calendar" formatString="yyyy-MM-dd" nullable disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder ?? 'yyyy-mm-dd'} onChange={(d) => (values[f.field] = toDateString(d))} />
|
|
467
591
|
{:else if f.editorType === 'datetime'}
|
|
468
|
-
<SvDateTimePicker id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? null} formatString="yyyy-MM-dd HH:mm" nullable disabled={f.readonly} invalid={!!
|
|
592
|
+
<SvDateTimePicker id={`sv-ef-${f.field}`} ariaLabel={f.label} value={values[f.field] ?? null} formatString="yyyy-MM-dd HH:mm" nullable disabled={f.readonly} invalid={!!err} required={f.required && !f.readonly} placeholder={f.placeholder ?? 'yyyy-mm-dd hh:mm'} onChange={(d) => (values[f.field] = toDateTimeString(d))} />
|
|
469
593
|
{:else if f.editorType === 'chips'}
|
|
470
594
|
<!-- Multi-value entry: stores a string[]. (Previously a chips field fell back to a single-select.) -->
|
|
471
|
-
<SvTagsInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={toTags(values[f.field])} disabled={f.readonly} invalid={!!
|
|
595
|
+
<SvTagsInput id={`sv-ef-${f.field}`} ariaLabel={f.label} value={toTags(values[f.field])} disabled={f.readonly} invalid={!!err} placeholder={f.placeholder ?? 'Add…'} onChange={(tags) => (values[f.field] = tags)} />
|
|
472
596
|
{:else if kind === 'select'}
|
|
473
597
|
{#if f.readonly}
|
|
474
|
-
<input id={`sv-ef-${f.field}`} type="text" value={values[f.field] ?? ''} disabled />
|
|
598
|
+
<input id={`sv-ef-${f.field}`} aria-invalid={!!err} aria-describedby={describedBy} type="text" value={values[f.field] ?? ''} disabled />
|
|
475
599
|
{:else}
|
|
476
600
|
<!-- Custom dropdown: its panel portals to <body> (position:fixed),
|
|
477
601
|
so it floats above the modal and never grows the form body
|
|
@@ -487,21 +611,21 @@
|
|
|
487
611
|
</div>
|
|
488
612
|
{/if}
|
|
489
613
|
{:else if kind === 'textarea'}
|
|
490
|
-
<textarea id={`sv-ef-${f.field}`} bind:value={values[f.field]} disabled={f.readonly} placeholder={f.placeholder}></textarea>
|
|
614
|
+
<textarea id={`sv-ef-${f.field}`} aria-invalid={!!err} aria-describedby={describedBy} bind:value={values[f.field]} disabled={f.readonly} placeholder={f.placeholder}></textarea>
|
|
491
615
|
{:else if kind === 'number'}
|
|
492
|
-
<input id={`sv-ef-${f.field}`} type="number" bind:value={values[f.field]} disabled={f.readonly} placeholder={f.placeholder} />
|
|
616
|
+
<input id={`sv-ef-${f.field}`} aria-invalid={!!err} aria-describedby={describedBy} type="number" bind:value={values[f.field]} disabled={f.readonly} placeholder={f.placeholder} />
|
|
493
617
|
{:else if kind === 'date'}
|
|
494
|
-
<input id={`sv-ef-${f.field}`} type="date" bind:value={values[f.field]} disabled={f.readonly} />
|
|
618
|
+
<input id={`sv-ef-${f.field}`} aria-invalid={!!err} aria-describedby={describedBy} type="date" bind:value={values[f.field]} disabled={f.readonly} />
|
|
495
619
|
{:else if kind === 'datetime'}
|
|
496
|
-
<input id={`sv-ef-${f.field}`} type="datetime-local" bind:value={values[f.field]} disabled={f.readonly} />
|
|
620
|
+
<input id={`sv-ef-${f.field}`} aria-invalid={!!err} aria-describedby={describedBy} type="datetime-local" bind:value={values[f.field]} disabled={f.readonly} />
|
|
497
621
|
{:else if kind === 'time'}
|
|
498
|
-
<input id={`sv-ef-${f.field}`} type="time" bind:value={values[f.field]} disabled={f.readonly} />
|
|
622
|
+
<input id={`sv-ef-${f.field}`} aria-invalid={!!err} aria-describedby={describedBy} type="time" bind:value={values[f.field]} disabled={f.readonly} />
|
|
499
623
|
{:else}
|
|
500
|
-
<input id={`sv-ef-${f.field}`} type="text" bind:value={values[f.field]} disabled={f.readonly} placeholder={f.placeholder} />
|
|
624
|
+
<input id={`sv-ef-${f.field}`} aria-invalid={!!err} aria-describedby={describedBy} type="text" bind:value={values[f.field]} disabled={f.readonly} placeholder={f.placeholder} />
|
|
501
625
|
{/if}
|
|
502
626
|
|
|
503
|
-
{#if f.help && !
|
|
504
|
-
{#if
|
|
627
|
+
{#if f.help && !err}<p class="sv-ep-field__help" id={`sv-eh-${f.field}`}>{f.help}</p>{/if}
|
|
628
|
+
{#if err}<p class="sv-ep-field__err" id={`sv-ee-${f.field}`}>{err}</p>{/if}
|
|
505
629
|
</div>
|
|
506
630
|
{/snippet}
|
|
507
631
|
|
|
@@ -726,6 +850,9 @@
|
|
|
726
850
|
.sv-ep__section { display: flex; flex-direction: column; }
|
|
727
851
|
.sv-ep__section + .sv-ep__section { border-top: 1px solid var(--sg-border, #e6e8ec); }
|
|
728
852
|
.sv-ep__section-title { margin: 0; padding: 14px 18px 0; font-size: 12.5px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; color: var(--sg-muted, #64748b); }
|
|
853
|
+
.sv-ep__section-desc { margin: 4px 0 0; padding: 0 18px; font-size: 12px; color: var(--sg-muted, #64748b); }
|
|
854
|
+
.sv-ep__discard { margin-right: auto; font-size: 12.5px; font-weight: 600; color: var(--ep-danger); }
|
|
855
|
+
.sv-ep__btn--danger { border-color: var(--ep-danger); background: var(--ep-danger); color: #fff; }
|
|
729
856
|
.sv-ep__section .sv-ep__body { flex: 0 0 auto; overflow: visible; }
|
|
730
857
|
.sv-ep-field {
|
|
731
858
|
display: flex;
|
|
@@ -807,6 +934,42 @@
|
|
|
807
934
|
font-weight: 500;
|
|
808
935
|
color: var(--ep-danger);
|
|
809
936
|
}
|
|
937
|
+
.sv-ep__summary {
|
|
938
|
+
margin: 0 18px 12px;
|
|
939
|
+
padding: 10px 12px;
|
|
940
|
+
border: 1px solid var(--ep-danger);
|
|
941
|
+
border-left-width: 3px;
|
|
942
|
+
border-radius: 6px;
|
|
943
|
+
background: color-mix(in srgb, var(--ep-danger) 7%, var(--sg-bg, #fff));
|
|
944
|
+
}
|
|
945
|
+
.sv-ep__summary-title {
|
|
946
|
+
margin: 0 0 6px;
|
|
947
|
+
font-size: 12.5px;
|
|
948
|
+
font-weight: 600;
|
|
949
|
+
color: var(--ep-danger);
|
|
950
|
+
}
|
|
951
|
+
.sv-ep__summary ul {
|
|
952
|
+
margin: 0;
|
|
953
|
+
padding-left: 18px;
|
|
954
|
+
display: flex;
|
|
955
|
+
flex-direction: column;
|
|
956
|
+
gap: 3px;
|
|
957
|
+
}
|
|
958
|
+
.sv-ep__summary li {
|
|
959
|
+
font-size: 12px;
|
|
960
|
+
color: var(--sg-fg, #0f172a);
|
|
961
|
+
}
|
|
962
|
+
.sv-ep__summary button {
|
|
963
|
+
background: none;
|
|
964
|
+
border: 0;
|
|
965
|
+
padding: 0;
|
|
966
|
+
font: inherit;
|
|
967
|
+
color: inherit;
|
|
968
|
+
text-align: left;
|
|
969
|
+
cursor: pointer;
|
|
970
|
+
text-decoration: underline;
|
|
971
|
+
text-underline-offset: 2px;
|
|
972
|
+
}
|
|
810
973
|
.sv-ep__submit-err {
|
|
811
974
|
margin: 0;
|
|
812
975
|
padding: 0 18px 4px;
|