From 01ddefc08081d07c4b21e36ac9e54e4a110921d9 Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Thu, 14 Aug 2025 13:28:17 +0200 Subject: [PATCH 01/36] bulk import and export --- import-export-schema/.gitignore | 1 + import-export-schema/README.md | 100 +- import-export-schema/biome.json | 26 +- import-export-schema/package-lock.json | 5692 ++++++++++++----- import-export-schema/package.json | 9 +- import-export-schema/postcss.config.cjs | 12 + import-export-schema/src/components/Field.tsx | 2 +- .../components/FieldsAndFieldsetsSummary.tsx | 115 + .../src/components/GraphCanvas.tsx | 40 + .../src/components/ItemTypeNodeRenderer.tsx | 51 +- .../src/components/PluginNodeRenderer.tsx | 2 +- .../src/components/ProgressStallNotice.tsx | 64 + .../src/entrypoints/Config/index.tsx | 19 +- .../src/entrypoints/ExportHome/index.tsx | 653 ++ .../ExportPage/ExportItemTypeNodeRenderer.tsx | 13 +- .../ExportPage/ExportPluginNodeRenderer.tsx | 8 +- .../entrypoints/ExportPage/ExportSchema.ts | 79 +- .../src/entrypoints/ExportPage/Inner.tsx | 546 +- .../ExportPage/LargeSelectionView.tsx | 411 ++ .../ExportPage/PostExportSummary.tsx | 703 ++ .../entrypoints/ExportPage/buildExportDoc.ts | 42 +- .../ExportPage/buildGraphFromSchema.ts | 239 +- .../src/entrypoints/ExportPage/index.tsx | 396 +- .../entrypoints/ExportPage/styles.module.css | 3 - .../ExportPage/useAnimatedNodes.tsx | 2 +- .../ConflictsManager/Collapsible.tsx | 17 +- .../ConflictsManager/ItemTypeConflict.tsx | 10 +- .../ConflictsManager/PluginConflict.tsx | 4 +- .../ConflictsManager/buildConflicts.ts | 26 +- .../ImportPage/ConflictsManager/index.tsx | 353 +- .../entrypoints/ImportPage/FileDropZone.tsx | 35 +- .../ImportPage/ImportItemTypeNodeRenderer.tsx | 10 +- .../ImportPage/ImportPluginNodeRenderer.tsx | 10 +- .../src/entrypoints/ImportPage/Inner.tsx | 128 +- .../ImportPage/LargeSelectionView.tsx | 291 + .../ImportPage/PostImportSummary.tsx | 995 +++ .../ImportPage/ResolutionsForm.tsx | 210 +- .../ImportPage/buildGraphFromExportDoc.ts | 89 +- .../entrypoints/ImportPage/buildImportDoc.ts | 9 +- .../entrypoints/ImportPage/importSchema.ts | 561 +- .../src/entrypoints/ImportPage/index.tsx | 1282 +++- .../src/icons/fieldgroup-text.svg | 3 - .../src/icons/fielgroup-location.svg | 6 - import-export-schema/src/index.css | 1000 ++- import-export-schema/src/main.tsx | 65 +- import-export-schema/src/types/lodash-es.d.ts | 18 + .../src/utils/ProjectSchema.ts | 99 +- .../src/utils/createCmaClient.ts | 24 + .../src/utils/datocms/appearance.ts | 81 + .../src/utils/datocms/fieldTypeInfo.ts | 75 +- .../src/utils/datocms/schema.ts | 33 +- .../src/utils/datocms/validators.ts | 34 + import-export-schema/src/utils/debug.ts | 17 + .../src/utils/exportDoc/normalize.ts | 57 + .../src/utils/fieldTypeGroups.ts | 136 - .../src/utils/graph/analysis.ts | 145 + .../src/utils/graph/buildGraph.ts | 178 + .../src/utils/graph/buildHierarchyNodes.ts | 41 +- .../src/utils/graph/dependencies.ts | 44 + import-export-schema/src/utils/graph/edges.ts | 68 + import-export-schema/src/utils/graph/nodes.ts | 25 + .../rebuildGraphWithPositionsFromHierarchy.ts | 28 +- import-export-schema/src/utils/graph/sort.ts | 17 + import-export-schema/src/utils/graph/types.ts | 2 +- import-export-schema/src/utils/ids.ts | 13 + import-export-schema/src/utils/progress.ts | 47 + .../src/utils/schema/ExportSchemaSource.ts | 32 + .../src/utils/schema/ISchemaSource.ts | 10 + .../src/utils/schema/ProjectSchemaSource.ts | 30 + import-export-schema/tsconfig.app.json | 2 +- import-export-schema/tsconfig.app.tsbuildinfo | 2 +- import-export-schema/tsconfig.node.json | 2 +- import-export-schema/vite.config.ts | 32 +- 73 files changed, 12672 insertions(+), 2952 deletions(-) create mode 100644 import-export-schema/postcss.config.cjs create mode 100644 import-export-schema/src/components/FieldsAndFieldsetsSummary.tsx create mode 100644 import-export-schema/src/components/GraphCanvas.tsx create mode 100644 import-export-schema/src/components/ProgressStallNotice.tsx create mode 100644 import-export-schema/src/entrypoints/ExportHome/index.tsx create mode 100644 import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.tsx create mode 100644 import-export-schema/src/entrypoints/ExportPage/PostExportSummary.tsx delete mode 100644 import-export-schema/src/entrypoints/ExportPage/styles.module.css create mode 100644 import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.tsx create mode 100644 import-export-schema/src/entrypoints/ImportPage/PostImportSummary.tsx delete mode 100644 import-export-schema/src/icons/fieldgroup-text.svg delete mode 100644 import-export-schema/src/icons/fielgroup-location.svg create mode 100644 import-export-schema/src/types/lodash-es.d.ts create mode 100644 import-export-schema/src/utils/createCmaClient.ts create mode 100644 import-export-schema/src/utils/datocms/appearance.ts create mode 100644 import-export-schema/src/utils/datocms/validators.ts create mode 100644 import-export-schema/src/utils/debug.ts create mode 100644 import-export-schema/src/utils/exportDoc/normalize.ts delete mode 100644 import-export-schema/src/utils/fieldTypeGroups.ts create mode 100644 import-export-schema/src/utils/graph/analysis.ts create mode 100644 import-export-schema/src/utils/graph/buildGraph.ts create mode 100644 import-export-schema/src/utils/graph/dependencies.ts create mode 100644 import-export-schema/src/utils/graph/edges.ts create mode 100644 import-export-schema/src/utils/graph/nodes.ts create mode 100644 import-export-schema/src/utils/graph/sort.ts create mode 100644 import-export-schema/src/utils/ids.ts create mode 100644 import-export-schema/src/utils/progress.ts create mode 100644 import-export-schema/src/utils/schema/ExportSchemaSource.ts create mode 100644 import-export-schema/src/utils/schema/ISchemaSource.ts create mode 100644 import-export-schema/src/utils/schema/ProjectSchemaSource.ts diff --git a/import-export-schema/.gitignore b/import-export-schema/.gitignore index b49acfe7..009e052b 100644 --- a/import-export-schema/.gitignore +++ b/import-export-schema/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +*.tsbuildinfo # Editor directories and files .vscode/* diff --git a/import-export-schema/README.md b/import-export-schema/README.md index c72e93d1..0ef2e40b 100644 --- a/import-export-schema/README.md +++ b/import-export-schema/README.md @@ -1,56 +1,84 @@ # DatoCMS Schema Import/Export Plugin -A powerful plugin for DatoCMS that enables seamless schema migration between projects through JSON import/export functionality. +Powerful, safe schema migration for DatoCMS. Export models/blocks and plugins as JSON, then import them into another project with guided conflict resolution. -## Features +## Highlights -- Export single or multiple models/block models as JSON files -- Import models into different DatoCMS projects -- Smart conflict resolution with guided instructions -- Automatic plugin dependency detection and inclusion -- Safe import operations that preserve existing schema +- Export from anywhere: start from a single model’s “Export as JSON…” action, select multiple models/blocks, or export the entire schema. +- Dependency-aware: auto-detects linked models/blocks and plugins; add them with one click (“Select all dependencies”). +- Scales to large projects: graph preview for small selections, fast list view with search and relation counts for big ones. +- Guided imports: detect conflicts, choose to reuse or rename, and confirm sensitive actions with typed confirmation. +- Post-action summaries: clear, filterable summaries after export/import with connections and plugin usage. +- Safe by design: imports are additive; existing models/blocks and plugins are never modified unless you explicitly opt to reuse. -## Safety Features +## Where To Find It -As a security measure, this plugin is designed to never modify existing schema entities in the target project during JSON imports. It only adds new entities to the project, making the operation completely safe and non-destructive. +- Settings > Plugins > Schema Import/Export > Export: start a new export, select multiple models/blocks, or export the entire current environment. +- Settings > Plugins > Schema Import/Export > Import: upload an export file (or paste a recipe URL) and import safely into the current environment. +- From a model/block: in Schema, open a model/block, click the three dots beside the model/block name, and pick “Export as JSON…” to export starting from that entity. ## Installation -1. Navigate to your DatoCMS environment configuration -2. Go to the Plugins section -3. Search for "Schema Import/Export" -4. Click Install +- In DatoCMS, open your project, go to Plugins, search for “Schema Import/Export”, then install. The plugin only requests `currentUserAccessToken`. -## Usage +## Export -### Exporting Models +- Start from a model/block + - Open Schema, select a model/block. + - Click the three dots beside the model/block name. + - Choose “Export as JSON…”. + - Preview dependencies and optionally include related models/blocks and plugins. + - Download the generated `export.json`. -1. In the Schema section, navigate to one of your models/block models -2. Select the "Export as JSON..." option -3. If the model/block model references other models/block models, you can decide to export them as well -4. Save the generated JSON file +- Start a new export (Schema > Export) + - Pick one or more starting models/blocks, then refine the selection. + - Use “Select all dependencies” to include all linked models/blocks and any used plugins. + - Search and filter in list view; see inbound/outbound relation counts and “Why included?” explanations. +- For large projects the graph is replaced with a fast list view. -### Importing Models +- Export the entire schema (one click) + - From Schema > Export, choose “Export entire current schema” to include all models/blocks and plugins. + - A progress overlay appears with a cancel button and a stall notice if rate limited; the JSON is downloaded when done. -1. Navigate to your DatoCMS environment configuration -2. Go to the Import/Export section -3. Drop your JSON file -4. Follow the conflict resolution prompts if any appear -5. Confirm the import +- After export + - A Post‑export summary shows counts (models, blocks, fields, fieldsets, plugins) and, for each model/block, the number of linked models/blocks and used plugins. + - You can re-download the JSON and close back to the export screen. -## Conflict Resolution +## Import -When importing models, the plugin will: +- Start an import (Schema > Import) + - Drag and drop an exported JSON file, or provide a recipe URL via `?recipe_url=https://…` (optional `recipe_title=…`). + - The plugin prepares a conflicts view by comparing the file against your project’s schema. -- Detect potential conflicts with existing schema -- Provide clear instructions for resolving each conflict -- Allow you to review changes before applying them +- Resolve conflicts safely + - For models/blocks: choose “Reuse existing” or “Rename” (typed confirmation required if you select any renames). + - For plugins: choose “Reuse existing” or “Skip”. + - The graph switches to a searchable list for large selections; click “Open details” to focus an entity. -## Dependencies +- Run the import + - The operation is additive: new models/blocks/plugins/fields/fieldsets are created; existing ones are never changed unless “reuse” is chosen. + - Field appearances are reconstructed safely: built‑in editors are preserved; external plugin editors/addons are mapped when included, otherwise sensible defaults are applied. + - A progress overlay (with cancel) shows what’s happening and warns if progress stalls due to API rate limits. -The plugin automatically handles the following dependencies: +- After import + - A Post‑import summary shows what was created, what was reused/skipped, any renames applied, and the connections to other models/blocks and plugins. -- Required field plugins -- Block model relationships -- Field validations -- Field appearance settings +## Notes & Limits + +- Plugin detection: editor/addon plugins used by fields are included when “Select all dependencies” is used. If the list of installed plugins cannot be fetched, the UI warns and detection may be incomplete. +- Appearance portability: if an editor plugin is not selected, that field falls back to a valid built‑in editor; addons are included only if selected or already installed. +- Rate limiting: long operations show a gentle notice if progress stalls; they usually resume automatically. You can cancel exports/imports at any time. + +## Export File Format + +- Version 2 (current): `{ version: '2', rootItemTypeId, entities: […] }` — preserves the explicit root model/block used to seed the export, to re-generate the export graph deterministically. +- Version 1 (legacy): `{ version: '1', entities: […] }` — still supported for import; the root is inferred from references. + +## Safety + +- Imports are additive and non‑destructive. The plugin never overwrites existing models/blocks or plugins. When conflicts are detected, you explicitly pick “Reuse existing” or “Rename”. + +## Troubleshooting + +- “Why did the graph disappear?” For very large selections, the UI switches to a faster list view. +- “Fields lost their editor?” If you don’t include a custom editor plugin in the export/import, the plugin selects a safe, built‑in editor so the field remains valid in the target project. diff --git a/import-export-schema/biome.json b/import-export-schema/biome.json index 5d64b5e3..72e05e33 100644 --- a/import-export-schema/biome.json +++ b/import-export-schema/biome.json @@ -1,21 +1,18 @@ { - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "files": { - "ignoreUnknown": false, - "ignore": ["vite.config.ts", "tsconfig.app.json", "tsconfig.node.json"] + "ignoreUnknown": false }, "formatter": { "enabled": true, "indentStyle": "space" }, - "organizeImports": { - "enabled": true - }, + "linter": { "enabled": true, "rules": { @@ -24,7 +21,8 @@ "noNonNullAssertion": "off" }, "a11y": { - "useKeyWithClickEvents": "off" + "useKeyWithClickEvents": "off", + "useSemanticElements": "off" }, "correctness": { "useExhaustiveDependencies": "off" @@ -38,5 +36,17 @@ "formatter": { "quoteStyle": "single" } - } + }, + "overrides": [ + { + "includes": ["src/types/lodash-es.d.ts"], + "linter": { + "rules": { + "suspicious": { + "noExplicitAny": "off" + } + } + } + } + ] } diff --git a/import-export-schema/package-lock.json b/import-export-schema/package-lock.json index 464c3755..f3fd4294 100644 --- a/import-export-schema/package-lock.json +++ b/import-export-schema/package-lock.json @@ -1,14 +1,15 @@ { "name": "datocms-plugin-schema-import-export", - "version": "0.1.7", + "version": "0.1.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "datocms-plugin-schema-import-export", - "version": "0.1.7", + "version": "0.1.15", "dependencies": { "@datocms/cma-client": "^3.4.5", + "@datocms/cma-client-browser": "^3.4.5", "@datocms/rest-api-events": "^3.4.3", "@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", @@ -29,12 +30,15 @@ "ts-easing": "^0.2.0" }, "devDependencies": { + "@biomejs/biome": "^2.2.0", "@types/d3-timer": "^3.0.2", "@types/node": "^22.13.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", "globals": "^15.9.0", + "postcss-preset-env": "^9.6.0", + "rollup-plugin-visualizer": "^5.12.0", "typescript": "^5.5.3", "vite": "^5.4.1", "vite-plugin-svgr": "^4.3.0" @@ -339,677 +343,1898 @@ "node": ">=6.9.0" } }, - "node_modules/@datocms/cma-client": { - "version": "3.4.5", - "resolved": "https://registry.npmjs.org/@datocms/cma-client/-/cma-client-3.4.5.tgz", - "integrity": "sha512-ddwqN1c0gNf6D79GjxkcZZXKqGk4541GTZfrpXUnU5H0NQJoh1avkCqaecaI9CybJClYwKmoEWgcXZYWjednCQ==", - "license": "MIT", - "dependencies": { - "@datocms/rest-client-utils": "^3.4.2", - "uuid": "^9.0.1" - } - }, - "node_modules/@datocms/rest-api-events": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/@datocms/rest-api-events/-/rest-api-events-3.4.3.tgz", - "integrity": "sha512-F1zz0Pj1JqVIDjgl/zzNJ8zyFZ/Yjyhi4INeP0XsnrHfBy/pHff075iGTvNBXjMHzbP2DU8rR9WeNfXPCUTsLg==", - "license": "MIT", - "dependencies": { - "pusher-js": "^7.0.6" - } - }, - "node_modules/@datocms/rest-client-utils": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/@datocms/rest-client-utils/-/rest-client-utils-3.4.2.tgz", - "integrity": "sha512-VjAtxySGH2c1qlZkJUnaRkujDiGAtoc5BtN1V42lvz35hFi/s/fkVOL40Ybr+lkIYsNtFdCPFaE5sW0tABHqaA==", - "license": "MIT", - "dependencies": { - "async-scheduler": "^1.4.4" - } - }, - "node_modules/@emotion/babel-plugin": { - "version": "11.13.5", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", - "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.16.7", - "@babel/runtime": "^7.18.3", - "@emotion/hash": "^0.9.2", - "@emotion/memoize": "^0.9.0", - "@emotion/serialize": "^1.3.3", - "babel-plugin-macros": "^3.1.0", - "convert-source-map": "^1.5.0", - "escape-string-regexp": "^4.0.0", - "find-root": "^1.1.0", - "source-map": "^0.5.7", - "stylis": "4.2.0" - } - }, - "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "license": "MIT" - }, - "node_modules/@emotion/cache": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", - "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", - "license": "MIT", - "dependencies": { - "@emotion/memoize": "^0.9.0", - "@emotion/sheet": "^1.4.0", - "@emotion/utils": "^1.4.2", - "@emotion/weak-memoize": "^0.4.0", - "stylis": "4.2.0" - } - }, - "node_modules/@emotion/hash": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", - "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", - "license": "MIT" - }, - "node_modules/@emotion/memoize": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", - "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", - "license": "MIT" - }, - "node_modules/@emotion/react": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", - "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.13.5", - "@emotion/cache": "^11.14.0", - "@emotion/serialize": "^1.3.3", - "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", - "@emotion/utils": "^1.4.2", - "@emotion/weak-memoize": "^0.4.0", - "hoist-non-react-statics": "^3.3.1" + "node_modules/@biomejs/biome": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.2.0.tgz", + "integrity": "sha512-3On3RSYLsX+n9KnoSgfoYlckYBoU6VRM22cw1gB4Y0OuUVSYd/O/2saOJMrA4HFfA1Ff0eacOvMN1yAAvHtzIw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" }, - "peerDependencies": { - "react": ">=16.8.0" + "engines": { + "node": ">=14.21.3" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@emotion/serialize": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", - "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", - "license": "MIT", - "dependencies": { - "@emotion/hash": "^0.9.2", - "@emotion/memoize": "^0.9.0", - "@emotion/unitless": "^0.10.0", - "@emotion/utils": "^1.4.2", - "csstype": "^3.0.2" - } - }, - "node_modules/@emotion/sheet": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", - "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", - "license": "MIT" - }, - "node_modules/@emotion/unitless": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", - "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", - "license": "MIT" - }, - "node_modules/@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", - "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", - "license": "MIT", - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@emotion/utils": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", - "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", - "license": "MIT" - }, - "node_modules/@emotion/weak-memoize": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", - "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", - "license": "MIT" - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.2.0", + "@biomejs/cli-darwin-x64": "2.2.0", + "@biomejs/cli-linux-arm64": "2.2.0", + "@biomejs/cli-linux-arm64-musl": "2.2.0", + "@biomejs/cli-linux-x64": "2.2.0", + "@biomejs/cli-linux-x64-musl": "2.2.0", + "@biomejs/cli-win32-arm64": "2.2.0", + "@biomejs/cli-win32-x64": "2.2.0" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.0.tgz", + "integrity": "sha512-zKbwUUh+9uFmWfS8IFxmVD6XwqFcENjZvEyfOxHs1epjdH3wyyMQG80FGDsmauPwS2r5kXdEM0v/+dTIA9FXAg==", "cpu": [ - "ppc64" + "arm64" ], "dev": true, - "license": "MIT", + "license": "MIT OR Apache-2.0", "optional": true, "os": [ - "aix" + "darwin" ], "engines": { - "node": ">=12" + "node": ">=14.21.3" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.0.tgz", + "integrity": "sha512-+OmT4dsX2eTfhD5crUOPw3RPhaR+SKVspvGVmSdZ9y9O/AgL8pla6T4hOn1q+VAFBHuHhsdxDRJgFCSC7RaMOw==", "cpu": [ - "arm" + "x64" ], "dev": true, - "license": "MIT", + "license": "MIT OR Apache-2.0", "optional": true, "os": [ - "android" + "darwin" ], "engines": { - "node": ">=12" + "node": ">=14.21.3" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.0.tgz", + "integrity": "sha512-6eoRdF2yW5FnW9Lpeivh7Mayhq0KDdaDMYOJnH9aT02KuSIX5V1HmWJCQQPwIQbhDh68Zrcpl8inRlTEan0SXw==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", + "license": "MIT OR Apache-2.0", "optional": true, "os": [ - "android" + "linux" ], "engines": { - "node": ">=12" + "node": ">=14.21.3" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.0.tgz", + "integrity": "sha512-egKpOa+4FL9YO+SMUMLUvf543cprjevNc3CAgDNFLcjknuNMcZ0GLJYa3EGTCR2xIkIUJDVneBV3O9OcIlCEZQ==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "license": "MIT", + "license": "MIT OR Apache-2.0", "optional": true, "os": [ - "android" + "linux" ], "engines": { - "node": ">=12" + "node": ">=14.21.3" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.0.tgz", + "integrity": "sha512-5UmQx/OZAfJfi25zAnAGHUMuOd+LOsliIt119x2soA2gLggQYrVPA+2kMUxR6Mw5M1deUF/AWWP2qpxgH7Nyfw==", "cpu": [ - "arm64" + "x64" ], "dev": true, - "license": "MIT", + "license": "MIT OR Apache-2.0", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": ">=12" + "node": ">=14.21.3" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.0.tgz", + "integrity": "sha512-I5J85yWwUWpgJyC1CcytNSGusu2p9HjDnOPAFG4Y515hwRD0jpR9sT9/T1cKHtuCvEQ/sBvx+6zhz9l9wEJGAg==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "MIT OR Apache-2.0", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": ">=12" + "node": ">=14.21.3" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.0.tgz", + "integrity": "sha512-n9a1/f2CwIDmNMNkFs+JI0ZjFnMO0jdOyGNtihgUNFnlmd84yIYY2KMTBmMV58ZlVHjgmY5Y6E1hVTnSRieggA==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", + "license": "MIT OR Apache-2.0", "optional": true, "os": [ - "freebsd" + "win32" ], "engines": { - "node": ">=12" + "node": ">=14.21.3" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.0.tgz", + "integrity": "sha512-Nawu5nHjP/zPKTIryh2AavzTc/KEg4um/MxWdXW0A6P/RZOyIpa7+QSjeXwAwX/utJGaCoXRPWtF3m5U/bB3Ww==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "MIT OR Apache-2.0", "optional": true, "os": [ - "freebsd" + "win32" ], "engines": { - "node": ">=12" + "node": ">=14.21.3" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], + "node_modules/@csstools/cascade-layer-name-parser": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-1.0.13.tgz", + "integrity": "sha512-MX0yLTwtZzr82sQ0zOjqimpZbzjMaK/h2pmlrLK7DCzlmiZLYFpoO94WmN1akRVo6ll/TdpHb53vihHLUMyvng==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], + "license": "MIT", "engines": { - "node": ">=12" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], + "node_modules/@csstools/color-helpers": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-4.2.1.tgz", + "integrity": "sha512-CEypeeykO9AN7JWkr1OEOQb0HRzZlPWGwV0Ya6DuVgFdDi6g3ma/cPZ5ZPZM4AWQikDpq/0llnGGlIL+j8afzw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], + "license": "MIT-0", "engines": { - "node": ">=12" + "node": "^14 || ^16 || >=18" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], + "node_modules/@csstools/css-calc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-1.2.4.tgz", + "integrity": "sha512-tfOuvUQeo7Hz+FcuOd3LfXVp+342pnWUJ7D2y8NUpu1Ww6xnTbHLpz018/y6rtbHifJ3iIEf9ttxXd8KG7nL0Q==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], - "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=12" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], + "node_modules/@csstools/css-color-parser": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-2.0.5.tgz", + "integrity": "sha512-lRZSmtl+DSjok3u9hTWpmkxFZnz7stkbZxzKc08aDUsdrWwhSgWo8yq9rq9DaFUtbAyAq2xnH92fj01S+pwIww==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^4.2.1", + "@csstools/css-calc": "^1.2.4" + }, "engines": { - "node": ">=12" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], + "node_modules/@csstools/css-parser-algorithms": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.7.1.tgz", + "integrity": "sha512-2SJS42gxmACHgikc1WGesXLIT8d/q2l0UFM7TaEeIzdFCE/FPMtTiizcPGGJtlPo2xuQzY09OhrLTzRxqJqwGw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], + "license": "MIT", "engines": { - "node": ">=12" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^2.4.1" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], + "node_modules/@csstools/css-tokenizer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-2.4.1.tgz", + "integrity": "sha512-eQ9DIktFJBhGjioABJRtUucoWR2mwllurfnM8LuNGAqX3ViZXaUchqk+1s7jjtkFiT9ySdACsFEA3etErkALUg==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], + "license": "MIT", "engines": { - "node": ">=12" + "node": "^14 || ^16 || >=18" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], + "node_modules/@csstools/media-query-list-parser": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.13.tgz", + "integrity": "sha512-XaHr+16KRU9Gf8XLi3q8kDlI18d5vzKSKCY510Vrtc9iNR0NJzbY9hhTmwhzYZj/ZwGL4VmB3TA9hJW0Um2qFA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], + "license": "MIT", "engines": { - "node": ">=12" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], + "node_modules/@csstools/postcss-cascade-layers": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-4.0.6.tgz", + "integrity": "sha512-Xt00qGAQyqAODFiFEJNkTpSUz5VfYqnDLECdlA/Vv17nl/OIV5QfTRHGAXrBGG5YcJyHpJ+GF9gF/RZvOQz4oA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], + "license": "MIT-0", + "dependencies": { + "@csstools/selector-specificity": "^3.1.1", + "postcss-selector-parser": "^6.0.13" + }, "engines": { - "node": ">=12" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], + "node_modules/@csstools/postcss-color-function": { + "version": "3.0.19", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-3.0.19.tgz", + "integrity": "sha512-d1OHEXyYGe21G3q88LezWWx31ImEDdmINNDy0LyLNN9ChgN2bPxoubUPiHf9KmwypBMaHmNcMuA/WZOKdZk/Lg==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^2.0.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/utilities": "^1.0.0" + }, "engines": { - "node": ">=12" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], + "node_modules/@csstools/postcss-color-mix-function": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-function/-/postcss-color-mix-function-2.0.19.tgz", + "integrity": "sha512-mLvQlMX+keRYr16AuvuV8WYKUwF+D0DiCqlBdvhQ0KYEtcQl9/is9Ssg7RcIys8x0jIn2h1zstS4izckdZj9wg==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^2.0.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/utilities": "^1.0.0" + }, "engines": { - "node": ">=12" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], + "node_modules/@csstools/postcss-content-alt-text": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-content-alt-text/-/postcss-content-alt-text-1.0.0.tgz", + "integrity": "sha512-SkHdj7EMM/57GVvSxSELpUg7zb5eAndBeuvGwFzYtU06/QXJ/h9fuK7wO5suteJzGhm3GDF/EWPCdWV2h1IGHQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/utilities": "^1.0.0" + }, "engines": { - "node": ">=12" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], + "node_modules/@csstools/postcss-exponential-functions": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@csstools/postcss-exponential-functions/-/postcss-exponential-functions-1.0.9.tgz", + "integrity": "sha512-x1Avr15mMeuX7Z5RJUl7DmjhUtg+Amn5DZRD0fQ2TlTFTcJS8U1oxXQ9e5mA62S2RJgUU6db20CRoJyDvae2EQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-calc": "^1.2.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1" + }, "engines": { - "node": ">=12" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], + "node_modules/@csstools/postcss-font-format-keywords": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-font-format-keywords/-/postcss-font-format-keywords-3.0.2.tgz", + "integrity": "sha512-E0xz2sjm4AMCkXLCFvI/lyl4XO6aN1NCSMMVEOngFDJ+k2rDwfr6NDjWljk1li42jiLNChVX+YFnmfGCigZKXw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], + "license": "MIT-0", + "dependencies": { + "@csstools/utilities": "^1.0.0", + "postcss-value-parser": "^4.2.0" + }, "engines": { - "node": ">=12" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], + "node_modules/@csstools/postcss-gamut-mapping": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@csstools/postcss-gamut-mapping/-/postcss-gamut-mapping-1.0.11.tgz", + "integrity": "sha512-KrHGsUPXRYxboXmJ9wiU/RzDM7y/5uIefLWKFSc36Pok7fxiPyvkSHO51kh+RLZS1W5hbqw9qaa6+tKpTSxa5g==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^2.0.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1" + }, "engines": { - "node": ">=12" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/@floating-ui/core": { - "version": "1.6.9", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", - "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", - "license": "MIT", + "node_modules/@csstools/postcss-gradients-interpolation-method": { + "version": "4.0.20", + "resolved": "https://registry.npmjs.org/@csstools/postcss-gradients-interpolation-method/-/postcss-gradients-interpolation-method-4.0.20.tgz", + "integrity": "sha512-ZFl2JBHano6R20KB5ZrB8KdPM2pVK0u+/3cGQ2T8VubJq982I2LSOvQ4/VtxkAXjkPkk1rXt4AD1ni7UjTZ1Og==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { - "@floating-ui/utils": "^0.2.9" + "@csstools/css-color-parser": "^2.0.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/utilities": "^1.0.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/@floating-ui/dom": { - "version": "1.6.13", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", - "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", - "license": "MIT", + "node_modules/@csstools/postcss-hwb-function": { + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-3.0.18.tgz", + "integrity": "sha512-3ifnLltR5C7zrJ+g18caxkvSRnu9jBBXCYgnBznRjxm6gQJGnnCO9H6toHfywNdNr/qkiVf2dymERPQLDnjLRQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { - "@floating-ui/core": "^1.6.0", - "@floating-ui/utils": "^0.2.9" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", - "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", - "license": "MIT" - }, - "node_modules/@fortawesome/fontawesome-common-types": { - "version": "6.7.2", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz", - "integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==", - "license": "MIT", + "@csstools/css-color-parser": "^2.0.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/utilities": "^1.0.0" + }, "engines": { - "node": ">=6" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/@fortawesome/fontawesome-svg-core": { - "version": "6.7.2", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz", - "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==", - "license": "MIT", + "node_modules/@csstools/postcss-ic-unit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@csstools/postcss-ic-unit/-/postcss-ic-unit-3.0.7.tgz", + "integrity": "sha512-YoaNHH2wNZD+c+rHV02l4xQuDpfR8MaL7hD45iJyr+USwvr0LOheeytJ6rq8FN6hXBmEeoJBeXXgGmM8fkhH4g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { - "@fortawesome/fontawesome-common-types": "6.7.2" + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/utilities": "^1.0.0", + "postcss-value-parser": "^4.2.0" }, "engines": { - "node": ">=6" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/@fortawesome/free-solid-svg-icons": { - "version": "6.7.2", - "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz", - "integrity": "sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==", - "license": "(CC-BY-4.0 AND MIT)", - "dependencies": { - "@fortawesome/fontawesome-common-types": "6.7.2" - }, + "node_modules/@csstools/postcss-initial": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-initial/-/postcss-initial-1.0.1.tgz", + "integrity": "sha512-wtb+IbUIrIf8CrN6MLQuFR7nlU5C7PwuebfeEXfjthUha1+XZj2RVi+5k/lukToA24sZkYAiSJfHM8uG/UZIdg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "engines": { - "node": ">=6" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/@fortawesome/react-fontawesome": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.2.tgz", - "integrity": "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g==", - "license": "MIT", + "node_modules/@csstools/postcss-is-pseudo-class": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-4.0.8.tgz", + "integrity": "sha512-0aj591yGlq5Qac+plaWCbn5cpjs5Sh0daovYUKJUOMjIp70prGH/XPLp7QjxtbFXz3CTvb0H9a35dpEuIuUi3Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { - "prop-types": "^15.8.1" + "@csstools/selector-specificity": "^3.1.1", + "postcss-selector-parser": "^6.0.13" + }, + "engines": { + "node": "^14 || ^16 || >=18" }, "peerDependencies": { - "@fortawesome/fontawesome-svg-core": "~1 || ~6", - "react": ">=16.3" + "postcss": "^8.4" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", - "license": "MIT", + "node_modules/@csstools/postcss-light-dark-function": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@csstools/postcss-light-dark-function/-/postcss-light-dark-function-1.0.8.tgz", + "integrity": "sha512-x0UtpCyVnERsplUeoaY6nEtp1HxTf4lJjoK/ULEm40DraqFfUdUSt76yoOyX5rGY6eeOUOkurHyYlFHVKv/pew==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/utilities": "^1.0.0" }, "engines": { - "node": ">=6.0.0" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", + "node_modules/@csstools/postcss-logical-float-and-clear": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-float-and-clear/-/postcss-logical-float-and-clear-2.0.1.tgz", + "integrity": "sha512-SsrWUNaXKr+e/Uo4R/uIsqJYt3DaggIh/jyZdhy/q8fECoJSKsSMr7nObSLdvoULB69Zb6Bs+sefEIoMG/YfOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "engines": { - "node": ">=6.0.0" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "license": "MIT", + "node_modules/@csstools/postcss-logical-overflow": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-overflow/-/postcss-logical-overflow-1.0.1.tgz", + "integrity": "sha512-Kl4lAbMg0iyztEzDhZuQw8Sj9r2uqFDcU1IPl+AAt2nue8K/f1i7ElvKtXkjhIAmKiy5h2EY8Gt/Cqg0pYFDCw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "engines": { - "node": ">=6.0.0" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "node_modules/@csstools/postcss-logical-overscroll-behavior": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-overscroll-behavior/-/postcss-logical-overscroll-behavior-1.0.1.tgz", + "integrity": "sha512-+kHamNxAnX8ojPCtV8WPcUP3XcqMFBSDuBuvT6MHgq7oX4IQxLIXKx64t7g9LiuJzE7vd06Q9qUYR6bh4YnGpQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/@rollup/pluginutils": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", - "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", + "node_modules/@csstools/postcss-logical-resize": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-resize/-/postcss-logical-resize-2.0.1.tgz", + "integrity": "sha512-W5Gtwz7oIuFcKa5SmBjQ2uxr8ZoL7M2bkoIf0T1WeNqljMkBrfw1DDA8/J83k57NQ1kcweJEjkJ04pUkmyee3A==", "dev": true, - "license": "MIT", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" + "postcss-value-parser": "^4.2.0" }, "engines": { - "node": ">=14.0.0" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-logical-viewport-units": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-viewport-units/-/postcss-logical-viewport-units-2.0.11.tgz", + "integrity": "sha512-ElITMOGcjQtvouxjd90WmJRIw1J7KMP+M+O87HaVtlgOOlDt1uEPeTeii8qKGe2AiedEp0XOGIo9lidbiU2Ogg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/utilities": "^1.0.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-media-minmax": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@csstools/postcss-media-minmax/-/postcss-media-minmax-1.1.8.tgz", + "integrity": "sha512-KYQCal2i7XPNtHAUxCECdrC7tuxIWQCW+s8eMYs5r5PaAiVTeKwlrkRS096PFgojdNCmHeG0Cb7njtuNswNf+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^1.2.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/media-query-list-parser": "^2.1.13" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-media-queries-aspect-ratio-number-values": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@csstools/postcss-media-queries-aspect-ratio-number-values/-/postcss-media-queries-aspect-ratio-number-values-2.0.11.tgz", + "integrity": "sha512-YD6jrib20GRGQcnOu49VJjoAnQ/4249liuz7vTpy/JfgqQ1Dlc5eD4HPUMNLOw9CWey9E6Etxwf/xc/ZF8fECA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/media-query-list-parser": "^2.1.13" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-nested-calc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-nested-calc/-/postcss-nested-calc-3.0.2.tgz", + "integrity": "sha512-ySUmPyawiHSmBW/VI44+IObcKH0v88LqFe0d09Sb3w4B1qjkaROc6d5IA3ll9kjD46IIX/dbO5bwFN/swyoyZA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/utilities": "^1.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-normalize-display-values": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-normalize-display-values/-/postcss-normalize-display-values-3.0.2.tgz", + "integrity": "sha512-fCapyyT/dUdyPtrelQSIV+d5HqtTgnNP/BEG9IuhgXHt93Wc4CfC1bQ55GzKAjWrZbgakMQ7MLfCXEf3rlZJOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-oklab-function": { + "version": "3.0.19", + "resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-3.0.19.tgz", + "integrity": "sha512-e3JxXmxjU3jpU7TzZrsNqSX4OHByRC3XjItV3Ieo/JEQmLg5rdOL4lkv/1vp27gXemzfNt44F42k/pn0FpE21Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^2.0.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/utilities": "^1.0.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-progressive-custom-properties": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-3.3.0.tgz", + "integrity": "sha512-W2oV01phnILaRGYPmGFlL2MT/OgYjQDrL9sFlbdikMFi6oQkFki9B86XqEWR7HCsTZFVq7dbzr/o71B75TKkGg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-relative-color-syntax": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/@csstools/postcss-relative-color-syntax/-/postcss-relative-color-syntax-2.0.19.tgz", + "integrity": "sha512-MxUMSNvio1WwuS6WRLlQuv6nNPXwIWUFzBBAvL/tBdWfiKjiJnAa6eSSN5gtaacSqUkQ/Ce5Z1OzLRfeaWhADA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^2.0.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/utilities": "^1.0.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-scope-pseudo-class": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-scope-pseudo-class/-/postcss-scope-pseudo-class-3.0.1.tgz", + "integrity": "sha512-3ZFonK2gfgqg29gUJ2w7xVw2wFJ1eNWVDONjbzGkm73gJHVCYK5fnCqlLr+N+KbEfv2XbWAO0AaOJCFB6Fer6A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-selector-parser": "^6.0.13" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-stepped-value-functions": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/postcss-stepped-value-functions/-/postcss-stepped-value-functions-3.0.10.tgz", + "integrity": "sha512-MZwo0D0TYrQhT5FQzMqfy/nGZ28D1iFtpN7Su1ck5BPHS95+/Y5O9S4kEvo76f2YOsqwYcT8ZGehSI1TnzuX2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-calc": "^1.2.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-text-decoration-shorthand": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@csstools/postcss-text-decoration-shorthand/-/postcss-text-decoration-shorthand-3.0.7.tgz", + "integrity": "sha512-+cptcsM5r45jntU6VjotnkC9GteFR7BQBfZ5oW7inLCxj7AfLGAzMbZ60hKTP13AULVZBdxky0P8um0IBfLHVA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/color-helpers": "^4.2.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-trigonometric-functions": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/postcss-trigonometric-functions/-/postcss-trigonometric-functions-3.0.10.tgz", + "integrity": "sha512-G9G8moTc2wiad61nY5HfvxLiM/myX0aYK4s1x8MQlPH29WDPxHQM7ghGgvv2qf2xH+rrXhztOmjGHJj4jsEqXw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-calc": "^1.2.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-unset-value": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-unset-value/-/postcss-unset-value-3.0.1.tgz", + "integrity": "sha512-dbDnZ2ja2U8mbPP0Hvmt2RMEGBiF1H7oY6HYSpjteXJGihYwgxgTr6KRbbJ/V6c+4wd51M+9980qG4gKVn5ttg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/selector-resolve-nested": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-1.1.0.tgz", + "integrity": "sha512-uWvSaeRcHyeNenKg8tp17EVDRkpflmdyvbE0DHo6D/GdBb6PDnCYYU6gRpXhtICMGMcahQmj2zGxwFM/WC8hCg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^6.0.13" + } + }, + "node_modules/@csstools/selector-specificity": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-3.1.1.tgz", + "integrity": "sha512-a7cxGcJ2wIlMFLlh8z2ONm+715QkPHiyJcxwQlKOz/03GPw1COpfhcmC9wm4xlZfp//jWHNNMwzjtqHXVWU9KA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^6.0.13" + } + }, + "node_modules/@csstools/utilities": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/utilities/-/utilities-1.0.0.tgz", + "integrity": "sha512-tAgvZQe/t2mlvpNosA4+CkMiZ2azISW5WPAcdSalZlEjQvUfghHxfQcrCiK/7/CrfAWVxyM88kGFYO82heIGDg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@datocms/cma-client": { + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/@datocms/cma-client/-/cma-client-3.4.5.tgz", + "integrity": "sha512-ddwqN1c0gNf6D79GjxkcZZXKqGk4541GTZfrpXUnU5H0NQJoh1avkCqaecaI9CybJClYwKmoEWgcXZYWjednCQ==", + "license": "MIT", + "dependencies": { + "@datocms/rest-client-utils": "^3.4.2", + "uuid": "^9.0.1" + } + }, + "node_modules/@datocms/cma-client-browser": { + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/@datocms/cma-client-browser/-/cma-client-browser-3.4.5.tgz", + "integrity": "sha512-z0Z3bXgpVshLKxtfcRoZzYzhKj0cI3+KNytXB1XG5sJ3nBwfAhGuBY3QLSHtOTPh44wCNox0Kw9TXaOfggJXXA==", + "license": "MIT", + "dependencies": { + "@datocms/cma-client": "^3.4.5", + "@datocms/rest-client-utils": "^3.4.2" + } + }, + "node_modules/@datocms/rest-api-events": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@datocms/rest-api-events/-/rest-api-events-3.4.3.tgz", + "integrity": "sha512-F1zz0Pj1JqVIDjgl/zzNJ8zyFZ/Yjyhi4INeP0XsnrHfBy/pHff075iGTvNBXjMHzbP2DU8rR9WeNfXPCUTsLg==", + "license": "MIT", + "dependencies": { + "pusher-js": "^7.0.6" + } + }, + "node_modules/@datocms/rest-client-utils": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@datocms/rest-client-utils/-/rest-client-utils-3.4.2.tgz", + "integrity": "sha512-VjAtxySGH2c1qlZkJUnaRkujDiGAtoc5BtN1V42lvz35hFi/s/fkVOL40Ybr+lkIYsNtFdCPFaE5sW0tABHqaA==", + "license": "MIT", + "dependencies": { + "async-scheduler": "^1.4.4" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", + "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", + "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "license": "MIT" + }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz", + "integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz", + "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.7.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz", + "integrity": "sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.7.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/react-fontawesome": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.2.tgz", + "integrity": "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~1 || ~6", + "react": ">=16.3" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", + "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" @@ -1020,1609 +2245,2634 @@ } } }, - "node_modules/@rollup/rollup-android-arm-eabi": { + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.6.tgz", + "integrity": "sha512-+GcCXtOQoWuC7hhX1P00LqjjIiS/iOouHXhMdiDSnq/1DGTox4SpUvO52Xm+div6+106r+TcvOeo/cxvyEyTgg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.6.tgz", + "integrity": "sha512-E8+2qCIjciYUnCa1AiVF1BkRgqIGW9KzJeesQqVfyRITGQN+dFuoivO0hnro1DjT74wXLRZ7QF8MIbz+luGaJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.6.tgz", + "integrity": "sha512-z9Ib+OzqN3DZEjX7PDQMHEhtF+t6Mi2z/ueChQPLS/qUMKY7Ybn5A2ggFoKRNRh1q1T03YTQfBTQCJZiepESAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.6.tgz", + "integrity": "sha512-PShKVY4u0FDAR7jskyFIYVyHEPCPnIQY8s5OcXkdU8mz3Y7eXDJPdyM/ZWjkYdR2m0izD9HHWA8sGcXn+Qrsyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.6.tgz", + "integrity": "sha512-YSwyOqlDAdKqs0iKuqvRHLN4SrD2TiswfoLfvYXseKbL47ht1grQpq46MSiQAx6rQEN8o8URtpXARCpqabqxGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.6.tgz", + "integrity": "sha512-HEP4CgPAY1RxXwwL5sPFv6BBM3tVeLnshF03HMhJYCNc6kvSqBgTMmsEjb72RkZBAWIqiPUyF1JpEBv5XT9wKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.6.tgz", + "integrity": "sha512-88fSzjC5xeH9S2Vg3rPgXJULkHcLYMkh8faix8DX4h4TIAL65ekwuQMA/g2CXq8W+NJC43V6fUpYZNjaX3+IIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.6.tgz", + "integrity": "sha512-wM4ztnutBqYFyvNeR7Av+reWI/enK9tDOTKNF+6Kk2Q96k9bwhDDOlnCUNRPvromlVXo04riSliMBs/Z7RteEg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.6.tgz", + "integrity": "sha512-9RyprECbRa9zEjXLtvvshhw4CMrRa3K+0wcp3KME0zmBe1ILmvcVHnypZ/aIDXpRyfhSYSuN4EPdCCj5Du8FIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.6.tgz", + "integrity": "sha512-qTmklhCTyaJSB05S+iSovfo++EwnIEZxHkzv5dep4qoszUMX5Ca4WM4zAVUMbfdviLgCSQOu5oU8YoGk1s6M9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.6.tgz", + "integrity": "sha512-4Qmkaps9yqmpjY5pvpkfOerYgKNUGzQpFxV6rnS7c/JfYbDSU0y6WpbbredB5cCpLFGJEqYX40WUmxMkwhWCjw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.6.tgz", + "integrity": "sha512-Zsrtux3PuaxuBTX/zHdLaFmcofWGzaWW1scwLU3ZbW/X+hSsFbz9wDIp6XvnT7pzYRl9MezWqEqKy7ssmDEnuQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.6.tgz", - "integrity": "sha512-+GcCXtOQoWuC7hhX1P00LqjjIiS/iOouHXhMdiDSnq/1DGTox4SpUvO52Xm+div6+106r+TcvOeo/cxvyEyTgg==", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.6.tgz", + "integrity": "sha512-aK+Zp+CRM55iPrlyKiU3/zyhgzWBxLVrw2mwiQSYJRobCURb781+XstzvA8Gkjg/hbdQFuDw44aUOxVQFycrAg==", "cpu": [ - "arm" + "riscv64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "android" + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.6.tgz", + "integrity": "sha512-WoKLVrY9ogmaYPXwTH326+ErlCIgMmsoRSx6bO+l68YgJnlOXhygDYSZe/qbUJCSiCiZAQ+tKm88NcWuUXqOzw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.6.tgz", + "integrity": "sha512-Sht4aFvmA4ToHd2vFzwMFaQCiYm2lDFho5rPcvPBT5pCdC+GwHG6CMch4GQfmWTQ1SwRKS0dhDYb54khSrjDWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.6.tgz", + "integrity": "sha512-zmmpOQh8vXc2QITsnCiODCDGXFC8LMi64+/oPpPx5qz3pqv0s6x46ps4xoycfUiVZps5PFn1gksZzo4RGTKT+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.6.tgz", + "integrity": "sha512-3/q1qUsO/tLqGBaD4uXsB6coVGB3usxw3qyeVb59aArCgedSF66MPdgRStUd7vbZOsko/CgVaY5fo2vkvPLWiA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.6.tgz", + "integrity": "sha512-oLHxuyywc6efdKVTxvc0135zPrRdtYVjtVD5GUm55I3ODxhU/PwkQFD97z16Xzxa1Fz0AEe4W/2hzRtd+IfpOA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.6.tgz", + "integrity": "sha512-0PVwmgzZ8+TZ9oGBmdZoQVXflbvuwzN/HRclujpl4N/q3i+y0lqLw8n1bXA8ru3sApDjlmONaNAuYr38y1Kr9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" ] }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.6.tgz", - "integrity": "sha512-E8+2qCIjciYUnCa1AiVF1BkRgqIGW9KzJeesQqVfyRITGQN+dFuoivO0hnro1DjT74wXLRZ7QF8MIbz+luGaJA==", - "cpu": [ - "arm64" - ], + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", + "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", + "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", + "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", + "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", + "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", + "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", + "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", + "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", + "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", + "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", + "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", + "@svgr/babel-plugin-transform-svg-component": "8.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/core": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", + "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^8.1.3", + "snake-case": "^3.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.6.tgz", - "integrity": "sha512-z9Ib+OzqN3DZEjX7PDQMHEhtF+t6Mi2z/ueChQPLS/qUMKY7Ybn5A2ggFoKRNRh1q1T03YTQfBTQCJZiepESAg==", - "cpu": [ - "arm64" - ], + "node_modules/@svgr/core/node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.6.tgz", - "integrity": "sha512-PShKVY4u0FDAR7jskyFIYVyHEPCPnIQY8s5OcXkdU8mz3Y7eXDJPdyM/ZWjkYdR2m0izD9HHWA8sGcXn+Qrsyg==", - "cpu": [ - "x64" - ], + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", + "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "dependencies": { + "@babel/types": "^7.21.3", + "entities": "^4.4.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.6.tgz", - "integrity": "sha512-YSwyOqlDAdKqs0iKuqvRHLN4SrD2TiswfoLfvYXseKbL47ht1grQpq46MSiQAx6rQEN8o8URtpXARCpqabqxGQ==", - "cpu": [ - "arm64" - ], + "node_modules/@svgr/plugin-jsx": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", + "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "@svgr/hast-util-to-babel-ast": "8.0.0", + "svg-parser": "^2.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.6.tgz", - "integrity": "sha512-HEP4CgPAY1RxXwwL5sPFv6BBM3tVeLnshF03HMhJYCNc6kvSqBgTMmsEjb72RkZBAWIqiPUyF1JpEBv5XT9wKQ==", - "cpu": [ - "x64" - ], + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.6.tgz", - "integrity": "sha512-88fSzjC5xeH9S2Vg3rPgXJULkHcLYMkh8faix8DX4h4TIAL65ekwuQMA/g2CXq8W+NJC43V6fUpYZNjaX3+IIg==", - "cpu": [ - "arm" - ], + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/types": "^7.0.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.6.tgz", - "integrity": "sha512-wM4ztnutBqYFyvNeR7Av+reWI/enK9tDOTKNF+6Kk2Q96k9bwhDDOlnCUNRPvromlVXo04riSliMBs/Z7RteEg==", - "cpu": [ - "arm" - ], + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.6.tgz", - "integrity": "sha512-9RyprECbRa9zEjXLtvvshhw4CMrRa3K+0wcp3KME0zmBe1ILmvcVHnypZ/aIDXpRyfhSYSuN4EPdCCj5Du8FIA==", - "cpu": [ - "arm64" - ], + "node_modules/@types/babel__traverse": { + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", + "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/types": "^7.20.7" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.6.tgz", - "integrity": "sha512-qTmklhCTyaJSB05S+iSovfo++EwnIEZxHkzv5dep4qoszUMX5Ca4WM4zAVUMbfdviLgCSQOu5oU8YoGk1s6M9Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.6.tgz", - "integrity": "sha512-4Qmkaps9yqmpjY5pvpkfOerYgKNUGzQpFxV6rnS7c/JfYbDSU0y6WpbbredB5cCpLFGJEqYX40WUmxMkwhWCjw==", - "cpu": [ - "loong64" - ], - "dev": true, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@types/d3-selection": "*" + } }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.6.tgz", - "integrity": "sha512-Zsrtux3PuaxuBTX/zHdLaFmcofWGzaWW1scwLU3ZbW/X+hSsFbz9wDIp6XvnT7pzYRl9MezWqEqKy7ssmDEnuQ==", - "cpu": [ - "ppc64" - ], - "dev": true, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@types/d3-color": "*" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.6.tgz", - "integrity": "sha512-aK+Zp+CRM55iPrlyKiU3/zyhgzWBxLVrw2mwiQSYJRobCURb781+XstzvA8Gkjg/hbdQFuDw44aUOxVQFycrAg==", - "cpu": [ - "riscv64" - ], + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@types/d3-selection": "*" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.6.tgz", - "integrity": "sha512-WoKLVrY9ogmaYPXwTH326+ErlCIgMmsoRSx6bO+l68YgJnlOXhygDYSZe/qbUJCSiCiZAQ+tKm88NcWuUXqOzw==", - "cpu": [ - "s390x" - ], - "dev": true, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.6.tgz", - "integrity": "sha512-Sht4aFvmA4ToHd2vFzwMFaQCiYm2lDFho5rPcvPBT5pCdC+GwHG6CMch4GQfmWTQ1SwRKS0dhDYb54khSrjDWw==", - "cpu": [ - "x64" - ], + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "dev": true, + "license": "MIT" + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.17.28", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz", + "integrity": "sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.6.tgz", - "integrity": "sha512-zmmpOQh8vXc2QITsnCiODCDGXFC8LMi64+/oPpPx5qz3pqv0s6x46ps4xoycfUiVZps5PFn1gksZzo4RGTKT+A==", - "cpu": [ - "x64" - ], + "node_modules/@types/gensync": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.4.tgz", + "integrity": "sha512-C3YYeRQWp2fmq9OryX+FoDy8nXS6scQ7dPptD8LnFDAUNcKWJjXQKDNJD3HVm+kOUsXhTOkpi69vI4EuAr95bA==", "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.13.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz", + "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "undici-types": "~6.20.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.6.tgz", - "integrity": "sha512-3/q1qUsO/tLqGBaD4uXsB6coVGB3usxw3qyeVb59aArCgedSF66MPdgRStUd7vbZOsko/CgVaY5fo2vkvPLWiA==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.9.18", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", + "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", + "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.6.tgz", - "integrity": "sha512-oLHxuyywc6efdKVTxvc0135zPrRdtYVjtVD5GUm55I3ODxhU/PwkQFD97z16Xzxa1Fz0AEe4W/2hzRtd+IfpOA==", - "cpu": [ - "ia32" - ], + "node_modules/@types/react-dom": { + "version": "18.3.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz", + "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "peerDependencies": { + "@types/react": "^18.0.0" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.6.tgz", - "integrity": "sha512-0PVwmgzZ8+TZ9oGBmdZoQVXflbvuwzN/HRclujpl4N/q3i+y0lqLw8n1bXA8ru3sApDjlmONaNAuYr38y1Kr9w==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "peerDependencies": { + "@types/react": "*" + } }, - "node_modules/@svgr/babel-plugin-add-jsx-attribute": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", - "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", + "node_modules/@types/scheduler": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", + "integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/core": "^7.26.0", + "@babel/plugin-transform-react-jsx-self": "^7.25.9", + "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.2" + }, "engines": { - "node": ">=14" + "node": "^14.18.0 || >=16.0.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + } + }, + "node_modules/@xyflow/react": { + "version": "12.4.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.4.2.tgz", + "integrity": "sha512-AFJKVc/fCPtgSOnRst3xdYJwiEcUN9lDY7EO/YiRvFHYCJGgfzg+jpvZjkTOnBLGyrMJre9378pRxAc3fsR06A==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.50", + "classcat": "^5.0.3", + "zustand": "^4.4.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "react": ">=17", + "react-dom": ">=17" } }, - "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", - "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", + "node_modules/@xyflow/system": { + "version": "0.0.50", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.50.tgz", + "integrity": "sha512-HVUZd4LlY88XAaldFh2nwVxDOcdIBxGpQ5txzwfJPf+CAjj2BfYug1fHs2p4yS7YO8H6A3EFJQovBE8YuHkAdg==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, "engines": { - "node": ">=14" + "node": ">=8" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", - "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-3.0.0.tgz", + "integrity": "sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==", + "license": "MIT" + }, + "node_modules/async-scheduler": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/async-scheduler/-/async-scheduler-1.4.4.tgz", + "integrity": "sha512-KVWlF6WKlUGJA8FOJYVVk7xDm3PxITWXp9hTeDsXMtUPvItQ2x6g2rIeNAa0TtAiiWvHJqhyA3wo+pj0rA7Ldg==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", - "engines": { - "node": ">=14" + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "postcss": "^8.1.0" } }, - "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", - "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", - "dev": true, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=10", + "npm": ">=6" } }, - "node_modules/@svgr/babel-plugin-svg-dynamic-title": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", - "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", + "node_modules/browserslist": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", + "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", - "engines": { - "node": ">=14" + "dependencies": { + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.1" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" + "bin": { + "browserslist": "cli.js" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/@svgr/babel-plugin-svg-em-dimensions": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", - "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", - "dev": true, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "license": "MIT", "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=6" } }, - "node_modules/@svgr/babel-plugin-transform-react-native-svg": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", - "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, "license": "MIT", "engines": { - "node": ">=14" + "node": ">=10" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@svgr/babel-plugin-transform-svg-component": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", - "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", + "node_modules/caniuse-lite": { + "version": "1.0.30001734", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001734.tgz", + "integrity": "sha512-uhE1Ye5vgqju6OI71HTQqcBCZrvHugk0MjLak7Q+HfoBgoq5Bi+5YnwjP4fjDgrtYr/l8MVRBvzz9dPD4KyK0A==", "dev": true, - "license": "MIT", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, "engines": { "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=7.0.0" } }, - "node_modules/@svgr/babel-preset": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", - "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/compute-scroll-into-view": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", + "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", "license": "MIT", "dependencies": { - "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", - "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", - "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", - "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", - "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", - "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", - "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", - "@svgr/babel-plugin-transform-svg-component": "8.0.0" + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/css-blank-pseudo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-6.0.2.tgz", + "integrity": "sha512-J/6m+lsqpKPqWHOifAFtKFeGLOzw3jR92rxQcwRUfA/eTuZzKfKlxOmYDx2+tqOPQAueNvBiY8WhAeHu5qNmTg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-selector-parser": "^6.0.13" }, "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" + "node": "^14 || ^16 || >=18" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "postcss": "^8.4" } }, - "node_modules/@svgr/core": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", - "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", + "node_modules/css-has-pseudo": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/css-has-pseudo/-/css-has-pseudo-6.0.5.tgz", + "integrity": "sha512-ZTv6RlvJJZKp32jPYnAJVhowDCrRrHUTAxsYSuUPBEDJjzws6neMnzkRblxtgmv1RgcV5dhH2gn7E3wA9Wt6lw==", "dev": true, - "license": "MIT", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { - "@babel/core": "^7.21.3", - "@svgr/babel-preset": "8.1.0", - "camelcase": "^6.2.0", - "cosmiconfig": "^8.1.3", - "snake-case": "^3.0.4" + "@csstools/selector-specificity": "^3.1.1", + "postcss-selector-parser": "^6.0.13", + "postcss-value-parser": "^4.2.0" }, "engines": { - "node": ">=14" + "node": "^14 || ^16 || >=18" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/@svgr/core/node_modules/cosmiconfig": { - "version": "8.3.6", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", - "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "node_modules/css-prefers-color-scheme": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/css-prefers-color-scheme/-/css-prefers-color-scheme-9.0.1.tgz", + "integrity": "sha512-iFit06ochwCKPRiWagbTa1OAWCvWWVdEnIFd8BaRrgO8YrrNh4RAWUQTFcYX5tdFZgFl1DJ3iiULchZyEbnF4g==", "dev": true, - "license": "MIT", - "dependencies": { - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0", - "path-type": "^4.0.0" - }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" + "node": "^14 || ^16 || >=18" }, "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "postcss": "^8.4" } }, - "node_modules/@svgr/hast-util-to-babel-ast": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", - "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", + "node_modules/cssdb": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.3.1.tgz", + "integrity": "sha512-XnDRQMXucLueX92yDe0LPKupXetWoFOgawr4O4X41l5TltgK2NVbJJVDnnOywDYfW1sTJ28AcXGKOqdRKwCcmQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/types": "^7.21.3", - "entities": "^4.4.0" + "bin": { + "cssesc": "bin/cssesc" }, "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" + "node": ">=4" } }, - "node_modules/@svgr/plugin-jsx": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", - "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", - "dev": true, - "license": "MIT", + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", "dependencies": { - "@babel/core": "^7.21.3", - "@svgr/babel-preset": "8.1.0", - "@svgr/hast-util-to-babel-ast": "8.0.0", - "svg-parser": "^2.0.4" + "d3-dispatch": "1 - 3", + "d3-selection": "3" }, "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@svgr/core": "*" + "node": ">=12" } }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" } }, - "node_modules/@types/babel__generator": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", - "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" } }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" } }, - "node_modules/@types/babel__traverse": { - "version": "7.20.6", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", - "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.20.7" + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" } }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } }, - "node_modules/@types/d3-drag": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", - "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", - "license": "MIT", + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", "dependencies": { - "@types/d3-selection": "*" + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" } }, - "node_modules/@types/d3-hierarchy": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", - "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", - "license": "MIT" + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "node_modules/datocms-plugin-sdk": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/datocms-plugin-sdk/-/datocms-plugin-sdk-2.0.13.tgz", + "integrity": "sha512-NU5Ghssnhnq+STCiGnNyVO5Hbv0gj2mX3mU0uHV2pZ1Xg5G1kGZiQnopz4Em4Y0sjxLysNkoAgIQ7T7cw7/Xrg==", "license": "MIT", "dependencies": { - "@types/d3-color": "*" + "@datocms/cma-client": "*", + "@types/react": "^17.0.3", + "datocms-structured-text-utils": "^2.0.0", + "penpal": "^4.1.1" } }, - "node_modules/@types/d3-selection": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", - "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", - "license": "MIT" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/d3-transition": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", - "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "node_modules/datocms-plugin-sdk/node_modules/@types/react": { + "version": "17.0.83", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.83.tgz", + "integrity": "sha512-l0m4ArKJvmFtR4e8UmKrj1pB4tUgOhJITf+mADyF/p69Ts1YAR/E+G9XEM0mHXKVRa1dQNHseyyDNzeuAXfXQw==", "license": "MIT", "dependencies": { - "@types/d3-selection": "*" + "@types/prop-types": "*", + "@types/scheduler": "^0.16", + "csstype": "^3.0.2" } }, - "node_modules/@types/d3-zoom": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", - "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "node_modules/datocms-plugin-sdk/node_modules/datocms-structured-text-utils": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/datocms-structured-text-utils/-/datocms-structured-text-utils-2.1.12.tgz", + "integrity": "sha512-tZtiPN/sEjl+4Z6igojPdap6Miy0Z6VO6Afor3vcyY/8PIwKVGbTsdd5trD+skWPCPRZVNzKpfYoAVziXWTL8Q==", "license": "MIT", "dependencies": { - "@types/d3-interpolate": "*", - "@types/d3-selection": "*" + "array-flatten": "^3.0.0" } }, - "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.17.28", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz", - "integrity": "sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig==", + "node_modules/datocms-react-ui": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/datocms-react-ui/-/datocms-react-ui-2.0.13.tgz", + "integrity": "sha512-PaYYNgv/ubRUplIN+bYqmqM7pSGyqlP7dAdAGy2jjM9rvJok2VwooPwKqclveeKko9X7RJAiS8wBem3WzSHNKA==", "license": "MIT", "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*" + "classnames": "^2.3.1", + "datocms-plugin-sdk": "^2.0.13", + "react-intersection-observer": "^8.31.0", + "react-select": "^5.2.1", + "scroll-into-view-if-needed": "^2.2.20" } }, - "node_modules/@types/gensync": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.4.tgz", - "integrity": "sha512-C3YYeRQWp2fmq9OryX+FoDy8nXS6scQ7dPptD8LnFDAUNcKWJjXQKDNJD3HVm+kOUsXhTOkpi69vI4EuAr95bA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.13.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz", - "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/@types/parse-json": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", - "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", - "license": "MIT" - }, - "node_modules/@types/prop-types": { - "version": "15.7.14", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", - "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", - "license": "MIT" - }, - "node_modules/@types/qs": { - "version": "6.9.18", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", - "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "license": "MIT" + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } }, - "node_modules/@types/react": { - "version": "18.3.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", - "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", "license": "MIT", "dependencies": { - "@types/prop-types": "*", + "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, - "node_modules/@types/react-dom": { - "version": "18.3.5", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz", - "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", "dev": true, "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" } }, - "node_modules/@types/react-transition-group": { - "version": "4.4.12", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", - "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*" - } + "node_modules/electron-to-chromium": { + "version": "1.5.97", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.97.tgz", + "integrity": "sha512-HKLtaH02augM7ZOdYRuO19rWDeY+QSJ1VxnXFa/XDFLf07HvM90pALIJFgrO+UVaajI3+aJMMpojoUTLZyQ7JQ==", + "dev": true, + "license": "ISC" }, - "node_modules/@types/scheduler": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", - "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", + "node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "license": "MIT" }, - "node_modules/@vitejs/plugin-react": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", - "integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==", + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.26.0", - "@babel/plugin-transform-react-jsx-self": "^7.25.9", - "@babel/plugin-transform-react-jsx-source": "^7.25.9", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.14.2" - }, + "license": "BSD-2-Clause", "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": ">=0.12" }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/@xyflow/react": { - "version": "12.4.2", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.4.2.tgz", - "integrity": "sha512-AFJKVc/fCPtgSOnRst3xdYJwiEcUN9lDY7EO/YiRvFHYCJGgfzg+jpvZjkTOnBLGyrMJre9378pRxAc3fsR06A==", + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", "license": "MIT", "dependencies": { - "@xyflow/system": "0.0.50", - "classcat": "^5.0.3", - "zustand": "^4.4.0" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" + "is-arrayish": "^0.2.1" } }, - "node_modules/@xyflow/system": { - "version": "0.0.50", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.50.tgz", - "integrity": "sha512-HVUZd4LlY88XAaldFh2nwVxDOcdIBxGpQ5txzwfJPf+CAjj2BfYug1fHs2p4yS7YO8H6A3EFJQovBE8YuHkAdg==", + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "@types/d3-drag": "^3.0.7", - "@types/d3-selection": "^3.0.10", - "@types/d3-transition": "^3.0.8", - "@types/d3-zoom": "^3.0.8", - "d3-drag": "^3.0.0", - "d3-selection": "^3.0.0", - "d3-zoom": "^3.0.0" + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, - "license": "Python-2.0" - }, - "node_modules/array-flatten": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-3.0.0.tgz", - "integrity": "sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==", - "license": "MIT" - }, - "node_modules/async-scheduler": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/async-scheduler/-/async-scheduler-1.4.4.tgz", - "integrity": "sha512-KVWlF6WKlUGJA8FOJYVVk7xDm3PxITWXp9hTeDsXMtUPvItQ2x6g2rIeNAa0TtAiiWvHJqhyA3wo+pj0rA7Ldg==", - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=6" + } }, - "node_modules/babel-plugin-macros": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", - "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5", - "cosmiconfig": "^7.0.0", - "resolve": "^1.19.0" - }, "engines": { - "node": ">=10", - "npm": ">=6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/browserslist": { - "version": "4.24.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", - "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "license": "MIT" + }, + "node_modules/final-form": { + "version": "4.20.10", + "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.10.tgz", + "integrity": "sha512-TL48Pi1oNHeMOHrKv1bCJUrWZDcD3DIG6AGYVNOnyZPr7Bd/pStN0pL+lfzF5BNoj/FclaoiaLenk4XUIFVYng==", "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" - }, - "bin": { - "browserslist": "cli.js" + "@babel/runtime": "^7.10.0" }, "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "node": ">=8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/final-form" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" }, - "node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" + "node": "*" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "patreon", + "url": "https://github.com/sponsors/rawify" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001699", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001699.tgz", - "integrity": "sha512-b+uH5BakXZ9Do9iK+CkDmctUSEqZl+SP056vc5usa0PL+ev5OHw003rZXcnjNDv3L8P5j6rwT6C0BPKSikW08w==", + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" ], - "license": "CC-BY-4.0" + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } }, - "node_modules/classcat": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", - "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", - "license": "MIT" + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "license": "MIT" + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } }, - "node_modules/compute-scroll-into-view": { - "version": "1.0.20", - "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", - "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==", - "license": "MIT" + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "node_modules/globals": { + "version": "15.14.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz", + "integrity": "sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/cosmiconfig": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", - "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" + "function-bind": "^1.1.2" }, "engines": { - "node": ">=10" + "node": ">= 0.4" } }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" } }, - "node_modules/d3-dispatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", - "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "license": "ISC", + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, "engines": { - "node": ">=12" + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/d3-drag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", - "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", - "license": "ISC", + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", "dependencies": { - "d3-dispatch": "1 - 3", - "d3-selection": "3" + "hasown": "^2.0.2" }, "engines": { - "node": ">=12" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, "engines": { - "node": ">=12" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/d3-hierarchy": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", - "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", - "license": "ISC", + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=12" + "node": ">=8" } }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", "dependencies": { - "d3-color": "1 - 3" + "is-docker": "^2.0.0" }, "engines": { - "node": ">=12" + "node": ">=8" } }, - "node_modules/d3-selection": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", - "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "license": "ISC", - "engines": { - "node": ">=12" + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, "engines": { - "node": ">=12" + "node": ">=6" } }, - "node_modules/d3-transition": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", - "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-dispatch": "1 - 3", - "d3-ease": "1 - 3", - "d3-interpolate": "1 - 3", - "d3-timer": "1 - 3" + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" }, "engines": { - "node": ">=12" + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" }, - "peerDependencies": { - "d3-selection": "2 - 3" + "bin": { + "loose-envify": "cli.js" } }, - "node_modules/d3-zoom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", - "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, "license": "ISC", "dependencies": { - "d3-dispatch": "1 - 3", - "d3-drag": "2 - 3", - "d3-interpolate": "1 - 3", - "d3-selection": "2 - 3", - "d3-transition": "2 - 3" + "yallist": "^3.0.2" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" }, "engines": { - "node": ">=12" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/datocms-plugin-sdk": { - "version": "2.0.13", - "resolved": "https://registry.npmjs.org/datocms-plugin-sdk/-/datocms-plugin-sdk-2.0.13.tgz", - "integrity": "sha512-NU5Ghssnhnq+STCiGnNyVO5Hbv0gj2mX3mU0uHV2pZ1Xg5G1kGZiQnopz4Em4Y0sjxLysNkoAgIQ7T7cw7/Xrg==", + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, "license": "MIT", "dependencies": { - "@datocms/cma-client": "*", - "@types/react": "^17.0.3", - "datocms-structured-text-utils": "^2.0.0", - "penpal": "^4.1.1" + "lower-case": "^2.0.2", + "tslib": "^2.0.3" } }, - "node_modules/datocms-plugin-sdk/node_modules/@types/react": { - "version": "17.0.83", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.83.tgz", - "integrity": "sha512-l0m4ArKJvmFtR4e8UmKrj1pB4tUgOhJITf+mADyF/p69Ts1YAR/E+G9XEM0mHXKVRa1dQNHseyyDNzeuAXfXQw==", + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "@types/scheduler": "^0.16", - "csstype": "^3.0.2" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/datocms-plugin-sdk/node_modules/datocms-structured-text-utils": { - "version": "2.1.12", - "resolved": "https://registry.npmjs.org/datocms-structured-text-utils/-/datocms-structured-text-utils-2.1.12.tgz", - "integrity": "sha512-tZtiPN/sEjl+4Z6igojPdap6Miy0Z6VO6Afor3vcyY/8PIwKVGbTsdd5trD+skWPCPRZVNzKpfYoAVziXWTL8Q==", + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "license": "MIT", - "dependencies": { - "array-flatten": "^3.0.0" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/datocms-react-ui": { - "version": "2.0.13", - "resolved": "https://registry.npmjs.org/datocms-react-ui/-/datocms-react-ui-2.0.13.tgz", - "integrity": "sha512-PaYYNgv/ubRUplIN+bYqmqM7pSGyqlP7dAdAGy2jjM9rvJok2VwooPwKqclveeKko9X7RJAiS8wBem3WzSHNKA==", + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, "license": "MIT", "dependencies": { - "classnames": "^2.3.1", - "datocms-plugin-sdk": "^2.0.13", - "react-intersection-observer": "^8.31.0", - "react-select": "^5.2.1", - "scroll-into-view-if-needed": "^2.2.20" + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "callsites": "^3.0.0" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=6" } }, - "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", - "dev": true, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" + "engines": { + "node": ">=8" } }, - "node_modules/electron-to-chromium": { - "version": "1.5.97", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.97.tgz", - "integrity": "sha512-HKLtaH02augM7ZOdYRuO19rWDeY+QSJ1VxnXFa/XDFLf07HvM90pALIJFgrO+UVaajI3+aJMMpojoUTLZyQ7JQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "node_modules/penpal": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/penpal/-/penpal-4.1.1.tgz", + "integrity": "sha512-6d1f8khVLyBz3DnhLztbfjJ7+ANxdXRM2l6awpnCdEtbrmse4AGTsELOvGuNY0SU7xZw7heGbP6IikVvaVTOWw==", "license": "MIT" }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "engines": { - "node": ">=0.12" + "node": ">=12" }, "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "node_modules/postcss": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.2.tgz", + "integrity": "sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "is-arrayish": "^0.2.1" + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" } }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "node_modules/postcss-attribute-case-insensitive": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-6.0.3.tgz", + "integrity": "sha512-KHkmCILThWBRtg+Jn1owTnHPnFit4OkqS+eKiGEOPIGke54DCeYGJ6r0Fx/HjfE9M9kznApCLcU0DvnPchazMQ==", "dev": true, - "hasInstallScript": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "dependencies": { + "postcss-selector-parser": "^6.0.13" }, "engines": { - "node": ">=12" + "node": "^14 || ^16 || >=18" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/postcss-clamp": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-clamp/-/postcss-clamp-4.1.0.tgz", + "integrity": "sha512-ry4b1Llo/9zz+PKC+030KUnPITTJAHeOwjfAyyB60eT0AorGLdzp52s31OsPRHRf8NchkgFoG2y6fCfn1IV1Ow==", "dev": true, "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, "engines": { - "node": ">=6" + "node": ">=7.6.0" + }, + "peerDependencies": { + "postcss": "^8.4.6" } }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "license": "MIT", + "node_modules/postcss-color-functional-notation": { + "version": "6.0.14", + "resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-6.0.14.tgz", + "integrity": "sha512-dNUX+UH4dAozZ8uMHZ3CtCNYw8fyFAmqqdcyxMr7PEdM9jLXV19YscoYO0F25KqZYhmtWKQ+4tKrIZQrwzwg7A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^2.0.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/utilities": "^1.0.0" + }, "engines": { - "node": ">=10" + "node": "^14 || ^16 || >=18" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "node_modules/postcss-color-hex-alpha": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/postcss-color-hex-alpha/-/postcss-color-hex-alpha-9.0.4.tgz", + "integrity": "sha512-XQZm4q4fNFqVCYMGPiBjcqDhuG7Ey2xrl99AnDJMyr5eDASsAGalndVgHZF8i97VFNy1GQeZc4q2ydagGmhelQ==", "dev": true, - "license": "MIT" - }, - "node_modules/final-form": { - "version": "4.20.10", - "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.10.tgz", - "integrity": "sha512-TL48Pi1oNHeMOHrKv1bCJUrWZDcD3DIG6AGYVNOnyZPr7Bd/pStN0pL+lfzF5BNoj/FclaoiaLenk4XUIFVYng==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "license": "MIT", "dependencies": { - "@babel/runtime": "^7.10.0" + "@csstools/utilities": "^1.0.0", + "postcss-value-parser": "^4.2.0" }, "engines": { - "node": ">=8" + "node": "^14 || ^16 || >=18" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/final-form" + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/find-root": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", - "license": "MIT" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/postcss-color-rebeccapurple": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/postcss-color-rebeccapurple/-/postcss-color-rebeccapurple-9.0.3.tgz", + "integrity": "sha512-ruBqzEFDYHrcVq3FnW3XHgwRqVMrtEPLBtD7K2YmsLKVc2jbkxzzNEctJKsPCpDZ+LeMHLKRDoSShVefGc+CkQ==", "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], + "license": "MIT-0", + "dependencies": { + "@csstools/utilities": "^1.0.0", + "postcss-value-parser": "^4.2.0" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "node_modules/postcss-custom-media": { + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-10.0.8.tgz", + "integrity": "sha512-V1KgPcmvlGdxTel4/CyQtBJEFhMVpEmRGFrnVtgfGIHj5PJX9vO36eFBxKBeJn+aCDTed70cc+98Mz3J/uVdGQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "license": "MIT", + "dependencies": { + "@csstools/cascade-layer-name-parser": "^1.0.13", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/media-query-list-parser": "^2.1.13" + }, "engines": { - "node": ">=6.9.0" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/globals": { - "version": "15.14.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz", - "integrity": "sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==", + "node_modules/postcss-custom-properties": { + "version": "13.3.12", + "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-13.3.12.tgz", + "integrity": "sha512-oPn/OVqONB2ZLNqN185LDyaVByELAA/u3l2CS2TS16x2j2XsmV4kd8U49+TMxmUsEU9d8fB/I10E6U7kB0L1BA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "license": "MIT", + "dependencies": { + "@csstools/cascade-layer-name-parser": "^1.0.13", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/utilities": "^1.0.0", + "postcss-value-parser": "^4.2.0" + }, "engines": { - "node": ">=18" + "node": "^14 || ^16 || >=18" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "node_modules/postcss-custom-selectors": { + "version": "7.1.12", + "resolved": "https://registry.npmjs.org/postcss-custom-selectors/-/postcss-custom-selectors-7.1.12.tgz", + "integrity": "sha512-ctIoprBMJwByYMGjXG0F7IT2iMF2hnamQ+aWZETyBM0aAlyaYdVZTeUkk8RB+9h9wP+NdN3f01lfvKl2ZSqC0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "license": "MIT", "dependencies": { - "function-bind": "^1.1.2" + "@csstools/cascade-layer-name-parser": "^1.0.13", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "postcss-selector-parser": "^6.1.0" }, "engines": { - "node": ">= 0.4" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "license": "BSD-3-Clause", + "node_modules/postcss-dir-pseudo-class": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/postcss-dir-pseudo-class/-/postcss-dir-pseudo-class-8.0.1.tgz", + "integrity": "sha512-uULohfWBBVoFiZXgsQA24JV6FdKIidQ+ZqxOouhWwdE+qJlALbkS5ScB43ZTjPK+xUZZhlaO/NjfCt5h4IKUfw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { - "react-is": "^16.7.0" + "postcss-selector-parser": "^6.0.13" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "license": "MIT", + "node_modules/postcss-double-position-gradients": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/postcss-double-position-gradients/-/postcss-double-position-gradients-5.0.7.tgz", + "integrity": "sha512-1xEhjV9u1s4l3iP5lRt1zvMjI/ya8492o9l/ivcxHhkO3nOz16moC4JpMxDUGrOs4R3hX+KWT7gKoV842cwRgg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/utilities": "^1.0.0", + "postcss-value-parser": "^4.2.0" }, "engines": { - "node": ">=6" + "node": "^14 || ^16 || >=18" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "license": "MIT" - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "license": "MIT", + "node_modules/postcss-focus-visible": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/postcss-focus-visible/-/postcss-focus-visible-9.0.1.tgz", + "integrity": "sha512-N2VQ5uPz3Z9ZcqI5tmeholn4d+1H14fKXszpjogZIrFbhaq0zNAtq8sAnw6VLiqGbL8YBzsnu7K9bBkTqaRimQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { - "hasown": "^2.0.2" + "postcss-selector-parser": "^6.0.13" }, "engines": { - "node": ">= 0.4" + "node": "^14 || ^16 || >=18" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "node_modules/postcss-focus-within": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/postcss-focus-within/-/postcss-focus-within-8.0.1.tgz", + "integrity": "sha512-NFU3xcY/xwNaapVb+1uJ4n23XImoC86JNwkY/uduytSl2s9Ekc2EpzmRR63+ExitnW3Mab3Fba/wRPCT5oDILA==", "dev": true, - "license": "MIT", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { - "argparse": "^2.0.1" + "postcss-selector-parser": "^6.0.13" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/postcss-font-variant": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/postcss-font-variant/-/postcss-font-variant-5.0.0.tgz", + "integrity": "sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA==", + "dev": true, "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" + "peerDependencies": { + "postcss": "^8.1.0" } }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT" + "node_modules/postcss-gap-properties": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postcss-gap-properties/-/postcss-gap-properties-5.0.1.tgz", + "integrity": "sha512-k2z9Cnngc24c0KF4MtMuDdToROYqGMMUQGcE6V0odwjHyOHtaDBlLeRBV70y9/vF7KIbShrTRZ70JjsI1BZyWw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/postcss-image-set-function": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-image-set-function/-/postcss-image-set-function-6.0.3.tgz", + "integrity": "sha512-i2bXrBYzfbRzFnm+pVuxVePSTCRiNmlfssGI4H0tJQvDue+yywXwUxe68VyzXs7cGtMaH6MCLY6IbCShrSroCw==", "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/utilities": "^1.0.0", + "postcss-value-parser": "^4.2.0" }, "engines": { - "node": ">=6" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" - }, - "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", + "node_modules/postcss-lab-function": { + "version": "6.0.19", + "resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-6.0.19.tgz", + "integrity": "sha512-vwln/mgvFrotJuGV8GFhpAOu9iGf3pvTBr6dLPDmUcqVD5OsQpEFyQMAFTxSxWXGEzBj6ld4pZ/9GDfEpXvo0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" + "@csstools/css-color-parser": "^2.0.4", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/utilities": "^1.0.0" }, - "bin": { - "loose-envify": "cli.js" + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "node_modules/postcss-logical": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-logical/-/postcss-logical-7.0.1.tgz", + "integrity": "sha512-8GwUQZE0ri0K0HJHkDv87XOLC8DE0msc+HoWLeKdtjDZEwpZ5xuK3QdV6FhmHSQW40LPkg43QzvATRAI3LsRkg==", "dev": true, - "license": "MIT", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { - "tslib": "^2.0.3" + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "node_modules/postcss-nesting": { + "version": "12.1.5", + "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-12.1.5.tgz", + "integrity": "sha512-N1NgI1PDCiAGWPTYrwqm8wpjv0bgDmkYHH72pNsqTCv9CObxjxftdYu6AKtGN+pnJa7FQjMm3v4sp8QJbFsYdQ==", "dev": true, - "license": "ISC", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { - "yallist": "^3.0.2" + "@csstools/selector-resolve-nested": "^1.1.0", + "@csstools/selector-specificity": "^3.1.1", + "postcss-selector-parser": "^6.1.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/memoize-one": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", - "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" + "node_modules/postcss-opacity-percentage": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postcss-opacity-percentage/-/postcss-opacity-percentage-2.0.0.tgz", + "integrity": "sha512-lyDrCOtntq5Y1JZpBFzIWm2wG9kbEdujpNt4NLannF+J9c8CgFIzPa80YQfdza+Y+yFfzbYj/rfoOsYsooUWTQ==", + "dev": true, + "funding": [ + { + "type": "kofi", + "url": "https://ko-fi.com/mrcgrtz" + }, + { + "type": "liberapay", + "url": "https://liberapay.com/mrcgrtz" + } + ], + "license": "MIT", + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.2" + } }, - "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "node_modules/postcss-overflow-shorthand": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postcss-overflow-shorthand/-/postcss-overflow-shorthand-5.0.1.tgz", + "integrity": "sha512-XzjBYKLd1t6vHsaokMV9URBt2EwC9a7nDhpQpjoPk2HRTSQfokPfyAS/Q7AOrzUu6q+vp/GnrDBGuj/FCaRqrQ==", "dev": true, "funding": [ { "type": "github", - "url": "https://github.com/sponsors/ai" + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" } ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" + "license": "MIT-0", + "dependencies": { + "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/no-case": { + "node_modules/postcss-page-break": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "resolved": "https://registry.npmjs.org/postcss-page-break/-/postcss-page-break-3.0.4.tgz", + "integrity": "sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ==", "dev": true, "license": "MIT", - "dependencies": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" + "peerDependencies": { + "postcss": "^8" } }, - "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "node_modules/postcss-place": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/postcss-place/-/postcss-place-9.0.1.tgz", + "integrity": "sha512-JfL+paQOgRQRMoYFc2f73pGuG/Aw3tt4vYMR6UA3cWVMxivviPTnMFnFTczUJOA4K2Zga6xgQVE+PcLs64WC8Q==", "dev": true, - "license": "MIT" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, "engines": { - "node": ">=0.10.0" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "license": "MIT", + "node_modules/postcss-preset-env": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-9.6.0.tgz", + "integrity": "sha512-Lxfk4RYjUdwPCYkc321QMdgtdCP34AeI94z+/8kVmqnTIlD4bMRQeGcMZgwz8BxHrzQiFXYIR5d7k/9JMs2MEA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { - "callsites": "^3.0.0" + "@csstools/postcss-cascade-layers": "^4.0.6", + "@csstools/postcss-color-function": "^3.0.19", + "@csstools/postcss-color-mix-function": "^2.0.19", + "@csstools/postcss-content-alt-text": "^1.0.0", + "@csstools/postcss-exponential-functions": "^1.0.9", + "@csstools/postcss-font-format-keywords": "^3.0.2", + "@csstools/postcss-gamut-mapping": "^1.0.11", + "@csstools/postcss-gradients-interpolation-method": "^4.0.20", + "@csstools/postcss-hwb-function": "^3.0.18", + "@csstools/postcss-ic-unit": "^3.0.7", + "@csstools/postcss-initial": "^1.0.1", + "@csstools/postcss-is-pseudo-class": "^4.0.8", + "@csstools/postcss-light-dark-function": "^1.0.8", + "@csstools/postcss-logical-float-and-clear": "^2.0.1", + "@csstools/postcss-logical-overflow": "^1.0.1", + "@csstools/postcss-logical-overscroll-behavior": "^1.0.1", + "@csstools/postcss-logical-resize": "^2.0.1", + "@csstools/postcss-logical-viewport-units": "^2.0.11", + "@csstools/postcss-media-minmax": "^1.1.8", + "@csstools/postcss-media-queries-aspect-ratio-number-values": "^2.0.11", + "@csstools/postcss-nested-calc": "^3.0.2", + "@csstools/postcss-normalize-display-values": "^3.0.2", + "@csstools/postcss-oklab-function": "^3.0.19", + "@csstools/postcss-progressive-custom-properties": "^3.3.0", + "@csstools/postcss-relative-color-syntax": "^2.0.19", + "@csstools/postcss-scope-pseudo-class": "^3.0.1", + "@csstools/postcss-stepped-value-functions": "^3.0.10", + "@csstools/postcss-text-decoration-shorthand": "^3.0.7", + "@csstools/postcss-trigonometric-functions": "^3.0.10", + "@csstools/postcss-unset-value": "^3.0.1", + "autoprefixer": "^10.4.19", + "browserslist": "^4.23.1", + "css-blank-pseudo": "^6.0.2", + "css-has-pseudo": "^6.0.5", + "css-prefers-color-scheme": "^9.0.1", + "cssdb": "^8.1.0", + "postcss-attribute-case-insensitive": "^6.0.3", + "postcss-clamp": "^4.1.0", + "postcss-color-functional-notation": "^6.0.14", + "postcss-color-hex-alpha": "^9.0.4", + "postcss-color-rebeccapurple": "^9.0.3", + "postcss-custom-media": "^10.0.8", + "postcss-custom-properties": "^13.3.12", + "postcss-custom-selectors": "^7.1.12", + "postcss-dir-pseudo-class": "^8.0.1", + "postcss-double-position-gradients": "^5.0.7", + "postcss-focus-visible": "^9.0.1", + "postcss-focus-within": "^8.0.1", + "postcss-font-variant": "^5.0.0", + "postcss-gap-properties": "^5.0.1", + "postcss-image-set-function": "^6.0.3", + "postcss-lab-function": "^6.0.19", + "postcss-logical": "^7.0.1", + "postcss-nesting": "^12.1.5", + "postcss-opacity-percentage": "^2.0.0", + "postcss-overflow-shorthand": "^5.0.1", + "postcss-page-break": "^3.0.4", + "postcss-place": "^9.0.1", + "postcss-pseudo-class-any-link": "^9.0.2", + "postcss-replace-overflow-wrap": "^4.0.0", + "postcss-selector-not": "^7.0.2" }, "engines": { - "node": ">=6" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "license": "MIT", + "node_modules/postcss-pseudo-class-any-link": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/postcss-pseudo-class-any-link/-/postcss-pseudo-class-any-link-9.0.2.tgz", + "integrity": "sha512-HFSsxIqQ9nA27ahyfH37cRWGk3SYyQLpk0LiWw/UGMV4VKT5YG2ONee4Pz/oFesnK0dn2AjcyequDbIjKJgB0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" + "postcss-selector-parser": "^6.0.13" }, "engines": { - "node": ">=8" + "node": "^14 || ^16 || >=18" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "postcss": "^8.4" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT" - }, - "node_modules/path-type": { + "node_modules/postcss-replace-overflow-wrap": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/penpal": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/penpal/-/penpal-4.1.1.tgz", - "integrity": "sha512-6d1f8khVLyBz3DnhLztbfjJ7+ANxdXRM2l6awpnCdEtbrmse4AGTsELOvGuNY0SU7xZw7heGbP6IikVvaVTOWw==", - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "resolved": "https://registry.npmjs.org/postcss-replace-overflow-wrap/-/postcss-replace-overflow-wrap-4.0.0.tgz", + "integrity": "sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "peerDependencies": { + "postcss": "^8.0.3" } }, - "node_modules/postcss": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.2.tgz", - "integrity": "sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA==", + "node_modules/postcss-selector-not": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-selector-not/-/postcss-selector-not-7.0.2.tgz", + "integrity": "sha512-/SSxf/90Obye49VZIfc0ls4H0P6i6V1iHv0pzZH8SdgvZOPFkF37ef1r5cyWcMflJSFJ5bfuoluTnFnBBFiuSA==", "dev": true, "funding": [ { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" + "type": "github", + "url": "https://github.com/sponsors/csstools" }, { - "type": "github", - "url": "https://github.com/sponsors/ai" + "type": "opencollective", + "url": "https://opencollective.com/csstools" } ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "postcss-selector-parser": "^6.0.13" }, "engines": { - "node": "^10 || ^12 || >=14" + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -2761,6 +5011,16 @@ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", "license": "MIT" }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -2829,6 +5089,47 @@ "fsevents": "~2.3.2" } }, + "node_modules/rollup-plugin-visualizer": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-5.14.0.tgz", + "integrity": "sha512-VlDXneTDaKsHIw8yzJAFWtrzguoJ/LnQ+lMpoVfYJ3jJF4Ihe5oYLAqLklIK/35lgUY+1yEzCkHyZ1j4A5w5fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "open": "^8.4.0", + "picomatch": "^4.0.2", + "source-map": "^0.7.4", + "yargs": "^17.5.1" + }, + "bin": { + "rollup-plugin-visualizer": "dist/bin/cli.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "rolldown": "1.x", + "rollup": "2.x || 3.x || 4.x" + }, + "peerDependenciesMeta": { + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -2887,6 +5188,41 @@ "node": ">=0.10.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -3005,6 +5341,13 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -3093,6 +5436,34 @@ "vite": ">=2.6.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -3109,6 +5480,35 @@ "node": ">= 6" } }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/zustand": { "version": "4.5.6", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.6.tgz", diff --git a/import-export-schema/package.json b/import-export-schema/package.json index cb45aa37..31bb3f14 100644 --- a/import-export-schema/package.json +++ b/import-export-schema/package.json @@ -3,7 +3,7 @@ "description": "Export one or multiple models/block models as JSON files for reimport into another DatoCMS project.", "homepage": "https://github.com/datocms/plugins", "private": false, - "version": "0.1.14", + "version": "0.1.15", "author": "Stefano Verna ", "type": "module", "keywords": [ @@ -26,11 +26,13 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", + "analyze": "ANALYZE=true npm run build", "prepublishOnly": "npm run build", "format": "npx @biomejs/biome check --unsafe --write ." }, "dependencies": { "@datocms/cma-client": "^3.4.5", + "@datocms/cma-client-browser": "^3.4.5", "@datocms/rest-api-events": "^3.4.3", "@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", @@ -51,14 +53,17 @@ "ts-easing": "^0.2.0" }, "devDependencies": { + "@biomejs/biome": "^2.2.0", "@types/d3-timer": "^3.0.2", "@types/node": "^22.13.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", "globals": "^15.9.0", + "postcss-preset-env": "^9.6.0", "typescript": "^5.5.3", "vite": "^5.4.1", - "vite-plugin-svgr": "^4.3.0" + "vite-plugin-svgr": "^4.3.0", + "rollup-plugin-visualizer": "^5.12.0" } } diff --git a/import-export-schema/postcss.config.cjs b/import-export-schema/postcss.config.cjs new file mode 100644 index 00000000..393892c4 --- /dev/null +++ b/import-export-schema/postcss.config.cjs @@ -0,0 +1,12 @@ +// Enable modern CSS features (including native nesting) for broader browser support +// without changing authoring style. +module.exports = { + plugins: [ + require('postcss-preset-env')({ + stage: 1, + features: { + 'nesting-rules': true, + }, + }), + ], +}; diff --git a/import-export-schema/src/components/Field.tsx b/import-export-schema/src/components/Field.tsx index c17db0cd..3b9dcbdb 100644 --- a/import-export-schema/src/components/Field.tsx +++ b/import-export-schema/src/components/Field.tsx @@ -1,9 +1,9 @@ +import type { SchemaTypes } from '@datocms/cma-client'; import { fieldGroupColors, fieldTypeDescriptions, fieldTypeGroups, } from '@/utils/datocms/schema'; -import type { SchemaTypes } from '@datocms/cma-client'; export function Field({ field }: { field: SchemaTypes.Field }) { const group = fieldTypeGroups.find((g) => diff --git a/import-export-schema/src/components/FieldsAndFieldsetsSummary.tsx b/import-export-schema/src/components/FieldsAndFieldsetsSummary.tsx new file mode 100644 index 00000000..93ce19d4 --- /dev/null +++ b/import-export-schema/src/components/FieldsAndFieldsetsSummary.tsx @@ -0,0 +1,115 @@ +import type { SchemaTypes } from '@datocms/cma-client'; +import { Button } from 'datocms-react-ui'; +import { useState } from 'react'; + +type Props = { + fields: SchemaTypes.Field[]; + fieldsets: SchemaTypes.Fieldset[]; + initialFields?: number; + initialFieldsets?: number; +}; + +export default function FieldsAndFieldsetsSummary({ + fields, + fieldsets, + initialFields = 10, + initialFieldsets = 6, +}: Props) { + const [fieldLimit, setFieldLimit] = useState(initialFields); + const [fieldsetLimit, setFieldsetLimit] = useState(initialFieldsets); + + return ( +
+
+ {fields.length} fields • {fieldsets.length} fieldsets +
+ {fields.length > 0 && ( +
+
Fields
+
    + {fields.slice(0, fieldLimit).map((f) => { + const label = f.attributes.label || f.attributes.api_key; + return ( +
  • + + {label}{' '} + + ({f.attributes.api_key}) + + +
  • + ); + })} +
+ {fields.length > initialFields && ( +
+ +
+ )} +
+ )} + + {fieldsets.length > 0 && ( +
+
Fieldsets
+
    + {fieldsets.slice(0, fieldsetLimit).map((fs) => ( +
  • + + {fs.attributes.title} + +
  • + ))} +
+ {fieldsets.length > initialFieldsets && ( +
+ +
+ )} +
+ )} +
+ ); +} diff --git a/import-export-schema/src/components/GraphCanvas.tsx b/import-export-schema/src/components/GraphCanvas.tsx new file mode 100644 index 00000000..03afa878 --- /dev/null +++ b/import-export-schema/src/components/GraphCanvas.tsx @@ -0,0 +1,40 @@ +import type { NodeMouseHandler, NodeTypes } from '@xyflow/react'; +import { Background, ReactFlow } from '@xyflow/react'; +import type { AppNode, Graph } from '@/utils/graph/types'; + +type Props = { + graph: Graph; + nodeTypes: NodeTypes; + edgeTypes: Parameters[0]['edgeTypes']; + onNodeClick?: NodeMouseHandler | NodeMouseHandler; + style?: React.CSSProperties; + fitView?: boolean; +}; + +export function GraphCanvas({ + graph, + nodeTypes, + edgeTypes, + onNodeClick, + style, + fitView = true, +}: Props) { + return ( + + + + ); +} diff --git a/import-export-schema/src/components/ItemTypeNodeRenderer.tsx b/import-export-schema/src/components/ItemTypeNodeRenderer.tsx index b091e8fd..9016577b 100644 --- a/import-export-schema/src/components/ItemTypeNodeRenderer.tsx +++ b/import-export-schema/src/components/ItemTypeNodeRenderer.tsx @@ -1,4 +1,3 @@ -import { Schema } from '@/utils/icons'; import type { SchemaTypes } from '@datocms/cma-client'; import { Handle, @@ -12,6 +11,7 @@ import { import classNames from 'classnames'; import { sortBy } from 'lodash-es'; import { useState } from 'react'; +import { Schema } from '@/utils/icons'; import { Field } from '../components/Field'; export type ItemTypeNode = Node< @@ -26,7 +26,10 @@ export type ItemTypeNode = Node< function Fieldset({ fieldset, allFields, -}: { fieldset: SchemaTypes.Fieldset; allFields: SchemaTypes.Field[] }) { +}: { + fieldset: SchemaTypes.Fieldset; + allFields: SchemaTypes.Field[]; +}) { return (
{fieldset.attributes.title}
@@ -36,7 +39,7 @@ function Fieldset({ (f) => f.relationships.fieldset.data?.id === fieldset.id, ), 'attributes.position', - ).map((field) => ( + ).map((field: SchemaTypes.Field) => ( ))}
@@ -72,27 +75,26 @@ export function ItemTypeNodeRenderer({ isVisible={isTooltipVisible} className="tooltip" > - {fields.length + fieldsets.length === 0 ? ( - <>No fields - ) : ( - sortBy( - [ - ...fields.filter((e) => !e.relationships.fieldset.data), - ...fieldsets, - ], - 'attributes.position', - ).map((fieldOrFieldset) => - fieldOrFieldset.type === 'field' ? ( - - ) : ( -
- ), - ) - )} + {fields.length + fieldsets.length === 0 + ? 'No fields' + : sortBy( + [ + ...fields.filter((e) => !e.relationships.fieldset.data), + ...fieldsets, + ], + 'attributes.position', + ).map( + (fieldOrFieldset: SchemaTypes.Field | SchemaTypes.Fieldset) => + fieldOrFieldset.type === 'field' ? ( + + ) : ( +
+ ), + )}
setTooltipVisible(true)} onMouseLeave={() => setTooltipVisible(false)} > diff --git a/import-export-schema/src/components/PluginNodeRenderer.tsx b/import-export-schema/src/components/PluginNodeRenderer.tsx index c34c5e90..fe327e72 100644 --- a/import-export-schema/src/components/PluginNodeRenderer.tsx +++ b/import-export-schema/src/components/PluginNodeRenderer.tsx @@ -1,4 +1,3 @@ -import { Schema } from '@/utils/icons'; import type { SchemaTypes } from '@datocms/cma-client'; import { Handle, @@ -9,6 +8,7 @@ import { useStore, } from '@xyflow/react'; import classNames from 'classnames'; +import { Schema } from '@/utils/icons'; export type PluginNode = Node< { diff --git a/import-export-schema/src/components/ProgressStallNotice.tsx b/import-export-schema/src/components/ProgressStallNotice.tsx new file mode 100644 index 00000000..754a4c42 --- /dev/null +++ b/import-export-schema/src/components/ProgressStallNotice.tsx @@ -0,0 +1,64 @@ +import { useEffect, useRef, useState } from 'react'; + +type Props = { + // Current completed unit (eg. done/finished). Pass undefined to disable. + current: number | undefined; + // Minimum time without progress before showing the notice. + thresholdMs?: number; + // Optional custom message. + message?: string; +}; + +export default function ProgressStallNotice({ + current, + thresholdMs = 8000, + message = 'We made too many requests in a short time. Progress may look paused and should resume automatically in a few seconds.', +}: Props) { + const [stalled, setStalled] = useState(false); + const lastValueRef = useRef(undefined); + const lastChangeAtRef = useRef(undefined); + const hasStartedRef = useRef(false); + + // Track progress changes + useEffect(() => { + if (typeof current !== 'number') { + // Reset when disabled/hidden + lastValueRef.current = undefined; + lastChangeAtRef.current = undefined; + hasStartedRef.current = false; + setStalled(false); + return; + } + + if (lastValueRef.current !== current) { + lastValueRef.current = current; + lastChangeAtRef.current = Date.now(); + if (current > 0) { + hasStartedRef.current = true; + } + // Any change clears a stall + setStalled(false); + } + }, [current]); + + // Timer to detect stalls + useEffect(() => { + const id = window.setInterval(() => { + if (!hasStartedRef.current) return; // don't warn before any real progress + if (typeof lastChangeAtRef.current !== 'number') return; + const since = Date.now() - lastChangeAtRef.current; + if (since >= thresholdMs) { + setStalled(true); + } + }, 500); + return () => window.clearInterval(id); + }, [thresholdMs]); + + if (!stalled) return null; + + return ( +
+ {message} +
+ ); +} diff --git a/import-export-schema/src/entrypoints/Config/index.tsx b/import-export-schema/src/entrypoints/Config/index.tsx index 9241a9bf..6ac8176e 100644 --- a/import-export-schema/src/entrypoints/Config/index.tsx +++ b/import-export-schema/src/entrypoints/Config/index.tsx @@ -24,7 +24,8 @@ function Link({ href, children }: { href: string; children: ReactNode }) { export function Config({ ctx }: Props) { const schemaUrl = `${ctx.isEnvironmentPrimary ? '' : `/environments/${ctx.environment}`}/schema`; - const pageUrl = `${ctx.isEnvironmentPrimary ? '' : `/environments/${ctx.environment}`}/configuration/p/${ctx.plugin.id}/pages/import-export`; + const importUrl = `${ctx.isEnvironmentPrimary ? '' : `/environments/${ctx.environment}`}/configuration/p/${ctx.plugin.id}/pages/import`; + const exportUrl = `${ctx.isEnvironmentPrimary ? '' : `/environments/${ctx.environment}`}/configuration/p/${ctx.plugin.id}/pages/export`; return ( @@ -41,12 +42,18 @@ export function Config({ ctx }: Props) {
  • - Need to import some models/blocks from an already generated export? - Go to the{' '} - - Schema > Import/Export section + To import models/blocks from an exported JSON, go to the{' '} + + Schema > Import {' '} - on the sidebar. + page in the sidebar. +
  • +
  • + To export a selection or the entire schema, go to the{' '} + + Schema > Export + {' '} + page.
  • diff --git a/import-export-schema/src/entrypoints/ExportHome/index.tsx b/import-export-schema/src/entrypoints/ExportHome/index.tsx new file mode 100644 index 00000000..022b97a7 --- /dev/null +++ b/import-export-schema/src/entrypoints/ExportHome/index.tsx @@ -0,0 +1,653 @@ +import type { SchemaTypes } from '@datocms/cma-client'; +import { ReactFlowProvider } from '@xyflow/react'; +import type { RenderPageCtx } from 'datocms-plugin-sdk'; +import { Button, Canvas, SelectField } from 'datocms-react-ui'; +import { useEffect, useId, useMemo, useRef, useState } from 'react'; +import type { GroupBase } from 'react-select'; +import ProgressStallNotice from '@/components/ProgressStallNotice'; +import { createCmaClient } from '@/utils/createCmaClient'; +import { downloadJSON } from '@/utils/downloadJson'; +import { ProjectSchema } from '@/utils/ProjectSchema'; +import type { ExportDoc } from '@/utils/types'; +import buildExportDoc from '../ExportPage/buildExportDoc'; +import ExportInner from '../ExportPage/Inner'; +import PostExportSummary from '../ExportPage/PostExportSummary'; + +type Props = { + ctx: RenderPageCtx; +}; + +export default function ExportHome({ ctx }: Props) { + const exportInitialSelectId = useId(); + const client = useMemo( + () => createCmaClient(ctx), + [ctx.currentUserAccessToken, ctx.environment], + ); + + const projectSchema = useMemo(() => new ProjectSchema(client), [client]); + + const [adminDomain, setAdminDomain] = useState(); + useEffect(() => { + let active = true; + (async () => { + try { + const site = await client.site.find(); + const domain = site.internal_domain || site.domain || undefined; + if (active) setAdminDomain(domain); + } catch { + // ignore; links will simply not be shown + } + })(); + return () => { + active = false; + }; + }, [client]); + + const [allItemTypes, setAllItemTypes] = useState< + SchemaTypes.ItemType[] | undefined + >(undefined); + useEffect(() => { + async function load() { + const types = await projectSchema.getAllItemTypes(); + setAllItemTypes(types); + } + load(); + }, [projectSchema]); + + const [exportInitialItemTypeIds, setExportInitialItemTypeIds] = useState< + string[] + >([]); + const [exportInitialItemTypes, setExportInitialItemTypes] = useState< + SchemaTypes.ItemType[] + >([]); + useEffect(() => { + async function resolveInitial() { + if (!exportInitialItemTypeIds.length) { + setExportInitialItemTypes([]); + return; + } + const list: SchemaTypes.ItemType[] = []; + for (const id of exportInitialItemTypeIds) { + list.push(await projectSchema.getItemTypeById(id)); + } + setExportInitialItemTypes(list); + } + resolveInitial(); + // using join to keep deps simple + }, [exportInitialItemTypeIds.join('-'), projectSchema]); + + const [exportStarted, setExportStarted] = useState(false); + const [postExportDoc, setPostExportDoc] = useState( + undefined, + ); + + const [exportAllBusy, setExportAllBusy] = useState(false); + const [exportAllProgress, setExportAllProgress] = useState< + { done: number; total: number; label: string } | undefined + >(undefined); + const [exportAllCancelled, setExportAllCancelled] = useState(false); + const exportAllCancelRef = useRef(false); + + const [exportPreparingBusy, setExportPreparingBusy] = useState(false); + const [exportPreparingProgress, setExportPreparingProgress] = useState< + { done: number; total: number; label: string } | undefined + >(undefined); + // Smoothed percent for preparing overlay to avoid jitter and changing max + const [exportPreparingPercent, setExportPreparingPercent] = useState(0.1); + + const [exportSelectionBusy, setExportSelectionBusy] = useState(false); + const [exportSelectionProgress, setExportSelectionProgress] = useState< + { done: number; total: number; label: string } | undefined + >(undefined); + const [exportSelectionCancelled, setExportSelectionCancelled] = + useState(false); + const exportSelectionCancelRef = useRef(false); + + return ( + + +
    +
    +
    + {postExportDoc ? ( + + downloadJSON(postExportDoc, { + fileName: 'export.json', + prettify: true, + }) + } + onClose={() => { + setPostExportDoc(undefined); + setExportStarted(false); + setExportInitialItemTypeIds([]); + setExportInitialItemTypes([]); + }} + /> + ) : !exportStarted ? ( +
    +
    + Start a new export +
    +
    +

    + Select one or more models/blocks to start selecting what + to export. +

    +
    +
    + + > + id={exportInitialSelectId} + name="export-initial-model" + label="Starting models/blocks" + selectInputProps={{ + isMulti: true, + isClearable: true, + isDisabled: !allItemTypes, + options: + allItemTypes?.map((it) => ({ + value: it.id, + label: `${it.attributes.name}${it.attributes.modular_block ? ' (Block)' : ''}`, + })) ?? [], + placeholder: 'Choose models/blocks…', + }} + value={ + allItemTypes + ? allItemTypes + .map((it) => ({ + value: it.id, + label: `${it.attributes.name}${it.attributes.modular_block ? ' (Block)' : ''}`, + })) + .filter((opt) => + exportInitialItemTypeIds.includes( + opt.value, + ), + ) + : [] + } + onChange={(options) => + setExportInitialItemTypeIds( + Array.isArray(options) + ? options.map((o) => o.value) + : [], + ) + } + /> +
    +
    + + +
    +
    + +
    +
    + +
    +
    +
    +
    + ) : ( + setExportPreparingBusy(false)} + onPrepareProgress={(p) => { + // ensure overlay shows determinate progress + setExportPreparingBusy(true); + setExportPreparingProgress(p); + const hasFixedTotal = (p.total ?? 0) > 0; + const raw = hasFixedTotal ? p.done / p.total : 0; + if (!hasFixedTotal) { + // Indeterminate scanning: gently advance up to 25% + setExportPreparingPercent((prev) => + Math.min(0.25, Math.max(prev, prev + 0.02)), + ); + } else { + // Determinate build: map to [0.25, 1] + const mapped = 0.25 + raw * 0.75; + setExportPreparingPercent((prev) => + Math.max(prev, Math.min(1, mapped)), + ); + } + }} + onClose={() => { + // Return to selection screen with current picks preserved + setExportStarted(false); + }} + onExport={async (itemTypeIds, pluginIds) => { + try { + setExportSelectionBusy(true); + setExportSelectionProgress(undefined); + setExportSelectionCancelled(false); + exportSelectionCancelRef.current = false; + + const total = pluginIds.length + itemTypeIds.length * 2; + setExportSelectionProgress({ + done: 0, + total, + label: 'Preparing export…', + }); + let done = 0; + + const exportDoc = await buildExportDoc( + projectSchema, + exportInitialItemTypeIds[0], + itemTypeIds, + pluginIds, + { + onProgress: (label: string) => { + done += 1; + setExportSelectionProgress({ done, total, label }); + }, + shouldCancel: () => exportSelectionCancelRef.current, + }, + ); + + if (exportSelectionCancelRef.current) { + throw new Error('Export cancelled'); + } + + downloadJSON(exportDoc, { + fileName: 'export.json', + prettify: true, + }); + setPostExportDoc(exportDoc); + ctx.notice('Export completed with success!'); + } catch (e) { + console.error('Selection export failed', e); + if ( + e instanceof Error && + e.message === 'Export cancelled' + ) { + ctx.notice('Export canceled'); + } else { + ctx.alert( + 'Could not complete the export. Please try again.', + ); + } + } finally { + setExportSelectionBusy(false); + setExportSelectionProgress(undefined); + setExportSelectionCancelled(false); + exportSelectionCancelRef.current = false; + } + }} + /> + )} +
    +
    +
    +
    + + {/* Blocking overlay while exporting all */} + {exportAllBusy && ( +
    +
    +
    Exporting entire schema
    +
    + Sit tight, we’re gathering models, blocks, and plugins… +
    + +
    +
    +
    +
    +
    + {exportAllProgress + ? exportAllProgress.label + : 'Loading project schema…'} +
    +
    + {exportAllProgress + ? `${exportAllProgress.done} / ${exportAllProgress.total}` + : ''} +
    +
    + +
    + +
    +
    +
    + )} + + {/* Overlay while preparing the Export selection view */} + {exportPreparingBusy && ( +
    +
    +
    Preparing export
    +
    + Sit tight, we’re setting up your models, blocks, and plugins… +
    + +
    0 + ? exportPreparingProgress.total + : undefined + } + aria-valuenow={ + exportPreparingProgress && + (exportPreparingProgress.total ?? 0) > 0 + ? exportPreparingProgress.done + : undefined + } + > +
    +
    +
    +
    + {exportPreparingProgress + ? exportPreparingProgress.label + : 'Preparing export…'} +
    +
    + {exportPreparingProgress && + (exportPreparingProgress.total ?? 0) > 0 + ? `${exportPreparingProgress.done} / ${exportPreparingProgress.total}` + : ''} +
    +
    + +
    +
    + )} + + {/* Overlay during selection export */} + {exportSelectionBusy && ( +
    +
    +
    Exporting selection
    +
    + Sit tight, we’re gathering models, blocks, and plugins… +
    + +
    +
    +
    +
    +
    + {exportSelectionProgress + ? exportSelectionProgress.label + : 'Preparing export…'} +
    +
    + {exportSelectionProgress + ? `${exportSelectionProgress.done} / ${exportSelectionProgress.total}` + : ''} +
    +
    + +
    + +
    +
    +
    + )} + + ); +} diff --git a/import-export-schema/src/entrypoints/ExportPage/ExportItemTypeNodeRenderer.tsx b/import-export-schema/src/entrypoints/ExportPage/ExportItemTypeNodeRenderer.tsx index 45427e23..c63fae55 100644 --- a/import-export-schema/src/entrypoints/ExportPage/ExportItemTypeNodeRenderer.tsx +++ b/import-export-schema/src/entrypoints/ExportPage/ExportItemTypeNodeRenderer.tsx @@ -1,25 +1,24 @@ +import type { NodeProps } from '@xyflow/react'; +import { useContext } from 'react'; import { type ItemTypeNode, ItemTypeNodeRenderer, } from '@/components/ItemTypeNodeRenderer'; import { EntitiesToExportContext } from '@/entrypoints/ExportPage/EntitiesToExportContext'; -import type { NodeProps } from '@xyflow/react'; -import { useContext } from 'react'; export function ExportItemTypeNodeRenderer(props: NodeProps) { const { itemType } = props.data; const entitiesToExport = useContext(EntitiesToExportContext); + const excluded = + entitiesToExport && !entitiesToExport.itemTypeIds.includes(itemType.id); + return ( ); } diff --git a/import-export-schema/src/entrypoints/ExportPage/ExportPluginNodeRenderer.tsx b/import-export-schema/src/entrypoints/ExportPage/ExportPluginNodeRenderer.tsx index f73c959a..f498b271 100644 --- a/import-export-schema/src/entrypoints/ExportPage/ExportPluginNodeRenderer.tsx +++ b/import-export-schema/src/entrypoints/ExportPage/ExportPluginNodeRenderer.tsx @@ -1,11 +1,11 @@ +import type { NodeProps } from '@xyflow/react'; +import classNames from 'classnames'; +import { useContext } from 'react'; import { type PluginNode, PluginNodeRenderer, } from '@/components/PluginNodeRenderer'; import { EntitiesToExportContext } from '@/entrypoints/ExportPage/EntitiesToExportContext'; -import type { NodeProps } from '@xyflow/react'; -import classNames from 'classnames'; -import { useContext } from 'react'; export function ExportPluginNodeRenderer(props: NodeProps) { const { plugin } = props.data; @@ -18,7 +18,7 @@ export function ExportPluginNodeRenderer(props: NodeProps) { className={classNames( entitiesToExport && !entitiesToExport.pluginIds.includes(plugin.id) && - 'app-node__excluded-from-export', + 'app-node--excluded', )} /> ); diff --git a/import-export-schema/src/entrypoints/ExportPage/ExportSchema.ts b/import-export-schema/src/entrypoints/ExportPage/ExportSchema.ts index 3c153379..04ae67d5 100644 --- a/import-export-schema/src/entrypoints/ExportPage/ExportSchema.ts +++ b/import-export-schema/src/entrypoints/ExportPage/ExportSchema.ts @@ -1,11 +1,12 @@ +import type { SchemaTypes } from '@datocms/cma-client'; +import { get } from 'lodash-es'; import { findLinkedItemTypeIds } from '@/utils/datocms/schema'; import { isDefined } from '@/utils/isDefined'; import type { ExportDoc } from '@/utils/types'; -import type { SchemaTypes } from '@datocms/cma-client'; -import { get } from 'lodash-es'; export class ExportSchema { public rootItemType: SchemaTypes.ItemType; + public rootItemTypes: SchemaTypes.ItemType[]; public itemTypesById: Map; public pluginsById: Map; public fieldsById: Map; @@ -16,55 +17,77 @@ export class ExportSchema { for (const itemType of exportDoc.entities.filter( (e): e is SchemaTypes.ItemType => e.type === 'item_type', )) { - this.itemTypesById.set(itemType.id, itemType); + // Normalize ID to string to avoid number/string key mismatches + this.itemTypesById.set(String(itemType.id), itemType); } this.pluginsById = new Map(); for (const plugin of exportDoc.entities.filter( (e): e is SchemaTypes.Plugin => e.type === 'plugin', )) { - this.pluginsById.set(plugin.id, plugin); + this.pluginsById.set(String(plugin.id), plugin); } this.fieldsById = new Map(); for (const field of exportDoc.entities.filter( (e): e is SchemaTypes.Field => e.type === 'field', )) { - this.fieldsById.set(field.id, field); + this.fieldsById.set(String(field.id), field); } this.fieldsetsById = new Map(); for (const fieldset of exportDoc.entities.filter( (e): e is SchemaTypes.Fieldset => e.type === 'fieldset', )) { - this.fieldsetsById.set(fieldset.id, fieldset); + this.fieldsetsById.set(String(fieldset.id), fieldset); } - if (exportDoc.version === '1') { - const targetItemTypeIds = new Set(); - - for (const field of this.fields) { - const itemTypeId = field.relationships.item_type.data.id; - for (const linkedItemTypeId of findLinkedItemTypeIds(field)) { - if (linkedItemTypeId !== itemTypeId) { - targetItemTypeIds.add(linkedItemTypeId); - } + // Compute roots by inspecting field link targets (no incoming edges) + const targetItemTypeIds = new Set(); + for (const field of this.fields) { + const itemTypeId = String(field.relationships.item_type.data.id); + for (const linkedItemTypeId of findLinkedItemTypeIds(field)) { + if (String(linkedItemTypeId) !== itemTypeId) { + targetItemTypeIds.add(String(linkedItemTypeId)); } } + } - const rootItemTypes = this.itemTypes.filter( - (itemType) => !targetItemTypeIds.has(itemType.id), - ); + this.rootItemTypes = this.itemTypes.filter( + (itemType) => !targetItemTypeIds.has(itemType.id), + ); - if (rootItemTypes.length !== 1) { + if (exportDoc.version === '1') { + if (this.rootItemTypes.length !== 1) { throw new Error( 'This export file was generated by an older version of this plugin, and it is invalid because the initial model/block model cannot be determined. Please update to the most recent version of the plugin and export your schema once more.', ); } - - this.rootItemType = rootItemTypes[0]; + this.rootItemType = this.rootItemTypes[0]; } else { - this.rootItemType = this.getItemTypeById(exportDoc.rootItemTypeId); + // Prefer explicit root in v2 for backward compatibility; fall back to computed roots + const explicitRootId = exportDoc.rootItemTypeId; + if (explicitRootId) { + this.rootItemType = this.getItemTypeById(String(explicitRootId)); + // Ensure it exists in the list, even if incoming edge analysis differs + if ( + !this.rootItemTypes.find( + (it) => String(it.id) === String(explicitRootId), + ) + ) { + this.rootItemTypes = [this.rootItemType, ...this.rootItemTypes]; + } + } else if (this.rootItemTypes.length > 0) { + this.rootItemType = this.rootItemTypes[0]; + } else { + // Fallback: pick any item type present + const any = this.itemTypes[0]; + if (!any) { + throw new Error('Invalid export: missing item types'); + } + this.rootItemType = any; + this.rootItemTypes = [any]; + } } } @@ -85,7 +108,7 @@ export class ExportSchema { } getItemTypeById(itemTypeId: string) { - const itemType = this.itemTypesById.get(itemTypeId); + const itemType = this.itemTypesById.get(String(itemTypeId)); if (!itemType) { throw new Error('Not existing'); @@ -95,7 +118,7 @@ export class ExportSchema { } getPluginById(pluginId: string) { - const plugin = this.pluginsById.get(pluginId); + const plugin = this.pluginsById.get(String(pluginId)); if (!plugin) { throw new Error('Not existing'); @@ -108,8 +131,8 @@ export class ExportSchema { return ( get(itemType, 'relationships.fields.data', []) as Array<{ id: string }> ) - .map((f) => f.id) - .map((fid) => this.fieldsById.get(fid)) + .map((f) => String(f.id)) + .map((fid) => this.fieldsById.get(String(fid))) .filter(isDefined); } @@ -119,8 +142,8 @@ export class ExportSchema { id: string; }> ) - .map((fs) => fs.id) - .map((fsid) => this.fieldsetsById.get(fsid)) + .map((fs) => String(fs.id)) + .map((fsid) => this.fieldsetsById.get(String(fsid))) .filter(isDefined); } } diff --git a/import-export-schema/src/entrypoints/ExportPage/Inner.tsx b/import-export-schema/src/entrypoints/ExportPage/Inner.tsx index d9985d84..17c09cfc 100644 --- a/import-export-schema/src/entrypoints/ExportPage/Inner.tsx +++ b/import-export-schema/src/entrypoints/ExportPage/Inner.tsx @@ -1,30 +1,25 @@ -import type { ProjectSchema } from '@/utils/ProjectSchema'; import type { SchemaTypes } from '@datocms/cma-client'; import { faFileExport, faXmark } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { - Background, - type NodeMouseHandler, - type NodeTypes, - Panel, - ReactFlow, -} from '@xyflow/react'; +import { type NodeMouseHandler, type NodeTypes, Panel } from '@xyflow/react'; +import type { ProjectSchema } from '@/utils/ProjectSchema'; import '@xyflow/react/dist/style.css'; -import { type AppNode, type Graph, edgeTypes } from '@/utils/graph/types'; import type { RenderPageCtx } from 'datocms-plugin-sdk'; -import { - Button, - Toolbar, - ToolbarStack, - ToolbarTitle, - useCtx, -} from 'datocms-react-ui'; +import { Button, Spinner, useCtx } from 'datocms-react-ui'; import { without } from 'lodash-es'; import { useCallback, useEffect, useState } from 'react'; +import { GraphCanvas } from '@/components/GraphCanvas'; +import { + findLinkedItemTypeIds, + findLinkedPluginIds, +} from '@/utils/datocms/schema'; +// import { collectDependencies } from '@/utils/graph/dependencies'; +import { type AppNode, edgeTypes, type Graph } from '@/utils/graph/types'; +import { buildGraphFromSchema } from './buildGraphFromSchema'; import { EntitiesToExportContext } from './EntitiesToExportContext'; import { ExportItemTypeNodeRenderer } from './ExportItemTypeNodeRenderer'; import { ExportPluginNodeRenderer } from './ExportPluginNodeRenderer'; -import { buildGraphFromSchema } from './buildGraphFromSchema'; +import LargeSelectionView from './LargeSelectionView'; import { useAnimatedNodes } from './useAnimatedNodes'; const nodeTypes: NodeTypes = { @@ -33,44 +28,129 @@ const nodeTypes: NodeTypes = { }; type Props = { - initialItemType: SchemaTypes.ItemType; + initialItemTypes: SchemaTypes.ItemType[]; schema: ProjectSchema; onExport: (itemTypeIds: string[], pluginIds: string[]) => void; + onClose?: () => void; + onGraphPrepared?: () => void; + onPrepareProgress?: (update: { + done: number; + total: number; + label: string; + phase?: 'scan' | 'build'; + }) => void; + installedPluginIds?: Set; + onSelectingDependenciesChange?: (busy: boolean) => void; }; -export default function Inner({ initialItemType, schema, onExport }: Props) { +export default function Inner({ + initialItemTypes, + schema, + onExport, + onClose, + onGraphPrepared, + onPrepareProgress, + installedPluginIds, + onSelectingDependenciesChange, +}: Props) { const ctx = useCtx(); const [graph, setGraph] = useState(); + const [error, setError] = useState(); + const [refreshKey, setRefreshKey] = useState(0); - const [selectedItemTypeIds, setSelectedItemTypeIds] = useState([ - initialItemType.id, - ]); + const [selectedItemTypeIds, setSelectedItemTypeIds] = useState( + initialItemTypes.map((it) => it.id), + ); const [selectedPluginIds, setSelectedPluginIds] = useState([]); + const [selectingDependencies, setSelectingDependencies] = useState(false); + const [autoSelectedDependencies, setAutoSelectedDependencies] = useState<{ + itemTypeIds: Set; + pluginIds: Set; + }>({ itemTypeIds: new Set(), pluginIds: new Set() }); + + // Overlay is controlled by parent; we signal prepared after each build useEffect(() => { async function run() { - const graph = await buildGraphFromSchema({ - initialItemType, - selectedItemTypeIds, - schema, - }); + try { + setError(undefined); + if ( + typeof window !== 'undefined' && + window.localStorage?.getItem('schemaDebug') === '1' + ) { + console.log('[Inner] buildGraphFromSchema start', { + selectedItemTypeIds: selectedItemTypeIds.length, + }); + } + const graph = await buildGraphFromSchema({ + initialItemTypes, + selectedItemTypeIds, + schema, + onProgress: onPrepareProgress, + installedPluginIds, + }); - setGraph(graph); + setGraph(graph); + if ( + typeof window !== 'undefined' && + window.localStorage?.getItem('schemaDebug') === '1' + ) { + console.log('[Inner] buildGraphFromSchema done', { + nodes: graph.nodes.length, + edges: graph.edges.length, + }); + } + onGraphPrepared?.(); + } catch (e) { + console.error('Error building export graph:', e); + setError(e as Error); + onGraphPrepared?.(); + } } run(); - }, [initialItemType, selectedItemTypeIds.sort().join('-'), schema]); + }, [ + initialItemTypes + .map((it) => it.id) + .sort() + .join('-'), + selectedItemTypeIds.sort().join('-'), + schema, + refreshKey, + ]); - const animatedNodes = useAnimatedNodes(graph ? graph.nodes : [], { - animationDuration: 300, - }); + // Keep selection in sync if the parent changes the initial set of item types + useEffect(() => { + const mustHave = new Set(initialItemTypes.map((it) => it.id)); + setSelectedItemTypeIds((prev) => { + const next = new Set(prev); + for (const id of mustHave) next.add(id); + return Array.from(next); + }); + }, [ + initialItemTypes + .map((it) => it.id) + .sort() + .join('-'), + ]); + + const GRAPH_NODE_THRESHOLD = 60; + + const showGraph = !!graph && graph.nodes.length <= GRAPH_NODE_THRESHOLD; + + const animatedNodes = useAnimatedNodes( + showGraph && graph ? graph.nodes : [], + { + animationDuration: 300, + }, + ); const onNodeClick: NodeMouseHandler = useCallback( (_, node) => { if (node.type === 'itemType') { - if (node.id === initialItemType.id) { + if (initialItemTypes.some((it) => `itemType--${it.id}` === node.id)) { return; } @@ -89,72 +169,358 @@ export default function Inner({ initialItemType, schema, onExport }: Props) { ); } }, - [initialItemType.id], + [ + initialItemTypes + .map((it) => it.id) + .sort() + .join('-'), + ], ); - if (!graph) { - return null; - } + const handleSelectAllDependencies = useCallback(async () => { + setSelectingDependencies(true); + onSelectingDependenciesChange?.(true); + try { + // Ensure any preparation overlay is hidden during dependency selection + onGraphPrepared?.(); + // Determine installed plugin IDs, warn user once if unknown + // (avoids false positives when detecting plugin dependencies) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const warnedKey = 'exportPluginIdsWarned'; + const installedFromGraph = graph + ? new Set( + graph.nodes + .filter((n) => n.type === 'plugin') + .map((n) => (n.type === 'plugin' ? n.data.plugin.id : '')), + ) + : undefined; + const installed = + installedPluginIds && installedPluginIds.size > 0 + ? installedPluginIds + : installedFromGraph && installedFromGraph.size > 0 + ? installedFromGraph + : undefined; + if (!installed && typeof window !== 'undefined') { + try { + const already = window.sessionStorage.getItem(warnedKey) === '1'; + if (!already) { + void ctx.notice( + 'Plugin dependency detection may be incomplete (installed plugin list unavailable).', + ); + window.sessionStorage.setItem(warnedKey, '1'); + } + } catch {} + } + if ( + typeof window !== 'undefined' && + window.localStorage?.getItem('schemaDebug') === '1' + ) { + console.log('[SelectAllDependencies] start', { + selectedItemTypeIds: selectedItemTypeIds.length, + selectedPluginIds: selectedPluginIds.length, + installedPluginIds: installed + ? Array.from(installed).slice(0, 5) + : 'unknown', + }); + } + const beforeItemTypeIds = new Set(selectedItemTypeIds); + const beforePluginIds = new Set(selectedPluginIds); + const nextItemTypeIds = new Set(selectedItemTypeIds); + const nextPluginIds = new Set(selectedPluginIds); + + const queue = [...selectedItemTypeIds]; + + while (queue.length > 0) { + const popped = queue.pop(); + if (!popped) break; + const id = popped; + const node = graph?.nodes.find((n) => n.id === `itemType--${id}`); + const fields = node?.type === 'itemType' ? node.data.fields : []; + + for (const field of fields) { + for (const linkedId of findLinkedItemTypeIds(field)) { + if (!nextItemTypeIds.has(linkedId)) { + nextItemTypeIds.add(linkedId); + queue.push(linkedId); + } + } + + for (const pluginId of findLinkedPluginIds(field, installed)) { + nextPluginIds.add(pluginId); + } + } + } + + const addedItemTypeIds = Array.from(nextItemTypeIds).filter( + (id) => !beforeItemTypeIds.has(id), + ); + const addedPluginIds = Array.from(nextPluginIds).filter( + (id) => !beforePluginIds.has(id), + ); + + setSelectedItemTypeIds(Array.from(nextItemTypeIds)); + setSelectedPluginIds(Array.from(nextPluginIds)); + setAutoSelectedDependencies({ + itemTypeIds: new Set(addedItemTypeIds), + pluginIds: new Set(addedPluginIds), + }); + if ( + typeof window !== 'undefined' && + window.localStorage?.getItem('schemaDebug') === '1' + ) { + console.log('[SelectAllDependencies] done', { + itemTypeIds: nextItemTypeIds.size, + pluginIds: nextPluginIds.size, + samplePluginIds: Array.from(nextPluginIds).slice(0, 5), + }); + } + void ctx.notice( + `Selected dependencies: +${addedItemTypeIds.length} models, +${addedPluginIds.length} plugins`, + ); + } finally { + setSelectingDependencies(false); + // Do not lift overlay suppression here; let onGraphPrepared re-enable it + } + }, [ + graph, + selectedItemTypeIds, + selectedPluginIds, + installedPluginIds, + onSelectingDependenciesChange, + ctx, + ]); + + const handleUnselectAllDependencies = useCallback(() => { + // Remove dependencies previously auto-selected; fallback to none + const toRemoveItemTypeIds = autoSelectedDependencies.itemTypeIds; + const toRemovePluginIds = autoSelectedDependencies.pluginIds; + + if (toRemoveItemTypeIds.size === 0 && toRemovePluginIds.size === 0) { + // No recorded auto-selected deps; nothing to do + void ctx.notice('No dependencies to unselect'); + return; + } + + const removedModelsCount = selectedItemTypeIds.filter((id) => + toRemoveItemTypeIds.has(id), + ).length; + const removedPluginsCount = selectedPluginIds.filter((id) => + toRemovePluginIds.has(id), + ).length; + + setSelectedItemTypeIds((prev) => + prev.filter((id) => !toRemoveItemTypeIds.has(id)), + ); + setSelectedPluginIds((prev) => + prev.filter((id) => !toRemovePluginIds.has(id)), + ); + setAutoSelectedDependencies({ + itemTypeIds: new Set(), + pluginIds: new Set(), + }); + void ctx.notice( + `Unselected dependencies: -${removedModelsCount} models, -${removedPluginsCount} plugins`, + ); + }, [autoSelectedDependencies, ctx, selectedItemTypeIds, selectedPluginIds]); + + // Determine if all deps are selected to toggle label + const areAllDependenciesSelected = (() => { + try { + const installedFromGraph = graph + ? new Set( + graph.nodes + .filter((n) => n.type === 'plugin') + .map((n) => (n.type === 'plugin' ? n.data.plugin.id : '')), + ) + : undefined; + const installed = + installedPluginIds && installedPluginIds.size > 0 + ? installedPluginIds + : installedFromGraph && installedFromGraph.size > 0 + ? installedFromGraph + : undefined; + + const nextItemTypeIds = new Set(selectedItemTypeIds); + const nextPluginIds = new Set(selectedPluginIds); + const queue = [...selectedItemTypeIds]; + while (queue.length > 0) { + const popped = queue.pop(); + if (!popped) break; + const id = popped; + const node = graph?.nodes.find((n) => n.id === `itemType--${id}`); + const fields = node?.type === 'itemType' ? node.data.fields : []; + for (const field of fields) { + for (const linkedId of findLinkedItemTypeIds(field)) { + if (!nextItemTypeIds.has(linkedId)) { + nextItemTypeIds.add(linkedId); + queue.push(linkedId); + } + } + for (const pluginId of findLinkedPluginIds(field, installed)) { + nextPluginIds.add(pluginId); + } + } + } + const toAddItemTypes = Array.from(nextItemTypeIds).filter( + (id) => !selectedItemTypeIds.includes(id), + ); + const toAddPlugins = Array.from(nextPluginIds).filter( + (id) => !selectedPluginIds.includes(id), + ); + return toAddItemTypes.length === 0 && toAddPlugins.length === 0; + } catch { + return false; + } + })(); return ( -
    - - - Export {initialItemType.attributes.name} -
    - - - + }} + > + Close + +
    - - {graph && ( - + ) : error ? ( +
    +
    Could not load export graph
    +
    + {(() => { + const anyErr = error as unknown as { + response?: { status?: number }; + }; + const status = anyErr?.response?.status; + if (status === 429) { + return "You're being rate-limited by the API (429). Please wait a few seconds and try again."; + } + if (status === 401 || status === 403) { + return 'You do not have permission to load the project schema. Please check your credentials and try again.'; + } + if (status && status >= 500) { + return 'The API is temporarily unavailable. Please try again shortly.'; + } + return 'An unexpected error occurred while preparing the export. Please try again.'; + })()} +
    + - - - )} - + Retry + +
    + ) : ( + + {showGraph ? ( + <> + + +
    + + {selectingDependencies && } + + +
    +
    + + ) : ( + + )} +
    + )}
    diff --git a/import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.tsx b/import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.tsx new file mode 100644 index 00000000..3b05ab35 --- /dev/null +++ b/import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.tsx @@ -0,0 +1,411 @@ +import type { SchemaTypes } from '@datocms/cma-client'; +import { Button, Spinner, TextField } from 'datocms-react-ui'; +import { useId, useMemo, useState } from 'react'; +import { + countCycles, + findInboundEdges, + findOutboundEdges, + getConnectedComponents, + splitNodesByType, +} from '@/utils/graph/analysis'; +import type { Graph } from '@/utils/graph/types'; + +type Props = { + initialItemTypes: SchemaTypes.ItemType[]; + graph: Graph; + selectedItemTypeIds: string[]; + setSelectedItemTypeIds: (next: string[]) => void; + selectedPluginIds: string[]; + setSelectedPluginIds: (next: string[]) => void; + onExport: (itemTypeIds: string[], pluginIds: string[]) => void; + onSelectAllDependencies: () => Promise | void; + onUnselectAllDependencies: () => Promise | void; + areAllDependenciesSelected: boolean; + selectingDependencies: boolean; +}; + +export default function LargeSelectionView({ + initialItemTypes, + graph, + selectedItemTypeIds, + setSelectedItemTypeIds, + selectedPluginIds, + setSelectedPluginIds, + onExport, + onSelectAllDependencies, + onUnselectAllDependencies, + areAllDependenciesSelected, + selectingDependencies, +}: Props) { + const searchInputId = useId(); + const [query, setQuery] = useState(''); + const [expandedWhy, setExpandedWhy] = useState>(new Set()); + + const initialItemTypeIdSet = useMemo( + () => new Set(initialItemTypes.map((it) => it.id)), + [initialItemTypes], + ); + + const { itemTypeNodes, pluginNodes } = useMemo( + () => splitNodesByType(graph), + [graph], + ); + + const components = useMemo(() => getConnectedComponents(graph), [graph]); + const cycles = useMemo(() => countCycles(graph), [graph]); + + const filteredItemTypeNodes = useMemo(() => { + if (!query) return itemTypeNodes; + const q = query.toLowerCase(); + return itemTypeNodes.filter((n) => { + const it = n.data.itemType; + return ( + it.attributes.name.toLowerCase().includes(q) || + it.attributes.api_key.toLowerCase().includes(q) + ); + }); + }, [itemTypeNodes, query]); + + const filteredPluginNodes = useMemo(() => { + if (!query) return pluginNodes; + const q = query.toLowerCase(); + return pluginNodes.filter((n) => + n.data.plugin.attributes.name.toLowerCase().includes(q), + ); + }, [pluginNodes, query]); + + function toggleItemType(id: string) { + setSelectedItemTypeIds( + selectedItemTypeIds.includes(id) + ? selectedItemTypeIds.filter((x) => x !== id) + : [...selectedItemTypeIds, id], + ); + } + + function togglePlugin(id: string) { + setSelectedPluginIds( + selectedPluginIds.includes(id) + ? selectedPluginIds.filter((x) => x !== id) + : [...selectedPluginIds, id], + ); + } + + function toggleWhy(id: string) { + const next = new Set(expandedWhy); + if (next.has(id)) next.delete(id); + else next.add(id); + setExpandedWhy(next); + } + + const selectedSourceSet = useMemo( + () => new Set(selectedItemTypeIds.map((id) => `itemType--${id}`)), + [selectedItemTypeIds], + ); + + return ( +
    +
    +
    + {itemTypeNodes.length} models • {pluginNodes.length} plugins •{' '} + {graph.edges.length} relations +
    +
    + Components: {components.length} • Cycles: {cycles} +
    +
    + setQuery(val)} + textInputProps={{ autoComplete: 'off' }} + /> +
    +
    + +
    +
    + Models +
      + {filteredItemTypeNodes.map((n) => { + const it = n.data.itemType; + const locked = initialItemTypeIdSet.has(it.id); + const checked = selectedItemTypeIds.includes(it.id); + const inbound = findInboundEdges(graph, `itemType--${it.id}`); + const outbound = findOutboundEdges(graph, `itemType--${it.id}`); + const isExpanded = expandedWhy.has(it.id); + const reasons = findInboundEdges( + graph, + `itemType--${it.id}`, + selectedSourceSet, + ); + + return ( +
    • +
      + toggleItemType(it.id)} + style={{ width: 16, height: 16 }} + /> +
      +
      + {it.attributes.name}{' '} + + ({it.attributes.api_key}) + {' '} + + {it.attributes.modular_block ? 'Block' : 'Model'} + +
      +
      + + ← {inbound.length} inbound + {' '} + •{' '} + + → {outbound.length} outbound + +
      +
      +
      + {reasons.length > 0 && ( + + )} +
      +
      + {isExpanded && reasons.length > 0 && ( +
      +
      + Included because: +
      +
        + {reasons.map((edge) => { + const sourceNode = graph.nodes.find( + (nd) => nd.id === edge.source, + ); + if (!sourceNode) return null; + const srcIt = + sourceNode.type === 'itemType' + ? sourceNode.data.itemType + : undefined; + return ( +
      • + {srcIt ? ( + <> + Selected model{' '} + {srcIt.attributes.name}{' '} + references it via fields:{' '} + + + ) : ( + <> + Referenced in fields:{' '} + + + )} +
      • + ); + })} +
      +
      + )} +
    • + ); + })} +
    +
    +
    + Plugins +
      + {filteredPluginNodes.map((n) => { + const pl = n.data.plugin; + const checked = selectedPluginIds.includes(pl.id); + const inbound = findInboundEdges(graph, `plugin--${pl.id}`); + return ( +
    • +
      + togglePlugin(pl.id)} + style={{ width: 16, height: 16 }} + /> +
      +
      + {pl.attributes.name} +
      +
      + ← {inbound.length} inbound from models +
      +
      +
      +
    • + ); + })} +
    +
    +
    + +
    +
    + Graph view hidden due to size. +
    + + {selectingDependencies && } +
    + +
    +
    + ); +} + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +
    + {children} +
    + ); +} + +function FieldsList({ fields }: { fields: SchemaTypes.Field[] }) { + if (!fields || fields.length === 0) return unknown fields; + return ( + <> + {fields + .map((f) => `${f.attributes.label} (${f.attributes.api_key})`) + .join(', ')} + + ); +} diff --git a/import-export-schema/src/entrypoints/ExportPage/PostExportSummary.tsx b/import-export-schema/src/entrypoints/ExportPage/PostExportSummary.tsx new file mode 100644 index 00000000..a298e66a --- /dev/null +++ b/import-export-schema/src/entrypoints/ExportPage/PostExportSummary.tsx @@ -0,0 +1,703 @@ +import type { SchemaTypes } from '@datocms/cma-client'; +import { Button, TextField } from 'datocms-react-ui'; +import { useId, useMemo, useState } from 'react'; +import { + findLinkedItemTypeIds, + findLinkedPluginIds, +} from '@/utils/datocms/schema'; +import type { ExportDoc } from '@/utils/types'; +import { ExportSchema } from './ExportSchema'; + +// Removed combined FieldsAndFieldsetsSummary in favor of separate panels + +type Props = { + exportDoc: ExportDoc; + adminDomain?: string; + onClose: () => void; + onDownload: () => void; +}; + +export default function PostExportSummary({ + exportDoc, + adminDomain, + onClose, + onDownload, +}: Props) { + const searchId = useId(); + const exportSchema = useMemo(() => new ExportSchema(exportDoc), [exportDoc]); + // Derive admin origin from provided domain or referrer as a robust fallback + const adminOrigin = useMemo( + () => (adminDomain ? `https://${adminDomain}` : undefined), + [adminDomain], + ); + console.log('[PostExportSummary] adminOrigin:', adminOrigin); + + const stats = useMemo(() => { + const models = exportSchema.itemTypes.filter( + (it) => !it.attributes.modular_block, + ); + const blocks = exportSchema.itemTypes.filter( + (it) => it.attributes.modular_block, + ); + const fields = exportSchema.fields; + const fieldsets = exportSchema.fieldsets; + const plugins = exportSchema.plugins; + return { models, blocks, fields, fieldsets, plugins }; + }, [exportSchema]); + + const connections = useMemo( + () => buildConnections(exportSchema), + [exportSchema], + ); + + const connectionsById = useMemo(() => { + const map = new Map< + string, + { + linkedItemTypes: Array<{ + target: SchemaTypes.ItemType; + fields: SchemaTypes.Field[]; + }>; + linkedPlugins: Array<{ + plugin: SchemaTypes.Plugin; + fields: SchemaTypes.Field[]; + }>; + } + >(); + for (const c of connections) { + map.set(c.itemType.id, { + linkedItemTypes: c.linkedItemTypes, + linkedPlugins: c.linkedPlugins, + }); + } + return map; + }, [connections]); + + const [contentQuery, setContentQuery] = useState(''); + const chipStyle = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: '72px', + height: '18px', + fontSize: '10px', + padding: '0 4px', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + } as const; + + const allFields = exportSchema.fields; + const allFieldsets = exportSchema.fieldsets; + + const filteredModels = useMemo(() => { + const q = contentQuery.trim().toLowerCase(); + if (!q) return stats.models; + return stats.models.filter( + (it) => + it.attributes.name.toLowerCase().includes(q) || + it.attributes.api_key.toLowerCase().includes(q), + ); + }, [stats.models, contentQuery]); + + const filteredBlocks = useMemo(() => { + const q = contentQuery.trim().toLowerCase(); + if (!q) return stats.blocks; + return stats.blocks.filter( + (it) => + it.attributes.name.toLowerCase().includes(q) || + it.attributes.api_key.toLowerCase().includes(q), + ); + }, [stats.blocks, contentQuery]); + + const filteredPlugins = useMemo(() => { + const q = contentQuery.trim().toLowerCase(); + if (!q) return stats.plugins; + return stats.plugins.filter((pl) => + pl.attributes.name.toLowerCase().includes(q), + ); + }, [stats.plugins, contentQuery]); + + const filteredFields = useMemo(() => { + const q = contentQuery.trim().toLowerCase(); + if (!q) return allFields; + return allFields.filter((f) => { + const label = (f.attributes.label || '').toLowerCase(); + const apiKey = f.attributes.api_key.toLowerCase(); + return label.includes(q) || apiKey.includes(q); + }); + }, [allFields, contentQuery]); + + const filteredFieldsets = useMemo(() => { + const q = contentQuery.trim().toLowerCase(); + if (!q) return allFieldsets; + return allFieldsets.filter((fs) => + (fs.attributes.title || '').toLowerCase().includes(q), + ); + }, [allFieldsets, contentQuery]); + + const fieldsetParentById = useMemo(() => { + const map = new Map(); + for (const it of exportSchema.itemTypes) { + for (const fs of exportSchema.getItemTypeFieldsets(it)) { + map.set(String(fs.id), it); + } + } + return map; + }, [exportSchema]); + type SectionKey = 'models' | 'blocks' | 'plugins' | 'fields' | 'fieldsets'; + const [activeSection, setActiveSection] = useState('models'); + const sections: Array<{ + key: SectionKey; + label: string; + count: number; + }> = [ + { key: 'models', label: 'Models', count: stats.models.length }, + { key: 'blocks', label: 'Blocks', count: stats.blocks.length }, + { key: 'plugins', label: 'Plugins', count: stats.plugins.length }, + { key: 'fields', label: 'Fields', count: allFields.length }, + { key: 'fieldsets', label: 'Fieldsets', count: allFieldsets.length }, + ]; + + return ( +
    +
    +
    +
    +
    + {sections.map((s) => ( + + ))} +
    + + +
    +
    +
    + {activeSection === 'models' && ( + <> +
    + Models ({stats.models.length}) +
    +
    + setContentQuery(val)} + textInputProps={{ autoComplete: 'off' }} + /> +
    + ( +
  • + {adminOrigin ? ( + + + {it.attributes.name}{' '} + + {it.attributes.api_key} + + + + + {connectionsById.get(it.id)?.linkedItemTypes + .length ?? 0}{' '} + links + + + {connectionsById.get(it.id)?.linkedPlugins + .length ?? 0}{' '} + plugins + + + + ) : ( + <> + {it.attributes.name}{' '} + + {it.attributes.api_key} + + + + {connectionsById.get(it.id)?.linkedItemTypes + .length ?? 0}{' '} + links + + + {connectionsById.get(it.id)?.linkedPlugins + .length ?? 0}{' '} + plugins + + + + )} +
  • + )} + /> + + )} + {activeSection === 'blocks' && ( + <> +
    + Blocks ({stats.blocks.length}) +
    +
    + setContentQuery(val)} + textInputProps={{ autoComplete: 'off' }} + /> +
    + ( +
  • + {adminOrigin ? ( + + + {it.attributes.name}{' '} + + {it.attributes.api_key} + + + + + {connectionsById.get(it.id)?.linkedItemTypes + .length ?? 0}{' '} + links + + + {connectionsById.get(it.id)?.linkedPlugins + .length ?? 0}{' '} + plugins + + + + ) : ( + <> + {it.attributes.name}{' '} + + {it.attributes.api_key} + + + + {connectionsById.get(it.id)?.linkedItemTypes + .length ?? 0}{' '} + links + + + {connectionsById.get(it.id)?.linkedPlugins + .length ?? 0}{' '} + plugins + + + + )} +
  • + )} + /> + + )} + {activeSection === 'plugins' && ( + <> +
    + Plugins ({stats.plugins.length}) +
    +
    + setContentQuery(val)} + textInputProps={{ autoComplete: 'off' }} + /> +
    +
      + {filteredPlugins.length > 0 ? ( + filteredPlugins.map((pl) => ( +
    • + {adminOrigin ? ( + + {pl.attributes.name} + + ) : ( + pl.attributes.name + )} +
    • + )) + ) : ( +
    • No plugins
    • + )} +
    + + )} + {activeSection === 'fields' && ( + <> +
    + Fields ({allFields.length}) +
    +
    + setContentQuery(val)} + textInputProps={{ autoComplete: 'off' }} + /> +
    + { + const label = f.attributes.label || f.attributes.api_key; + const parentId = String( + (f as SchemaTypes.Field).relationships.item_type.data + .id, + ); + const parent = exportSchema.itemTypesById.get(parentId); + const isBlockParent = + parent?.attributes.modular_block === true; + const basePath = isBlockParent + ? '/schema/blocks_library' + : '/schema/item_types'; + const fieldUrl = `${adminOrigin}${basePath}/${parentId}#f${f.id}`; + return ( +
  • + {adminOrigin ? ( + + {label}{' '} + + ({f.attributes.api_key}) + + + ) : ( + + {label}{' '} + + ({f.attributes.api_key}) + + + )} +
  • + ); + }} + /> + + )} + {activeSection === 'fieldsets' && ( + <> +
    + Fieldsets ({allFieldsets.length}) +
    +
    + setContentQuery(val)} + textInputProps={{ autoComplete: 'off' }} + /> +
    + { + const parent = fieldsetParentById.get(String(fs.id)); + const isBlockParent = + parent?.attributes.modular_block === true; + const basePath = isBlockParent + ? '/schema/blocks_library' + : '/schema/item_types'; + const href = + parent && adminOrigin + ? `${adminOrigin}${basePath}/${parent.id}` + : undefined; + return ( +
  • + {href ? ( + + {fs.attributes.title} + + ) : ( + + {fs.attributes.title} + + )} +
  • + ); + }} + /> + + )} +
    +
    +
    + {/* Removed the old connections section; counts are now shown inline in Models/Blocks */} +
    +
    + ); +} + +// Removed SectionTitle in favor of summary__title for cleaner layout + +function buildConnections(exportSchema: ExportSchema) { + const pluginIds = new Set(Array.from(exportSchema.pluginsById.keys())); + const out = [] as Array<{ + itemType: SchemaTypes.ItemType; + linkedItemTypes: Array<{ + target: SchemaTypes.ItemType; + fields: SchemaTypes.Field[]; + }>; + linkedPlugins: Array<{ + plugin: SchemaTypes.Plugin; + fields: SchemaTypes.Field[]; + }>; + }>; + + for (const it of exportSchema.itemTypes) { + const fields = exportSchema.getItemTypeFields(it); + const byItemType = new Map(); + const byPlugin = new Map(); + + for (const field of fields) { + for (const linkedId of findLinkedItemTypeIds(field)) { + const arr = byItemType.get(String(linkedId)) || []; + arr.push(field); + byItemType.set(String(linkedId), arr); + } + for (const pluginId of findLinkedPluginIds(field, pluginIds)) { + const arr = byPlugin.get(String(pluginId)) || []; + arr.push(field); + byPlugin.set(String(pluginId), arr); + } + } + + const linkedItemTypes = Array.from(byItemType.entries()).flatMap( + ([targetId, fields]) => { + const target = exportSchema.itemTypesById.get(String(targetId)); + return target ? [{ target, fields }] : []; + }, + ); + + const linkedPlugins = Array.from(byPlugin.entries()).flatMap( + ([pid, fields]) => { + const plugin = exportSchema.pluginsById.get(String(pid)); + return plugin ? [{ plugin, fields }] : []; + }, + ); + + out.push({ itemType: it, linkedItemTypes, linkedPlugins }); + } + + return out; +} + +// Collapsible removed in favor of the new two-pane layout + +function LimitedList({ + items, + renderItem, + initial = 20, +}: { + items: T[]; + renderItem: (item: T) => React.ReactNode; + initial?: number; +}) { + const [limit, setLimit] = useState(initial); + const showingAll = limit >= items.length; + const visible = items.slice(0, limit); + return ( + <> +
      + {visible.map((it) => renderItem(it))} +
    + {items.length > initial && ( +
    + +
    + )} + + ); +} diff --git a/import-export-schema/src/entrypoints/ExportPage/buildExportDoc.ts b/import-export-schema/src/entrypoints/ExportPage/buildExportDoc.ts index 5cc60f8f..fa8dda8c 100644 --- a/import-export-schema/src/entrypoints/ExportPage/buildExportDoc.ts +++ b/import-export-schema/src/entrypoints/ExportPage/buildExportDoc.ts @@ -1,21 +1,25 @@ -import type { ProjectSchema } from '@/utils/ProjectSchema'; -import { - defaultAppearanceForFieldType, - isHardcodedEditor, -} from '@/utils/datocms/fieldTypeInfo'; +import { cloneDeep, get, intersection, set } from 'lodash-es'; +import { ensureExportableAppearance } from '@/utils/datocms/appearance'; import { validatorsContainingBlocks, validatorsContainingLinks, } from '@/utils/datocms/schema'; +import type { ProjectSchema } from '@/utils/ProjectSchema'; import type { ExportDocV2 } from '@/utils/types'; -import { cloneDeep, get, intersection, set } from 'lodash-es'; + +type BuildExportDocOptions = { + onProgress?: (label: string) => void; + shouldCancel?: () => boolean; +}; export default async function buildExportDoc( schema: ProjectSchema, initialItemTypeId: string, itemTypeIdsToExport: string[], pluginIdsToExport: string[], + options: BuildExportDocOptions = {}, ): Promise { + const { onProgress, shouldCancel } = options; const doc: ExportDocV2 = { version: '2', rootItemTypeId: initialItemTypeId, @@ -23,24 +27,32 @@ export default async function buildExportDoc( }; for (const pluginId of pluginIdsToExport) { + if (shouldCancel?.()) throw new Error('Export cancelled'); const plugin = await schema.getPluginById(pluginId); doc.entities.push(plugin); + onProgress?.(`Plugin: ${plugin.attributes.name}`); } for (const itemTypeIdToExport of itemTypeIdsToExport) { + if (shouldCancel?.()) throw new Error('Export cancelled'); const itemTypeToExport = await schema.getItemTypeById(itemTypeIdToExport); + onProgress?.(`Model/Block: ${itemTypeToExport.attributes.name}`); const [fields, fieldsets] = await schema.getItemTypeFieldsAndFieldsets(itemTypeToExport); + if (shouldCancel?.()) throw new Error('Export cancelled'); + onProgress?.(`Fields/Fieldsets for ${itemTypeToExport.attributes.name}`); doc.entities.push(itemTypeToExport); for (const fieldset of fieldsets) { + if (shouldCancel?.()) throw new Error('Export cancelled'); doc.entities.push(fieldset); } for (const field of fields) { + if (shouldCancel?.()) throw new Error('Export cancelled'); const exportableField = cloneDeep(field); const validators = [ @@ -65,20 +77,10 @@ export default async function buildExportDoc( ); } - field.attributes.appeareance = undefined; - - if ( - !(await isHardcodedEditor(field.attributes.appearance.editor)) && - !pluginIdsToExport.includes(field.attributes.appearance.editor) - ) { - exportableField.attributes.appearance = - await defaultAppearanceForFieldType(field.attributes.field_type); - } - - exportableField.attributes.appearance.addons = - field.attributes.appearance.addons.filter((addon) => - pluginIdsToExport.includes(addon.id), - ); + exportableField.attributes.appearance = await ensureExportableAppearance( + field, + pluginIdsToExport, + ); doc.entities.push(exportableField); } diff --git a/import-export-schema/src/entrypoints/ExportPage/buildGraphFromSchema.ts b/import-export-schema/src/entrypoints/ExportPage/buildGraphFromSchema.ts index 7985fea6..bdd259d0 100644 --- a/import-export-schema/src/entrypoints/ExportPage/buildGraphFromSchema.ts +++ b/import-export-schema/src/entrypoints/ExportPage/buildGraphFromSchema.ts @@ -1,229 +1,38 @@ -import type { ItemTypeNode } from '@/components/ItemTypeNodeRenderer'; -import type { PluginNode } from '@/components/PluginNodeRenderer'; -import type { ProjectSchema } from '@/utils/ProjectSchema'; -import { - findLinkedItemTypeIds, - findLinkedPluginIds, -} from '@/utils/datocms/schema'; -import { buildHierarchyNodes } from '@/utils/graph/buildHierarchyNodes'; -import { rebuildGraphWithPositionsFromHierarchy } from '@/utils/graph/rebuildGraphWithPositionsFromHierarchy'; -import type { AppEdge, Graph } from '@/utils/graph/types'; import type { SchemaTypes } from '@datocms/cma-client'; -import { MarkerType } from '@xyflow/react'; -import { find, sortBy } from 'lodash-es'; +import { buildGraph } from '@/utils/graph/buildGraph'; +import type { Graph } from '@/utils/graph/types'; +import type { ProjectSchema } from '@/utils/ProjectSchema'; +import { ProjectSchemaSource } from '@/utils/schema/ProjectSchemaSource'; type Options = { - initialItemType: SchemaTypes.ItemType; + initialItemTypes: SchemaTypes.ItemType[]; selectedItemTypeIds: string[]; schema: ProjectSchema; + onProgress?: (update: { + done: number; + total: number; + label: string; + phase?: 'scan' | 'build'; + }) => void; + installedPluginIds?: Set; }; -type QueueItem = SchemaTypes.ItemType | SchemaTypes.Plugin; +// Note: queue type was unused; removed for strict build export async function buildGraphFromSchema({ - initialItemType, + initialItemTypes, selectedItemTypeIds, schema, + onProgress, }: Options): Promise { - const graph: Graph = { nodes: [], edges: [] }; - - const queue: QueueItem[][] = [[initialItemType]]; - const processedNodes = new Set(); - - // Process each level of the graph - while (queue.length > 0) { - const currentLevelItems = queue.shift(); - - if (!currentLevelItems) { - throw new Error('Unexpected error: currentLevelItemTypes is undefined'); - } - - const nextLevelQueue = new Set(); - - // Process all nodes at the current level in parallel - await Promise.all( - currentLevelItems.map(async (itemTypeOrPlugin) => { - // Skip if already processed - if (processedNodes.has(itemTypeOrPlugin)) { - return; - } - - // Mark as processed - processedNodes.add(itemTypeOrPlugin); - - if (itemTypeOrPlugin.type === 'item_type') { - const itemType = itemTypeOrPlugin; - - // Process fields and collect child nodes - const [fields, fieldsets] = - await schema.getItemTypeFieldsAndFieldsets(itemType); - - // Add current node to graph - graph.nodes.push(buildItemTypeNode(itemType, fields, fieldsets)); - - if (!selectedItemTypeIds.includes(itemType.id)) { - return; - } - - const [edges, linkedItemTypeIds, linkedPluginIds] = - await buildEdgesForItemType(itemType, fields, initialItemType); - - graph.edges.push(...edges); - - // Process all item types in parallel - await Promise.all([ - ...Array.from(linkedItemTypeIds).map(async (linkedItemTypeId) => { - const linkedItemType = - await schema.getItemTypeById(linkedItemTypeId); - - if (!processedNodes.has(linkedItemType)) { - nextLevelQueue.add(linkedItemType); - } - }), - ...Array.from(linkedPluginIds).map(async (linkedPluginId) => { - const linkedPlugin = await schema.getPluginById(linkedPluginId); - - if (!processedNodes.has(linkedPlugin)) { - nextLevelQueue.add(linkedPlugin); - } - }), - ]); - } else { - const plugin = itemTypeOrPlugin; - - // Add current node to graph - graph.nodes.push(buildPluginNode(plugin)); - } - }), - ); - - // If we have nodes for the next level, add them to the queue - if (nextLevelQueue.size > 0) { - queue.push([...nextLevelQueue]); - } - } - - const sortedGraph = deterministicGraphSort(graph); - - if (sortedGraph.nodes.length === 0) { - return sortedGraph; - } - - const hierarchy = buildHierarchyNodes(sortedGraph, selectedItemTypeIds); - - for (const hierarchyNode of hierarchy.descendants()) { - const innerNode = hierarchy.data; - - if (innerNode.type === 'itemType') { - hierarchyNode.children = selectedItemTypeIds.includes( - innerNode.data.itemType.id, - ) - ? hierarchyNode.children - : undefined; - } - } - - return rebuildGraphWithPositionsFromHierarchy(hierarchy, sortedGraph.edges); + const source = new ProjectSchemaSource(schema); + return buildGraph({ + source, + initialItemTypes, + selectedItemTypeIds, + onProgress, + }); } -export async function buildEdgesForItemType( - itemType: SchemaTypes.ItemType, - fields: SchemaTypes.Field[], - rootItemType: SchemaTypes.ItemType, -) { - const edges: AppEdge[] = []; - const linkedItemTypeIds = new Set(); - const linkedPluginIds = new Set(); - - for (const field of fields) { - for (const linkedItemTypeId of await findLinkedItemTypeIds(field)) { - if (linkedItemTypeId === rootItemType.id) { - continue; - } - - const id = `toItemType--${itemType.id}->${linkedItemTypeId}`; - linkedItemTypeIds.add(linkedItemTypeId); - - const edge = find(edges, { id }); - - if (edge) { - edge.data!.fields.push(field); - } else { - edges.push({ - id, - source: `itemType--${itemType.id}`, - target: `itemType--${linkedItemTypeId}`, - type: 'field', - data: { fields: [field] }, - markerEnd: { type: MarkerType.ArrowClosed }, - }); - } - } - - for (const linkedPluginId of await findLinkedPluginIds(field)) { - const id = `toPlugin--${itemType.id}->${linkedPluginId}`; - const edge = find(edges, { id }); - linkedPluginIds.add(linkedPluginId); - - if (edge) { - edge.data!.fields.push(field); - } else { - edges.push({ - id, - source: `itemType--${itemType.id}`, - target: `plugin--${linkedPluginId}`, - type: 'field', - data: { fields: [field] }, - markerEnd: { type: MarkerType.ArrowClosed }, - }); - } - } - } - - return [edges, linkedItemTypeIds, linkedPluginIds] as const; -} - -export function buildPluginNode(plugin: SchemaTypes.Plugin): PluginNode { - return { - id: `plugin--${plugin.id}`, - position: { - x: 0, - y: 0, - }, - type: 'plugin', - data: { - plugin, - }, - }; -} - -export function buildItemTypeNode( - itemType: SchemaTypes.ItemType, - fields: SchemaTypes.Field[], - fieldsets: SchemaTypes.Fieldset[], -): ItemTypeNode { - return { - id: `itemType--${itemType.id}`, - position: { - x: 0, - y: 0, - }, - type: 'itemType', - data: { - itemType, - fields, - fieldsets, - }, - }; -} - -export function deterministicGraphSort(graph: Graph) { - return { - nodes: sortBy(graph.nodes, [ - 'type', - 'data.itemType.attributes.api_key', - 'data.itemType.attributes.name', - ]), - edges: graph.edges, - }; -} +// The helper exports moved to utils/graph; kept named export for compatibility if imported elsewhere +export type { SchemaTypes }; diff --git a/import-export-schema/src/entrypoints/ExportPage/index.tsx b/import-export-schema/src/entrypoints/ExportPage/index.tsx index acf9669c..8bb6d47e 100644 --- a/import-export-schema/src/entrypoints/ExportPage/index.tsx +++ b/import-export-schema/src/entrypoints/ExportPage/index.tsx @@ -1,12 +1,16 @@ -import { ProjectSchema } from '@/utils/ProjectSchema'; -import { downloadJSON } from '@/utils/downloadJson'; -import { type SchemaTypes, buildClient } from '@datocms/cma-client'; +import type { SchemaTypes } from '@datocms/cma-client'; import { ReactFlowProvider } from '@xyflow/react'; import type { RenderPageCtx } from 'datocms-plugin-sdk'; -import { Canvas, Spinner } from 'datocms-react-ui'; -import { useEffect, useMemo, useState } from 'react'; -import Inner from './Inner'; +import { Button, Canvas, Spinner } from 'datocms-react-ui'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import ProgressStallNotice from '@/components/ProgressStallNotice'; +import { createCmaClient } from '@/utils/createCmaClient'; +import { downloadJSON } from '@/utils/downloadJson'; +import { ProjectSchema } from '@/utils/ProjectSchema'; +import type { ExportDoc } from '@/utils/types'; import buildExportDoc from './buildExportDoc'; +import Inner from './Inner'; +import PostExportSummary from './PostExportSummary'; type Props = { ctx: RenderPageCtx; @@ -14,39 +18,168 @@ type Props = { }; export default function ExportPage({ ctx, initialItemTypeId }: Props) { + const client = useMemo( + () => createCmaClient(ctx), + [ctx.currentUserAccessToken, ctx.environment], + ); + const [initialItemType, setInitialItemType] = useState< SchemaTypes.ItemType | undefined >(); + const [preparingBusy, setPreparingBusy] = useState(true); + const [suppressPreparingOverlay, setSuppressPreparingOverlay] = + useState(false); + const [preparingProgress, setPreparingProgress] = useState< + | { done: number; total: number; label: string; phase?: 'scan' | 'build' } + | undefined + >(undefined); + // Smoothed visual progress percentage for the preparing overlay. + // We map the initial scanning phase to 0–25%, then determinate build to 25–100%. + const [preparingPercent, setPreparingPercent] = useState(0.1); - const schema = useMemo(() => { - const client = buildClient({ - apiToken: ctx.currentUserAccessToken!, - environment: ctx.environment, - }); - return new ProjectSchema(client); - }, [ctx.currentUserAccessToken, ctx.environment]); + // Heartbeat fallback: gently move during scan if no numeric totals arrive + useEffect(() => { + if (!preparingBusy || suppressPreparingOverlay) return; + const phase = preparingProgress?.phase ?? 'scan'; + if (phase !== 'scan') return; + const id = window.setInterval(() => { + // Only drift if totals are not provided + if (!preparingProgress || (preparingProgress.total ?? 0) === 0) { + setPreparingPercent((prev) => Math.min(0.88, prev + 0.015)); + } + }, 250); + return () => window.clearInterval(id); + }, [preparingBusy, suppressPreparingOverlay, preparingProgress]); + + const schema = useMemo(() => new ProjectSchema(client), [client]); + + const [adminDomain, setAdminDomain] = useState(); + useEffect(() => { + let active = true; + (async () => { + try { + const site = await client.site.find(); + console.log('[ExportPage] site:', site); + const domain = site.internal_domain || site.domain || undefined; + if (active) setAdminDomain(domain); + } catch { + // ignore; links will simply not be shown + } + })(); + return () => { + active = false; + }; + }, [client]); + + // Preload installed plugin IDs once to avoid network calls during selection + const [installedPluginIds, setInstalledPluginIds] = useState< + Set | undefined + >(); + useEffect(() => { + let active = true; + (async () => { + try { + const plugins = await schema.getAllPlugins(); + if (active) setInstalledPluginIds(new Set(plugins.map((p) => p.id))); + } catch (_) { + // ignore; selection will just skip plugin dependencies when unknown + } + })(); + return () => { + active = false; + }; + }, [schema]); + const [lastPreparedForId, setLastPreparedForId] = useState< + string | undefined + >(undefined); useEffect(() => { async function run() { const itemType = await schema.getItemTypeById(initialItemTypeId); setInitialItemType(itemType); + if (lastPreparedForId !== initialItemTypeId) { + try { + if ( + typeof window !== 'undefined' && + window.localStorage?.getItem('schemaDebug') === '1' + ) { + console.log( + `[ExportPage] preparingBusy -> true (init); initialItemTypeId=${initialItemTypeId}`, + ); + } + } catch {} + setPreparingBusy(true); + setPreparingProgress(undefined); + setPreparingPercent(0.1); + setLastPreparedForId(initialItemTypeId); + } } run(); - }, [schema, initialItemTypeId]); + }, [schema, initialItemTypeId, lastPreparedForId]); + + // Progress overlay state for selection export + const [exportBusy, setExportBusy] = useState(false); + const [exportProgress, setExportProgress] = useState< + | { + done: number; + total: number; + label: string; + } + | undefined + >(); + const [exportCancelled, setExportCancelled] = useState(false); + const exportCancelRef = useRef(false); + const [postExportDoc, setPostExportDoc] = useState( + undefined, + ); async function handleExport(itemTypeIds: string[], pluginIds: string[]) { - const exportDoc = await buildExportDoc( - schema, - initialItemTypeId, - itemTypeIds, - pluginIds, - ); - downloadJSON(exportDoc, { fileName: 'export.json', prettify: true }); - ctx.notice('Export completed with success!'); - ctx.navigateTo( - `${ctx.isEnvironmentPrimary ? '' : `/environments/${ctx.environment}`}/configuration/p/${ctx.plugin.id}/pages/import-export`, - ); + try { + setExportBusy(true); + setExportProgress(undefined); + setExportCancelled(false); + exportCancelRef.current = false; + + // Initialize progress bar + const total = pluginIds.length + itemTypeIds.length * 2; + setExportProgress({ done: 0, total, label: 'Preparing export…' }); + let done = 0; + + const exportDoc = await buildExportDoc( + schema, + initialItemTypeId, + itemTypeIds, + pluginIds, + { + onProgress: (label: string) => { + done += 1; + setExportProgress({ done, total, label }); + }, + shouldCancel: () => exportCancelRef.current, + }, + ); + + if (exportCancelRef.current) { + throw new Error('Export cancelled'); + } + + downloadJSON(exportDoc, { fileName: 'export.json', prettify: true }); + setPostExportDoc(exportDoc); + ctx.notice('Export completed with success!'); + } catch (e) { + console.error('Export failed', e); + if (e instanceof Error && e.message === 'Export cancelled') { + ctx.notice('Export canceled'); + } else { + ctx.alert('Could not complete the export. Please try again.'); + } + } finally { + setExportBusy(false); + setExportProgress(undefined); + setExportCancelled(false); + exportCancelRef.current = false; + } } if (!initialItemType) { @@ -62,13 +195,214 @@ export default function ExportPage({ ctx, initialItemTypeId }: Props) { return ( - + {postExportDoc ? ( + + downloadJSON(postExportDoc, { + fileName: 'export.json', + prettify: true, + }) + } + onClose={() => + ctx.navigateTo( + `${ctx.isEnvironmentPrimary ? '' : `/environments/${ctx.environment}`}/configuration/p/${ctx.plugin.id}/pages/export`, + ) + } + /> + ) : ( + { + setPreparingProgress(p); + const phase = p.phase ?? 'scan'; + const hasTotals = (p.total ?? 0) > 0; + if (phase === 'scan') { + if (hasTotals) { + const raw = Math.max(0, Math.min(1, p.done / p.total)); + // Map scan to [0.05, 0.85]; keep monotonic + const mapped = 0.05 + raw * 0.8; + setPreparingPercent((prev) => + Math.max(prev, Math.min(0.88, mapped)), + ); + } + // else: heartbeat drives percent + } else { + if (hasTotals) { + const raw = Math.max(0, Math.min(1, p.done / p.total)); + // Map build to [0.85, 1.00]; keep monotonic + const mapped = 0.85 + raw * 0.15; + setPreparingPercent((prev) => + Math.max(prev, Math.min(1, mapped)), + ); + } + } + }} + onGraphPrepared={() => { + try { + if ( + typeof window !== 'undefined' && + window.localStorage?.getItem('schemaDebug') === '1' + ) { + console.log( + '[ExportPage] onGraphPrepared -> preparingBusy false', + ); + } + } catch {} + setPreparingBusy(false); + setSuppressPreparingOverlay(false); + }} + installedPluginIds={installedPluginIds} + onSelectingDependenciesChange={(busy) => { + // Hide overlay during dependency expansion; release when graph is prepared + if (busy) { + setSuppressPreparingOverlay(true); + setPreparingBusy(false); + } + }} + /> + )} + {preparingBusy && !suppressPreparingOverlay && ( +
    +
    +
    Preparing export
    +
    + Sit tight, we’re setting up your models, blocks, and plugins… +
    + +
    0 + ? preparingProgress.total + : undefined + } + aria-valuenow={ + preparingProgress && (preparingProgress.total ?? 0) > 0 + ? preparingProgress.done + : undefined + } + > +
    +
    +
    +
    + {preparingProgress + ? preparingProgress.label + : 'Preparing export…'} +
    +
    + {preparingProgress && (preparingProgress.total ?? 0) > 0 + ? `${preparingProgress.done} / ${preparingProgress.total}` + : ''} +
    +
    + +
    +
    + )} + {exportBusy && ( +
    +
    +
    Exporting selection
    +
    + Sit tight, we’re gathering models, blocks, and plugins… +
    + +
    +
    +
    +
    +
    + {exportProgress ? exportProgress.label : 'Preparing export…'} +
    +
    + {exportProgress + ? `${exportProgress.done} / ${exportProgress.total}` + : ''} +
    +
    + +
    + +
    +
    +
    + )} ); } diff --git a/import-export-schema/src/entrypoints/ExportPage/styles.module.css b/import-export-schema/src/entrypoints/ExportPage/styles.module.css deleted file mode 100644 index 81e37de8..00000000 --- a/import-export-schema/src/entrypoints/ExportPage/styles.module.css +++ /dev/null @@ -1,3 +0,0 @@ -.inspector { - margin-top: var(--spacing-l); -} diff --git a/import-export-schema/src/entrypoints/ExportPage/useAnimatedNodes.tsx b/import-export-schema/src/entrypoints/ExportPage/useAnimatedNodes.tsx index c17a3164..3a2f7426 100644 --- a/import-export-schema/src/entrypoints/ExportPage/useAnimatedNodes.tsx +++ b/import-export-schema/src/entrypoints/ExportPage/useAnimatedNodes.tsx @@ -1,8 +1,8 @@ -import type { AppNode } from '@/utils/graph/types'; import { useReactFlow } from '@xyflow/react'; import { timer } from 'd3-timer'; import { useEffect, useState } from 'react'; import { easing } from 'ts-easing'; +import type { AppNode } from '@/utils/graph/types'; export function useAnimatedNodes( initialNodes: AppNode[], diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/Collapsible.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/Collapsible.tsx index fb647324..bd3560d0 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/Collapsible.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/Collapsible.tsx @@ -49,15 +49,24 @@ export default function Collapsible({ )} ref={elRef} > -
    +
    -
    +
    {children} -
    +
    ); } diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx index f1673d25..81c01eec 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx @@ -1,6 +1,7 @@ import type { SchemaTypes } from '@datocms/cma-client'; import { useReactFlow } from '@xyflow/react'; import { SelectField, TextField } from 'datocms-react-ui'; +import { useId } from 'react'; import { Field } from 'react-final-form'; import type { GroupBase } from 'react-select'; import { useResolutionStatusForItemType } from '../ResolutionsForm'; @@ -14,6 +15,9 @@ type Props = { }; export function ItemTypeConflict({ exportItemType, projectItemType }: Props) { + const selectId = useId(); + const nameId = useId(); + const apiKeyId = useId(); const fieldPrefix = `itemType-${exportItemType.id}`; const resolution = useResolutionStatusForItemType(exportItemType.id)!; const node = useReactFlow().getNode(`itemType--${exportItemType.id}`); @@ -60,7 +64,7 @@ export function ItemTypeConflict({ exportItemType, projectItemType }: Props) { {({ input, meta: { error } }) => ( > {...input} - id="fieldTypes" + id={selectId} label="To resolve this conflict:" selectInputProps={{ options, @@ -77,7 +81,7 @@ export function ItemTypeConflict({ exportItemType, projectItemType }: Props) { {({ input, meta: { error } }) => ( {({ input, meta: { error } }) => ( ( > {...input} - id="fieldTypes" + id={selectId} label="To resolve this conflict:" selectInputProps={{ options, diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/buildConflicts.ts b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/buildConflicts.ts index 1f81b094..24289eaa 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/buildConflicts.ts +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/buildConflicts.ts @@ -1,7 +1,7 @@ -import type { ExportSchema } from '@/entrypoints/ExportPage/ExportSchema'; -import type { ProjectSchema } from '@/utils/ProjectSchema'; import type { SchemaTypes } from '@datocms/cma-client'; import { keyBy } from 'lodash-es'; +import type { ExportSchema } from '@/entrypoints/ExportPage/ExportSchema'; +import type { ProjectSchema } from '@/utils/ProjectSchema'; export type Conflicts = { plugins: Record; @@ -11,8 +11,15 @@ export type Conflicts = { export default async function buildConflicts( exportSchema: ExportSchema, projectSchema: ProjectSchema, + onProgress?: (p: { done: number; total: number; label: string }) => void, ) { + let done = 0; + const total = 2 + exportSchema.itemTypes.length + exportSchema.plugins.length; + + onProgress?.({ done, total, label: 'Loading models…' }); const projectItemTypes = await projectSchema.getAllItemTypes(); + done += 1; + onProgress?.({ done, total, label: 'Loading plugins…' }); const projectItemTypesByName = keyBy(projectItemTypes, 'attributes.name'); const projectItemTypesByApiKey = keyBy( projectItemTypes, @@ -20,6 +27,8 @@ export default async function buildConflicts( ); const projectPlugins = await projectSchema.getAllPlugins(); + done += 1; + onProgress?.({ done, total, label: 'Scanning item types…' }); const projectPluginsByName = keyBy(projectPlugins, 'attributes.name'); const projectPluginsByUrl = keyBy(projectPlugins, 'attributes.url'); @@ -31,18 +40,27 @@ export default async function buildConflicts( projectItemTypesByApiKey[itemType.attributes.api_key]; if (conflictingItemType) { - conflicts.itemTypes[itemType.id] = conflictingItemType; + conflicts.itemTypes[String(itemType.id)] = conflictingItemType; } + done += 1; + onProgress?.({ + done, + total, + label: `Item type: ${itemType.attributes.name}`, + }); } + onProgress?.({ done, total, label: 'Scanning plugins…' }); for (const plugin of exportSchema.plugins) { const conflictingPlugin = projectPluginsByUrl[plugin.attributes.url] || projectPluginsByName[plugin.attributes.name]; if (conflictingPlugin) { - conflicts.plugins[plugin.id] = conflictingPlugin; + conflicts.plugins[String(plugin.id)] = conflictingPlugin; } + done += 1; + onProgress?.({ done, total, label: `Plugin: ${plugin.attributes.name}` }); } return conflicts; diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx index 94a5dddd..f3285bfd 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx @@ -1,20 +1,61 @@ +import type { SchemaTypes } from '@datocms/cma-client'; +import type { RenderPageCtx } from 'datocms-plugin-sdk'; +import { Button, Spinner, TextField } from 'datocms-react-ui'; +import { defaults, groupBy, map, mapValues, sortBy } from 'lodash-es'; +import { useContext, useId, useMemo, useState } from 'react'; +import { flushSync } from 'react-dom'; +import { useForm, useFormState } from 'react-final-form'; import type { ExportSchema } from '@/entrypoints/ExportPage/ExportSchema'; import { getTextWithoutRepresentativeEmojiAndPadding } from '@/utils/emojiAgnosticSorter'; -import { Button, Toolbar, ToolbarStack, ToolbarTitle } from 'datocms-react-ui'; -import { defaults, groupBy, map, mapValues, sortBy } from 'lodash-es'; -import { useContext } from 'react'; -import { useFormState } from 'react-final-form'; +import type { ProjectSchema } from '@/utils/ProjectSchema'; import { ConflictsContext } from './ConflictsContext'; import { ItemTypeConflict } from './ItemTypeConflict'; import { PluginConflict } from './PluginConflict'; type Props = { exportSchema: ExportSchema; + schema: ProjectSchema; + // ctx is currently unused; keep for future enhancements + ctx?: RenderPageCtx; }; -export default function ConflictsManager({ exportSchema }: Props) { +export default function ConflictsManager({ + exportSchema, + schema: _schema, +}: Props) { const conflicts = useContext(ConflictsContext); - const { submitting, valid } = useFormState(); + const { submitting, valid, validating } = useFormState(); + const form = useForm(); + const [nameSuffix, setNameSuffix] = useState(' (Import)'); + const [apiKeySuffix, setApiKeySuffix] = useState('import'); + const nameSuffixId = useId(); + const apiKeySuffixId = useId(); + // Mass action toggles — applied on submit only + const [selectedItemTypesAction, setSelectedItemTypesAction] = useState< + 'rename' | 'reuse' | null + >(null); + const [selectedPluginsAction, setSelectedPluginsAction] = useState< + 'reuse' | 'skip' | null + >(null); + const anyBusy = false; + const apiKeySuffixError = useMemo(() => { + // Canonical DatoCMS API key pattern for the final key: + // ^[a-z][a-z0-9_]*[a-z0-9]$ + // We validate the suffix independently but with equivalent character rules + if (!apiKeySuffix || apiKeySuffix.length === 0) { + return 'API key suffix is required'; + } + if (!/^[a-z0-9_]+$/.test(apiKeySuffix)) { + return 'Only lowercase letters, digits and underscores allowed'; + } + if (!/^[a-z]/.test(apiKeySuffix)) { + return 'Suffix must start with a lowercase letter'; + } + if (!/[a-z0-9]$/.test(apiKeySuffix)) { + return 'Suffix must end with a letter or digit'; + } + return undefined; + }, [apiKeySuffix]); if (!conflicts) { return null; @@ -27,60 +68,245 @@ export default function ConflictsManager({ exportSchema }: Props) { const groupedItemTypes = defaults( mapValues( groupBy( - map(conflicts.itemTypes, (projectItemType, exportItemTypeId) => { - const exportItemType = exportSchema.getItemTypeById(exportItemTypeId); - return { exportItemTypeId, exportItemType, projectItemType }; - }), - ({ exportItemType }) => + map( + conflicts.itemTypes, + (projectItemType: SchemaTypes.ItemType, exportItemTypeId: string) => { + const exportItemType = + exportSchema.getItemTypeById(exportItemTypeId); + return { exportItemTypeId, exportItemType, projectItemType }; + }, + ), + ({ exportItemType }: { exportItemType: SchemaTypes.ItemType }) => exportItemType?.attributes.modular_block ? 'blocks' : 'models', ), - (group) => - sortBy(group, ({ exportItemType }) => - getTextWithoutRepresentativeEmojiAndPadding( - exportItemType.attributes.name, - ), + (group: Array<{ exportItemType: SchemaTypes.ItemType }>) => + sortBy( + group, + ({ exportItemType }: { exportItemType: SchemaTypes.ItemType }) => + getTextWithoutRepresentativeEmojiAndPadding( + exportItemType.attributes.name, + ), ), ), { blocks: [], models: [] }, ); const sortedPlugins = sortBy( - map(conflicts.plugins, (projectPlugin, exportPluginId) => { - const exportPlugin = exportSchema.getPluginById(exportPluginId); - return { exportPluginId, exportPlugin, projectPlugin }; - }), - ({ exportPlugin }) => exportPlugin.attributes.name, + map( + conflicts.plugins, + (projectPlugin: SchemaTypes.Plugin, exportPluginId: string) => { + const exportPlugin = exportSchema.getPluginById(exportPluginId); + return { exportPluginId, exportPlugin, projectPlugin }; + }, + ), + ({ exportPlugin }: { exportPlugin: SchemaTypes.Plugin }) => + exportPlugin.attributes.name, ); return (
    - - - Import conflicts -
    - - +
    +
    + Import conflicts +
    +
    {noPotentialConflicts ? (

    - No conflicts have been found with existing schema of this project! + No conflicts have been found with the existing schema in this + project.

    ) : (

    Some conflicts exist with the current schema in this project. - Before importing, we need to determine how to handle them. + Before importing, choose how to handle them below.

    )}
    + {!noPotentialConflicts && ( +
    + {anyBusy && ( +
    + +
    + )} + +
    +
    +
    + Models & blocks (Select One) +
    +
    +
    + + +
    + + {selectedItemTypesAction === 'rename' && ( +
    +
    + Default suffixes +
    +
    + { + setNameSuffix(val); + form.change('mass.nameSuffix', val); + }} + /> + { + setApiKeySuffix(val); + form.change('mass.apiKeySuffix', val); + }} + error={apiKeySuffixError} + /> +
    +
    + These suffixes will be used if you choose to rename + conflicting models/blocks. +
    +
    + )} +
    +
    + +
    +
    Plugins (Select One)
    +
    +
    + + +
    +
    +
    +
    +
    + )} +
    {groupedItemTypes.models.length > 0 && (
    -
    Models
    +
    + Models ({groupedItemTypes.models.length}) +
    {groupedItemTypes.models.map( - ({ exportItemTypeId, exportItemType, projectItemType }) => ( + ({ + exportItemTypeId, + exportItemType, + projectItemType, + }: { + exportItemTypeId: string; + exportItemType: SchemaTypes.ItemType; + projectItemType: SchemaTypes.ItemType; + }) => ( 0 && (
    - Block models + Block models ({groupedItemTypes.blocks.length})
    {groupedItemTypes.blocks.map( - ({ exportItemTypeId, exportItemType, projectItemType }) => ( + ({ + exportItemTypeId, + exportItemType, + projectItemType, + }: { + exportItemTypeId: string; + exportItemType: SchemaTypes.ItemType; + projectItemType: SchemaTypes.ItemType; + }) => ( 0 && (
    -
    Plugins
    +
    + Plugins ({sortedPlugins.length}) +
    {sortedPlugins.map( - ({ exportPluginId, exportPlugin, projectPlugin }) => ( + ({ + exportPluginId, + exportPlugin, + projectPlugin, + }: { + exportPluginId: string; + exportPlugin: SchemaTypes.Plugin; + projectPlugin: SchemaTypes.Plugin; + }) => (
    + {/** Precompute disabled state to attach tooltip when needed */} + {(() => { + return null; + })()} + {(() => { + const proceedDisabled = submitting || !valid || validating || anyBusy; + return ( +
    + +
    + ); + })()}

    The import will never alter any existing elements in the schema.

    diff --git a/import-export-schema/src/entrypoints/ImportPage/FileDropZone.tsx b/import-export-schema/src/entrypoints/ImportPage/FileDropZone.tsx index 48cb0ca0..59d0d94a 100644 --- a/import-export-schema/src/entrypoints/ImportPage/FileDropZone.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/FileDropZone.tsx @@ -1,10 +1,10 @@ -import type { ExportDoc } from '@/utils/types'; import { faFolderOpen } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import type { RenderPageCtx } from 'datocms-plugin-sdk'; import { Button, useCtx } from 'datocms-react-ui'; import type React from 'react'; import { type ReactNode, useCallback, useRef, useState } from 'react'; +import type { ExportDoc } from '@/utils/types'; type Props = { onJsonDrop: (filename: string, exportDoc: ExportDoc) => void; @@ -14,24 +14,36 @@ type Props = { export default function FileDropZone({ onJsonDrop, children }: Props) { const ctx = useCtx(); const fileInputRef = useRef(null); + // Track nested dragenter/leaves so moving over children does not cancel pending state + const dragDepthRef = useRef(0); const [pendingDrop, setPendingDrop] = useState(false); - const handleDragOver = useCallback((e: React.DragEvent) => { + const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); }, []); - const handleDragEnter = useCallback((e: React.DragEvent) => { + const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); - setPendingDrop(true); + // Increment depth on every dragenter (including children) + dragDepthRef.current += 1; + // Only show pending if a file is being dragged + const hasFiles = Array.from(e.dataTransfer?.types ?? []).includes('Files'); + if (hasFiles) { + setPendingDrop(true); + } }, []); - const handleDragLeave = useCallback((e: React.DragEvent) => { + const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); - setPendingDrop(false); + // Decrement depth; only clear pending when leaving root entirely + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) { + setPendingDrop(false); + } }, []); const handleFileSelection = useCallback( @@ -55,7 +67,7 @@ export default function FileDropZone({ onJsonDrop, children }: Props) { const json = JSON.parse(result) as ExportDoc; onJsonDrop(file.name, json); - } catch (err) { + } catch (_err) { ctx.alert('Invalid JSON format'); } }; @@ -65,10 +77,12 @@ export default function FileDropZone({ onJsonDrop, children }: Props) { ); const handleDrop = useCallback( - (e: React.DragEvent) => { + (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); + // Reset depth and pending state on drop + dragDepthRef.current = 0; setPendingDrop(false); const file = e.dataTransfer.files[0]; @@ -96,12 +110,13 @@ export default function FileDropZone({ onJsonDrop, children }: Props) { ); return ( -
    {children( <> @@ -122,6 +137,6 @@ export default function FileDropZone({ onJsonDrop, children }: Props) { , )} -
    + ); } diff --git a/import-export-schema/src/entrypoints/ImportPage/ImportItemTypeNodeRenderer.tsx b/import-export-schema/src/entrypoints/ImportPage/ImportItemTypeNodeRenderer.tsx index 41e94e89..bfc16823 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ImportItemTypeNodeRenderer.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ImportItemTypeNodeRenderer.tsx @@ -1,3 +1,6 @@ +import type { NodeProps } from '@xyflow/react'; +import classNames from 'classnames'; +import { useContext } from 'react'; import { type ItemTypeNode, ItemTypeNodeRenderer, @@ -5,9 +8,6 @@ import { import { ConflictsContext } from '@/entrypoints/ImportPage/ConflictsManager/ConflictsContext'; import { useResolutionStatusForItemType } from '@/entrypoints/ImportPage/ResolutionsForm'; import { SelectedEntityContext } from '@/entrypoints/ImportPage/SelectedEntityContext'; -import type { NodeProps } from '@xyflow/react'; -import classNames from 'classnames'; -import { useContext } from 'react'; export function ImportItemTypeNodeRenderer(props: NodeProps) { const { itemType } = props.data; @@ -36,8 +36,8 @@ export function ImportItemTypeNodeRenderer(props: NodeProps) { apiKey={resolutionNewApiKey || itemType.attributes.api_key} className={classNames( unresolvedConflict && 'app-node--conflict', - resolutionStrategyIsReuseExisting && 'app-node__excluded-from-export', - selectedEntityContext.entity === itemType && 'app-node__focused', + resolutionStrategyIsReuseExisting && 'app-node--excluded', + selectedEntityContext.entity === itemType && 'app-node--focused', )} /> ); diff --git a/import-export-schema/src/entrypoints/ImportPage/ImportPluginNodeRenderer.tsx b/import-export-schema/src/entrypoints/ImportPage/ImportPluginNodeRenderer.tsx index d82574bf..d1c9d03c 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ImportPluginNodeRenderer.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ImportPluginNodeRenderer.tsx @@ -1,12 +1,12 @@ +import type { NodeProps } from '@xyflow/react'; +import classNames from 'classnames'; +import { useContext } from 'react'; import { type PluginNode, PluginNodeRenderer, } from '@/components/PluginNodeRenderer'; import { ConflictsContext } from '@/entrypoints/ImportPage/ConflictsManager/ConflictsContext'; import { SelectedEntityContext } from '@/entrypoints/ImportPage/SelectedEntityContext'; -import type { NodeProps } from '@xyflow/react'; -import classNames from 'classnames'; -import { useContext } from 'react'; import { useResolutionStatusForPlugin } from './ResolutionsForm'; export function ImportPluginNodeRenderer(props: NodeProps) { @@ -21,8 +21,8 @@ export function ImportPluginNodeRenderer(props: NodeProps) { {...props} className={classNames( conflict && resolution?.invalid && 'app-node--conflict', - conflict && !resolution?.invalid && 'app-node__excluded-from-export', - selectedEntityContext.entity === plugin && 'app-node__focused', + conflict && !resolution?.invalid && 'app-node--excluded', + selectedEntityContext.entity === plugin && 'app-node--focused', )} /> ); diff --git a/import-export-schema/src/entrypoints/ImportPage/Inner.tsx b/import-export-schema/src/entrypoints/ImportPage/Inner.tsx index f7c7812f..6543662e 100644 --- a/import-export-schema/src/entrypoints/ImportPage/Inner.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/Inner.tsx @@ -1,4 +1,3 @@ -import { type AppNode, type Graph, edgeTypes } from '@/utils/graph/types'; import type { SchemaTypes } from '@datocms/cma-client'; import { Background, @@ -7,15 +6,17 @@ import { ReactFlow, useReactFlow, } from '@xyflow/react'; -import { VerticalSplit } from 'datocms-react-ui'; import { useCallback, useEffect, useState } from 'react'; +import { type AppNode, edgeTypes, type Graph } from '@/utils/graph/types'; +import type { ProjectSchema } from '@/utils/ProjectSchema'; import type { ExportSchema } from '../ExportPage/ExportSchema'; +import { buildGraphFromExportDoc } from './buildGraphFromExportDoc'; import ConflictsManager from './ConflictsManager'; import { ImportItemTypeNodeRenderer } from './ImportItemTypeNodeRenderer'; import { ImportPluginNodeRenderer } from './ImportPluginNodeRenderer'; +import LargeSelectionView from './LargeSelectionView'; import { useSkippedItemsAndPluginIds } from './ResolutionsForm'; import { SelectedEntityContext } from './SelectedEntityContext'; -import { buildGraphFromExportDoc } from './buildGraphFromExportDoc'; const nodeTypes: NodeTypes = { itemType: ImportItemTypeNodeRenderer, @@ -24,9 +25,11 @@ const nodeTypes: NodeTypes = { type Props = { exportSchema: ExportSchema; + schema: ProjectSchema; + ctx: import('datocms-plugin-sdk').RenderPageCtx; }; -export function Inner({ exportSchema }: Props) { +export function Inner({ exportSchema, schema, ctx: _ctx }: Props) { const { fitBounds, fitView } = useReactFlow(); const { skippedItemTypeIds, skippedPluginIds } = useSkippedItemsAndPluginIds(); @@ -74,7 +77,8 @@ export function Inner({ exportSchema }: Props) { ? node.type === 'plugin' && node.data.plugin.id === newEntity.id : node.type === 'itemType' && node.data.itemType.id === newEntity.id, - )!; + ); + if (!node) return; fitBounds( { x: node.position.x, y: node.position.y, width: 200, height: 200 }, @@ -86,35 +90,99 @@ export function Inner({ exportSchema }: Props) { } } + const GRAPH_NODE_THRESHOLD = 60; + + const totalPotentialNodes = + exportSchema.itemTypes.length + exportSchema.plugins.length; + + const showGraph = + !!graph && + graph.nodes.length <= GRAPH_NODE_THRESHOLD && + totalPotentialNodes <= GRAPH_NODE_THRESHOLD; + return ( - -
    - {graph && ( - setSelectedEntity(undefined)} - onNodeClick={onNodeClick} - > - - - )} -
    -
    - -
    -
    +
    +
    +
    + {graph && showGraph && ( + setSelectedEntity(undefined)} + onNodeClick={onNodeClick} + > + + + )} + {graph && !showGraph && ( + <> + {/* List view for large selections */} + handleSelectEntity(entity, true)} + /> + {/* Hidden ReactFlow to keep nodes available for Conflicts UI */} +
    + +
    + + )} +
    +
    +
    +
    + +
    +
    +
    ); } diff --git a/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.tsx b/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.tsx new file mode 100644 index 00000000..ba4c6397 --- /dev/null +++ b/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.tsx @@ -0,0 +1,291 @@ +import type { SchemaTypes } from '@datocms/cma-client'; +import { Button, TextField } from 'datocms-react-ui'; +import { useContext, useId, useMemo, useState } from 'react'; +import { + countCycles, + findInboundEdges, + findOutboundEdges, + getConnectedComponents, + splitNodesByType, +} from '@/utils/graph/analysis'; +import type { Graph } from '@/utils/graph/types'; +import { SelectedEntityContext } from './SelectedEntityContext'; + +type Props = { + graph: Graph; + onSelect: (entity: SchemaTypes.ItemType | SchemaTypes.Plugin) => void; +}; + +export default function LargeSelectionView({ graph, onSelect }: Props) { + const searchInputId = useId(); + const [query, setQuery] = useState(''); + const selected = useContext(SelectedEntityContext).entity; + + const { itemTypeNodes, pluginNodes } = useMemo( + () => splitNodesByType(graph), + [graph], + ); + + const components = useMemo(() => getConnectedComponents(graph), [graph]); + const cycles = useMemo(() => countCycles(graph), [graph]); + + const filteredItemTypeNodes = useMemo(() => { + if (!query) return itemTypeNodes; + const q = query.toLowerCase(); + return itemTypeNodes.filter((n) => { + const it = n.data.itemType; + return ( + it.attributes.name.toLowerCase().includes(q) || + it.attributes.api_key.toLowerCase().includes(q) + ); + }); + }, [itemTypeNodes, query]); + + const filteredPluginNodes = useMemo(() => { + if (!query) return pluginNodes; + const q = query.toLowerCase(); + return pluginNodes.filter((n) => + n.data.plugin.attributes.name.toLowerCase().includes(q), + ); + }, [pluginNodes, query]); + + return ( +
    +
    +
    + {itemTypeNodes.length} models • {pluginNodes.length} plugins •{' '} + {graph.edges.length} relations +
    +
    + Components: {components.length} • Cycles: {cycles} +
    +
    + Graph view is hidden due to size. +
    +
    + setQuery(val)} + textInputProps={{ autoComplete: 'off' }} + /> +
    +
    + +
    +
    + Models +
      + {filteredItemTypeNodes.map((n) => { + const it = n.data.itemType; + const inbound = findInboundEdges(graph, `itemType--${it.id}`); + const outbound = findOutboundEdges(graph, `itemType--${it.id}`); + const isSelected = + selected?.type === 'item_type' && selected.id === it.id; + + return ( +
    • +
      onSelect(it)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(it); + } + }} + style={{ + border: 0, + background: isSelected + ? 'rgba(51, 94, 234, 0.08)' + : 'transparent', + borderRadius: 6, + width: '100%', + textAlign: 'left', + padding: '8px 8px', + cursor: 'pointer', + }} + aria-pressed={isSelected} + > +
      +
      +
      + {it.attributes.name}{' '} + + ({it.attributes.api_key}) + {' '} + + {it.attributes.modular_block ? 'Block' : 'Model'} + +
      +
      + + ← {inbound.length} inbound + {' '} + •{' '} + + → {outbound.length} outbound + +
      +
      +
      + +
      +
      +
      +
    • + ); + })} +
    + + Plugins +
      + {filteredPluginNodes.map((n) => { + const pl = n.data.plugin; + const inbound = findInboundEdges(graph, `plugin--${pl.id}`); + const isSelected = + selected?.type === 'plugin' && selected.id === pl.id; + + return ( +
    • +
      onSelect(pl)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(pl); + } + }} + style={{ + border: 0, + background: isSelected + ? 'rgba(51, 94, 234, 0.08)' + : 'transparent', + borderRadius: 6, + width: '100%', + textAlign: 'left', + padding: '8px 8px', + cursor: 'pointer', + }} + aria-pressed={isSelected} + > +
      +
      +
      + {pl.attributes.name} +
      +
      + ← {inbound.length} inbound from models +
      +
      +
      + +
      +
      +
      +
    • + ); + })} +
    +
    +
    +
    + ); +} + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +
    + {children} +
    + ); +} diff --git a/import-export-schema/src/entrypoints/ImportPage/PostImportSummary.tsx b/import-export-schema/src/entrypoints/ImportPage/PostImportSummary.tsx new file mode 100644 index 00000000..c2b5aef3 --- /dev/null +++ b/import-export-schema/src/entrypoints/ImportPage/PostImportSummary.tsx @@ -0,0 +1,995 @@ +import type { SchemaTypes } from '@datocms/cma-client'; +import { Button, TextField } from 'datocms-react-ui'; +import { useId, useMemo, useState } from 'react'; +import { + findLinkedItemTypeIds, + findLinkedPluginIds, +} from '@/utils/datocms/schema'; +import type { ExportSchema } from '../ExportPage/ExportSchema'; +import type { ImportDoc } from './buildImportDoc'; + +type Props = { + exportSchema: ExportSchema; + importDoc: ImportDoc; + adminDomain?: string; + idByApiKey?: Record; + pluginIdByName?: Record; + fieldIdByExportId?: Record; + onClose: () => void; +}; + +export default function PostImportSummary({ + exportSchema, + importDoc, + adminDomain, + idByApiKey, + pluginIdByName, + fieldIdByExportId, + onClose, +}: Props) { + const searchId = useId(); + const createdEntries = importDoc.itemTypes.entitiesToCreate; + const adminOrigin = useMemo( + () => (adminDomain ? `https://${adminDomain}` : undefined), + [adminDomain], + ); + // Map export item type ID -> final API key after rename (or original if unchanged) + const finalApiKeyByExportItemTypeId = useMemo(() => { + const map = new Map(); + for (const e of importDoc.itemTypes.entitiesToCreate) { + map.set( + String(e.entity.id), + e.rename?.apiKey || e.entity.attributes.api_key, + ); + } + return map; + }, [importDoc]); + const createdItemTypes = useMemo( + () => createdEntries.map((e) => e.entity), + [createdEntries], + ); + const createdPlugins = useMemo( + () => importDoc.plugins.entitiesToCreate, + [importDoc], + ); + // counts computed directly from arrays below + const reusedItemTypesCount = useMemo( + () => Object.keys(importDoc.itemTypes.idsToReuse).length, + [importDoc], + ); + const reusedPluginsCount = useMemo( + () => Object.keys(importDoc.plugins.idsToReuse).length, + [importDoc], + ); + + const createdModels = useMemo( + () => createdEntries.filter((e) => !e.entity.attributes.modular_block), + [createdEntries], + ); + const createdBlocks = useMemo( + () => createdEntries.filter((e) => e.entity.attributes.modular_block), + [createdEntries], + ); + + const pluginStateById = useMemo(() => { + const map = new Map(); + for (const pl of exportSchema.plugins) { + const id = String(pl.id); + if (importDoc.plugins.entitiesToCreate.find((p) => String(p.id) === id)) { + map.set(id, 'created'); + } else if (id in importDoc.plugins.idsToReuse) { + map.set(id, 'reused'); + } else { + map.set(id, 'skipped'); + } + } + return map; + }, [exportSchema, importDoc]); + + const createdItemTypeIdSet = useMemo(() => { + const set = new Set(); + for (const e of createdEntries) set.add(String(e.entity.id)); + return set; + }, [createdEntries]); + + const connections = useMemo( + () => + buildConnections( + exportSchema, + createdItemTypes, + pluginStateById, + createdItemTypeIdSet, + ), + [exportSchema, createdItemTypes, pluginStateById, createdItemTypeIdSet], + ); + + const connectionsById = useMemo(() => { + const map = new Map< + string, + { + linkedItemTypes: Array<{ + target: SchemaTypes.ItemType; + fields: SchemaTypes.Field[]; + }>; + linkedPlugins: Array<{ + plugin: SchemaTypes.Plugin; + fields: SchemaTypes.Field[]; + }>; + } + >(); + for (const c of connections) { + map.set(c.itemType.id, { + linkedItemTypes: c.linkedItemTypes, + linkedPlugins: c.linkedPlugins, + }); + } + return map; + }, [connections]); + + const renamedItems = useMemo( + () => + importDoc.itemTypes.entitiesToCreate.filter( + (e): e is typeof e & { rename: { name: string; apiKey: string } } => + Boolean(e.rename), + ), + [importDoc], + ); + + const createdFields = useMemo( + () => importDoc.itemTypes.entitiesToCreate.flatMap((e) => e.fields), + [importDoc], + ); + const createdFieldsets = useMemo( + () => importDoc.itemTypes.entitiesToCreate.flatMap((e) => e.fieldsets), + [importDoc], + ); + + const [contentQuery, setContentQuery] = useState(''); + const chipStyle = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: '72px', + height: '18px', + fontSize: '10px', + padding: '0 4px', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + } as const; + + const filteredModels = useMemo(() => { + const q = contentQuery.trim().toLowerCase(); + if (!q) return createdModels; + return createdModels.filter((e) => { + const name = (e.rename?.name || e.entity.attributes.name).toLowerCase(); + const apiKey = ( + e.rename?.apiKey || e.entity.attributes.api_key + ).toLowerCase(); + return name.includes(q) || apiKey.includes(q); + }); + }, [createdModels, contentQuery]); + + const filteredBlocks = useMemo(() => { + const q = contentQuery.trim().toLowerCase(); + if (!q) return createdBlocks; + return createdBlocks.filter((e) => { + const name = (e.rename?.name || e.entity.attributes.name).toLowerCase(); + const apiKey = ( + e.rename?.apiKey || e.entity.attributes.api_key + ).toLowerCase(); + return name.includes(q) || apiKey.includes(q); + }); + }, [createdBlocks, contentQuery]); + + const filteredPlugins = useMemo(() => { + const q = contentQuery.trim().toLowerCase(); + if (!q) return createdPlugins; + return createdPlugins.filter((pl) => + pl.attributes.name.toLowerCase().includes(q), + ); + }, [createdPlugins, contentQuery]); + + const filteredFields = useMemo(() => { + const q = contentQuery.trim().toLowerCase(); + if (!q) return createdFields; + return createdFields.filter((f) => { + const label = (f.attributes.label || '').toLowerCase(); + const apiKey = f.attributes.api_key.toLowerCase(); + return label.includes(q) || apiKey.includes(q); + }); + }, [createdFields, contentQuery]); + + const filteredFieldsets = useMemo(() => { + const q = contentQuery.trim().toLowerCase(); + if (!q) return createdFieldsets; + return createdFieldsets.filter((fs) => + (fs.attributes.title || '').toLowerCase().includes(q), + ); + }, [createdFieldsets, contentQuery]); + + type SectionKey = + | 'models' + | 'blocks' + | 'plugins' + | 'fields' + | 'fieldsets' + | 'reused' + | 'renames'; + const [activeSection, setActiveSection] = useState('models'); + const sections: Array<{ key: SectionKey; label: string; count: number }> = [ + { key: 'models', label: 'Models', count: createdModels.length }, + { key: 'blocks', label: 'Blocks', count: createdBlocks.length }, + { key: 'plugins', label: 'Plugins', count: createdPlugins.length }, + { key: 'fields', label: 'Fields', count: createdFields.length }, + { key: 'fieldsets', label: 'Fieldsets', count: createdFieldsets.length }, + { + key: 'reused', + label: 'Reused', + count: reusedItemTypesCount + reusedPluginsCount, + }, + { key: 'renames', label: 'Renames', count: renamedItems.length }, + ]; + + return ( +
    +
    +
    +
    +
    + {sections.map((s) => ( + + ))} +
    + +
    +
    +
    + {activeSection === 'models' && ( + <> +
    + Models ({createdModels.length}) +
    +
    + setContentQuery(val)} + textInputProps={{ autoComplete: 'off' }} + /> +
    + { + const name = e.rename?.name || e.entity.attributes.name; + const apiKey = + e.rename?.apiKey || e.entity.attributes.api_key; + const targetId = idByApiKey?.[apiKey]; + return ( +
  • + {adminOrigin && targetId ? ( + + + {name}{' '} + + {apiKey} + + + + + {connectionsById.get(String(e.entity.id)) + ?.linkedItemTypes.length ?? 0}{' '} + links + + + {connectionsById.get(String(e.entity.id)) + ?.linkedPlugins.length ?? 0}{' '} + plugins + + + + ) : ( + <> + {name}{' '} + + {apiKey} + + + + {connectionsById.get(String(e.entity.id)) + ?.linkedItemTypes.length ?? 0}{' '} + links + + + {connectionsById.get(String(e.entity.id)) + ?.linkedPlugins.length ?? 0}{' '} + plugins + + + + )} +
  • + ); + }} + /> + + )} + {activeSection === 'blocks' && ( + <> +
    + Blocks ({createdBlocks.length}) +
    +
    + setContentQuery(val)} + textInputProps={{ autoComplete: 'off' }} + /> +
    + { + const name = e.rename?.name || e.entity.attributes.name; + const apiKey = + e.rename?.apiKey || e.entity.attributes.api_key; + const targetId = idByApiKey?.[apiKey]; + return ( +
  • + {adminOrigin && targetId ? ( + + + {name}{' '} + + {apiKey} + + + + + {connectionsById.get(String(e.entity.id)) + ?.linkedItemTypes.length ?? 0}{' '} + links + + + {connectionsById.get(String(e.entity.id)) + ?.linkedPlugins.length ?? 0}{' '} + plugins + + + + ) : ( + <> + {name}{' '} + + {apiKey} + + + + {connectionsById.get(String(e.entity.id)) + ?.linkedItemTypes.length ?? 0}{' '} + links + + + {connectionsById.get(String(e.entity.id)) + ?.linkedPlugins.length ?? 0}{' '} + plugins + + + + )} +
  • + ); + }} + /> + + )} + {activeSection === 'plugins' && ( + <> +
    + Plugins ({createdPlugins.length}) +
    +
    + setContentQuery(val)} + textInputProps={{ autoComplete: 'off' }} + /> +
    +
      + {filteredPlugins.length > 0 ? ( + filteredPlugins.map((pl) => { + const name = pl.attributes.name; + const pluginId = pluginIdByName?.[name]; + const href = + adminOrigin && pluginId + ? `${adminOrigin}/configuration/plugins/${pluginId}/edit` + : undefined; + return ( +
    • + {href ? ( + + {name} + + ) : ( + name + )} +
    • + ); + }) + ) : ( +
    • No plugins
    • + )} +
    + + )} + {activeSection === 'fields' && ( + <> +
    + Fields ({createdFields.length}) +
    +
    + setContentQuery(val)} + textInputProps={{ autoComplete: 'off' }} + /> +
    + { + const label = f.attributes.label || f.attributes.api_key; + const parentId = String( + (f as SchemaTypes.Field).relationships.item_type.data + .id, + ); + const parent = exportSchema.itemTypesById.get(parentId); + const isBlockParent = + parent?.attributes.modular_block === true; + const basePath = isBlockParent + ? '/schema/blocks_library' + : '/schema/item_types'; + const finalParentApiKey = + finalApiKeyByExportItemTypeId.get(parentId) || + parent?.attributes.api_key; + const targetParentId = finalParentApiKey + ? idByApiKey?.[finalParentApiKey] + : undefined; + const newFieldId = + fieldIdByExportId?.[String(f.id)] || String(f.id); + const href = + adminOrigin && targetParentId + ? `${adminOrigin}${basePath}/${targetParentId}#f${newFieldId}` + : undefined; + return ( +
  • + {href ? ( + + {label}{' '} + + ({f.attributes.api_key}) + + + ) : ( + + {label}{' '} + + ({f.attributes.api_key}) + + + )} +
  • + ); + }} + /> + + )} + {activeSection === 'fieldsets' && ( + <> +
    + Fieldsets ({createdFieldsets.length}) +
    +
    + setContentQuery(val)} + textInputProps={{ autoComplete: 'off' }} + /> +
    + { + const parent = exportSchema.itemTypes.find((it) => + exportSchema + .getItemTypeFieldsets(it) + .some((x) => String(x.id) === String(fs.id)), + ); + const isBlockParent = + parent?.attributes.modular_block === true; + const basePath = isBlockParent + ? '/schema/blocks_library' + : '/schema/item_types'; + const finalParentApiKey = parent + ? finalApiKeyByExportItemTypeId.get( + String(parent.id), + ) || parent.attributes.api_key + : undefined; + const targetParentId = finalParentApiKey + ? idByApiKey?.[finalParentApiKey] + : undefined; + const href = + parent && adminOrigin && targetParentId + ? `${adminOrigin}${basePath}/${targetParentId}` + : undefined; + return ( +
  • + {href ? ( + + {fs.attributes.title} + + ) : ( + + {fs.attributes.title} + + )} +
  • + ); + }} + /> + + )} + {activeSection === 'reused' && ( + <> +
    Reused
    +
    + {reusedItemTypesCount > 0 && ( + +
    Reused models/blocks
    +
    + {reusedItemTypesCount} reused +
    +
    + )} + {reusedPluginsCount > 0 && ( + +
    Reused plugins
    +
    + {reusedPluginsCount} reused +
    +
    + )} +
    + + )} + {activeSection === 'renames' && ( + <> +
    Renames
    +
    + {renamedItems.length > 0 ? ( +
    +
    Renamed models/blocks
    +
      + {renamedItems.map((r) => { + const from = exportSchema.getItemTypeById( + String(r.entity.id), + ); + const isBlock = + from.attributes.modular_block === true; + const basePath = isBlock + ? '/schema/blocks_library' + : '/schema/item_types'; + const finalApiKey = + r.rename?.apiKey || from.attributes.api_key; + const targetId = idByApiKey?.[finalApiKey]; + const href = + adminOrigin && targetId + ? `${adminOrigin}${basePath}/${targetId}` + : undefined; + return ( +
    • + {href ? ( + + + {from.attributes.name} + {' → '} + {r.rename?.name || ''} + + + ({from.attributes.api_key} + {' → '} + {r.rename?.apiKey || ''}) + + + ) : ( +
      + + {from.attributes.name} + {' → '} + {r.rename?.name || ''} + + + ({from.attributes.api_key} + {' → '} + {r.rename?.apiKey || ''}) + +
      + )} +
    • + ); + })} +
    +
    + ) : ( +
    +
    Renamed models/blocks
    +
    0 renamed
    +
    + )} +
    + + )} +
    +
    +
    +
    +
    + ); +} + +function Box({ children }: { children: React.ReactNode }) { + return
    {children}
    ; +} + +function buildConnections( + exportSchema: ExportSchema, + createdItemTypes: SchemaTypes.ItemType[], + pluginStateById: Map, + createdItemTypeIdSet: Set, +) { + const out = [] as Array<{ + itemType: SchemaTypes.ItemType; + linkedItemTypes: Array<{ + target: SchemaTypes.ItemType; + fields: SchemaTypes.Field[]; + status: 'created' | 'reused'; + }>; + linkedPlugins: Array<{ + plugin: SchemaTypes.Plugin; + fields: SchemaTypes.Field[]; + status: 'created' | 'reused' | 'skipped'; + }>; + }>; + + for (const it of createdItemTypes) { + const fields = exportSchema.getItemTypeFields(it); + const byItemType = new Map(); + const byPlugin = new Map(); + + for (const field of fields) { + for (const linkedId of findLinkedItemTypeIds(field)) { + const arr = byItemType.get(String(linkedId)) || []; + arr.push(field); + byItemType.set(String(linkedId), arr); + } + for (const pluginId of findLinkedPluginIds( + field, + new Set(exportSchema.plugins.map((p) => String(p.id))), + )) { + const arr = byPlugin.get(String(pluginId)) || []; + arr.push(field); + byPlugin.set(String(pluginId), arr); + } + } + + const linkedItemTypes = Array.from(byItemType.entries()) + .map(([targetId, fields]) => { + const target = exportSchema.itemTypesById.get(String(targetId)); + if (!target) return null; // target not in export doc; skip + const status: 'created' | 'reused' = createdItemTypeIdSet.has( + String(targetId), + ) + ? 'created' + : 'reused'; + return { target, fields, status }; + }) + .filter( + ( + v, + ): v is { + target: SchemaTypes.ItemType; + fields: SchemaTypes.Field[]; + status: 'created' | 'reused'; + } => !!v, + ); + + const linkedPlugins = Array.from(byPlugin.entries()).flatMap( + ([pid, fields]) => { + const plugin = exportSchema.pluginsById.get(String(pid)); + if (!plugin) return []; + return [ + { + plugin, + fields, + status: (pluginStateById.get(String(pid)) || 'skipped') as + | 'created' + | 'reused' + | 'skipped', + }, + ]; + }, + ); + + out.push({ itemType: it, linkedItemTypes, linkedPlugins }); + } + + return out; +} + +// Collapsible removed in import summary rework + +function LimitedList({ + items, + renderItem, + initial = 20, +}: { + items: T[]; + renderItem: (item: T) => React.ReactNode; + initial?: number; +}) { + const [limit, setLimit] = useState(initial); + const showingAll = limit >= items.length; + const visible = items.slice(0, limit); + return ( + <> +
      + {visible.map((it) => renderItem(it))} +
    + {items.length > initial && ( +
    + +
    + )} + + ); +} + +// ConnectionsPanel removed; inline counts are shown alongside models/blocks + +// StatusPill removed with ConnectionsPanel diff --git a/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx b/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx index 4884ae6a..5f750b37 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx @@ -1,8 +1,9 @@ -import type { ProjectSchema } from '@/utils/ProjectSchema'; import { useNodes, useReactFlow } from '@xyflow/react'; import { get, keyBy, set } from 'lodash-es'; import { type ReactNode, useContext, useMemo } from 'react'; import { Form as FormHandler, useFormState } from 'react-final-form'; +import type { ItemTypeNode } from '@/components/ItemTypeNodeRenderer'; +import type { ProjectSchema } from '@/utils/ProjectSchema'; import { ConflictsContext } from './ConflictsManager/ConflictsContext'; export type ItemTypeConflictResolutionRename = { @@ -31,7 +32,16 @@ type ItemTypeValues = { }; type PluginValues = { strategy: 'reuseExisting' | 'skip' | null }; -type FormValues = Record; +type MassValues = { + itemTypesStrategy?: 'reuseExisting' | 'rename' | null; + pluginsStrategy?: 'reuseExisting' | 'skip' | null; + nameSuffix?: string; + apiKeySuffix?: string; +}; + +type FormValues = Record & { + mass?: MassValues; +}; type Props = { children: ReactNode; @@ -77,6 +87,12 @@ export default function ResolutionsForm({ schema, children, onSubmit }: Props) { () => conflicts ? { + mass: { + itemTypesStrategy: null, + pluginsStrategy: null, + nameSuffix: ' (Import)', + apiKeySuffix: 'import', + }, ...Object.fromEntries( Object.keys(conflicts.plugins).map((id) => [ `plugin-${id}`, @@ -100,42 +116,126 @@ export default function ResolutionsForm({ schema, children, onSubmit }: Props) { [conflicts], ); - function handleSubmit(values: FormValues) { + async function handleSubmit(values: FormValues) { const resolutions: Resolutions = { itemTypes: {}, plugins: {} }; if (!conflicts) { return resolutions; } + const mass = values.mass; + // Preload project names/apiKeys once to guarantee uniqueness for mass-renames + const projectItemTypes = await schema.getAllItemTypes(); + const usedNames = new Set(projectItemTypes.map((it) => it.attributes.name)); + const usedApiKeys = new Set( + projectItemTypes.map((it) => it.attributes.api_key), + ); + + function computeUniqueRename( + baseName: string, + baseApiKey: string, + nameSuffix: string, + apiKeySuffix: string, + usedNames: Set, + usedApiKeys: Set, + ) { + let name = `${baseName}${nameSuffix}`; + let apiKey = `${baseApiKey}${apiKeySuffix}`; + let i = 2; + while (usedNames.has(name)) { + name = `${baseName}${nameSuffix} ${i}`; + i += 1; + } + i = 2; + while (usedApiKeys.has(apiKey)) { + apiKey = `${baseApiKey}${apiKeySuffix}${i}`; + i += 1; + } + usedNames.add(name); + usedApiKeys.add(apiKey); + return { name, apiKey }; + } + for (const pluginId of Object.keys(conflicts.plugins)) { if (!getNode(`plugin--${pluginId}`)) { continue; } - const result = get(values, [`plugin-${pluginId}`]) as PluginValues; - - resolutions.plugins[pluginId] = { - strategy: result.strategy as 'reuseExisting' | 'skip', - }; + // Apply mass plugin strategy if set; otherwise use per-plugin selection + if (mass?.pluginsStrategy) { + resolutions.plugins[pluginId] = { strategy: mass.pluginsStrategy }; + } else { + const result = get(values, [`plugin-${pluginId}`]) as PluginValues; + if (result?.strategy) { + resolutions.plugins[pluginId] = { + strategy: result.strategy as 'reuseExisting' | 'skip', + }; + } + } } for (const itemTypeId of Object.keys(conflicts.itemTypes)) { - if (!getNode(`itemType--${itemTypeId}`)) { + const node = getNode(`itemType--${itemTypeId}`); + if (!node) { continue; } - const fieldPrefix = `itemType-${itemTypeId}`; - - const result = get(values, fieldPrefix) as ItemTypeValues; - - if (result.strategy === 'reuseExisting') { - resolutions.itemTypes[itemTypeId] = { strategy: 'reuseExisting' }; + const exportItemType = (node.data as ItemTypeNode['data']) + .itemType as import('@datocms/cma-client').SchemaTypes.ItemType; + + if (mass?.itemTypesStrategy) { + if (mass.itemTypesStrategy === 'reuseExisting') { + // Reuse only when modular_block matches; otherwise mass-rename fallback with suffixes + const projectItemType = conflicts.itemTypes[itemTypeId]; + const compatible = + exportItemType.attributes.modular_block === + projectItemType.attributes.modular_block; + if (compatible) { + resolutions.itemTypes[itemTypeId] = { strategy: 'reuseExisting' }; + } else { + // Ensure unique names using suffixes + const { name, apiKey } = computeUniqueRename( + exportItemType.attributes.name, + exportItemType.attributes.api_key, + mass.nameSuffix || ' (Import)', + mass.apiKeySuffix || 'import', + usedNames, + usedApiKeys, + ); + resolutions.itemTypes[itemTypeId] = { + strategy: 'rename', + name, + apiKey, + }; + } + } else if (mass.itemTypesStrategy === 'rename') { + const { name, apiKey } = computeUniqueRename( + exportItemType.attributes.name, + exportItemType.attributes.api_key, + mass.nameSuffix || ' (Import)', + mass.apiKeySuffix || 'import', + usedNames, + usedApiKeys, + ); + resolutions.itemTypes[itemTypeId] = { + strategy: 'rename', + name, + apiKey, + }; + } } else { - resolutions.itemTypes[itemTypeId] = { - strategy: 'rename', - apiKey: result.apiKey!, - name: result.name!, - }; + const fieldPrefix = `itemType-${itemTypeId}`; + const result = get(values, fieldPrefix) as ItemTypeValues; + + if (result?.strategy === 'reuseExisting') { + resolutions.itemTypes[itemTypeId] = { strategy: 'reuseExisting' }; + } else if (result?.strategy === 'rename') { + resolutions.itemTypes[itemTypeId] = { + strategy: 'rename', + apiKey: result.apiKey!, + name: result.name!, + }; + } } } @@ -160,15 +260,18 @@ export default function ResolutionsForm({ schema, children, onSubmit }: Props) { const itemTypesByName = keyBy(projectItemTypes, 'attributes.name'); const itemTypesByApiKey = keyBy(projectItemTypes, 'attributes.api_key'); + const mass = values.mass; + for (const pluginId of Object.keys(conflicts.plugins)) { if (!getNode(`plugin--${pluginId}`)) { continue; } const fieldPrefix = `plugin-${pluginId}`; - - if (!get(values, [fieldPrefix, 'strategy'])) { - set(errors, [fieldPrefix, 'strategy'], 'Required!'); + if (!mass?.pluginsStrategy) { + if (!get(values, [fieldPrefix, 'strategy'])) { + set(errors, [fieldPrefix, 'strategy'], 'Required!'); + } } } @@ -178,30 +281,45 @@ export default function ResolutionsForm({ schema, children, onSubmit }: Props) { } const fieldPrefix = `itemType-${itemTypeId}`; - - const strategy = get(values, [fieldPrefix, 'strategy']); - - if (!strategy) { - set(errors, [fieldPrefix, 'strategy'], 'Required!'); - } - - if (strategy === 'rename') { - const name = get(values, [fieldPrefix, 'name']); - - if (!name) { - set(errors, [fieldPrefix, 'name'], 'Required!'); - } else if (name in itemTypesByName) { - set(errors, [fieldPrefix, 'name'], 'Already used in project!'); + if (!mass?.itemTypesStrategy) { + const strategy = get(values, [fieldPrefix, 'strategy']); + if (!strategy) { + set(errors, [fieldPrefix, 'strategy'], 'Required!'); } - - const apiKey = get(values, [fieldPrefix, 'apiKey']); - - if (!apiKey) { - set(errors, [fieldPrefix, 'apiKey'], 'Required!'); - } else if (!isValidApiKey(apiKey)) { - set(errors, [fieldPrefix, 'apiKey'], 'Invalid format'); - } else if (apiKey in itemTypesByApiKey) { - set(errors, [fieldPrefix, 'apiKey'], 'Already used in project!'); + if (strategy === 'rename') { + const name = get(values, [fieldPrefix, 'name']); + if (!name) { + set(errors, [fieldPrefix, 'name'], 'Required!'); + } else if (name in itemTypesByName) { + set(errors, [fieldPrefix, 'name'], 'Already used in project!'); + } + const apiKey = get(values, [fieldPrefix, 'apiKey']); + if (!apiKey) { + set(errors, [fieldPrefix, 'apiKey'], 'Required!'); + } else if (!isValidApiKey(apiKey)) { + set(errors, [fieldPrefix, 'apiKey'], 'Invalid format'); + } else if (apiKey in itemTypesByApiKey) { + set( + errors, + [fieldPrefix, 'apiKey'], + 'Already used in project!', + ); + } + } + } else if (mass.itemTypesStrategy === 'rename') { + // Validate mass suffixes + const nameSuffix = mass.nameSuffix || ' (Import)'; + const apiKeySuffix = mass.apiKeySuffix || 'import'; + // Basic validation of apiKeySuffix + if ( + !/^[a-z0-9_]+$/.test(apiKeySuffix) || + !/^[a-z]/.test(apiKeySuffix) || + !/[a-z0-9]$/.test(apiKeySuffix) + ) { + set(errors, ['mass', 'apiKeySuffix'], 'Invalid API key suffix'); + } + if (nameSuffix === undefined) { + set(errors, ['mass', 'nameSuffix'], 'Name suffix required'); } } } diff --git a/import-export-schema/src/entrypoints/ImportPage/buildGraphFromExportDoc.ts b/import-export-schema/src/entrypoints/ImportPage/buildGraphFromExportDoc.ts index 5adcd0c8..3d99854b 100644 --- a/import-export-schema/src/entrypoints/ImportPage/buildGraphFromExportDoc.ts +++ b/import-export-schema/src/entrypoints/ImportPage/buildGraphFromExportDoc.ts @@ -1,89 +1,16 @@ -import { buildHierarchyNodes } from '@/utils/graph/buildHierarchyNodes'; -import { rebuildGraphWithPositionsFromHierarchy } from '@/utils/graph/rebuildGraphWithPositionsFromHierarchy'; +import { buildGraph } from '@/utils/graph/buildGraph'; import type { Graph } from '@/utils/graph/types'; -import type { SchemaTypes } from '@datocms/cma-client'; +import { ExportSchemaSource } from '@/utils/schema/ExportSchemaSource'; import type { ExportSchema } from '../ExportPage/ExportSchema'; -import { - buildEdgesForItemType, - buildItemTypeNode, - buildPluginNode, - deterministicGraphSort, -} from '../ExportPage/buildGraphFromSchema'; - -type QueueItem = SchemaTypes.ItemType | SchemaTypes.Plugin; export async function buildGraphFromExportDoc( exportSchema: ExportSchema, itemTypeIdsToSkip: string[], ): Promise { - const graph: Graph = { nodes: [], edges: [] }; - const queue: QueueItem[][] = [[exportSchema.rootItemType]]; - const processedNodes = new Set(); - - // Process each level of the graph - while (queue.length > 0) { - const currentLevelItems = queue.shift(); - - if (!currentLevelItems) { - throw new Error('Unexpected error: currentLevelItemTypes is undefined'); - } - - const nextLevelQueue = new Set(); - - for (const itemTypeOrPlugin of currentLevelItems) { - if (processedNodes.has(itemTypeOrPlugin)) { - continue; - } - - processedNodes.add(itemTypeOrPlugin); - - if (itemTypeOrPlugin.type === 'item_type') { - const itemType = itemTypeOrPlugin; - - const fields = exportSchema.getItemTypeFields(itemType); - const fieldsets = exportSchema.getItemTypeFieldsets(itemType); - - graph.nodes.push(buildItemTypeNode(itemType, fields, fieldsets)); - - if (itemTypeIdsToSkip.includes(itemType.id)) { - continue; - } - - const [edges, linkedItemTypeIds, linkedPluginIds] = - await buildEdgesForItemType( - itemType, - fields, - exportSchema.rootItemType, - ); - - graph.edges.push(...edges); - - for (const linkedItemTypeId of linkedItemTypeIds) { - const linkedItemType = - exportSchema.itemTypesById.get(linkedItemTypeId)!; - nextLevelQueue.add(linkedItemType); - } - - for (const linkedPluginId of linkedPluginIds) { - const linkedPlugin = exportSchema.pluginsById.get(linkedPluginId)!; - - nextLevelQueue.add(linkedPlugin); - } - } else { - const plugin = itemTypeOrPlugin; - - // Add current node to graph - graph.nodes.push(buildPluginNode(plugin)); - } - } - - // If we have nodes for the next level, add them to the queue - if (nextLevelQueue.size > 0) { - queue.push([...nextLevelQueue]); - } - } - - const sortedGraph = deterministicGraphSort(graph); - const hierarchy = buildHierarchyNodes(sortedGraph); - return rebuildGraphWithPositionsFromHierarchy(hierarchy, sortedGraph.edges); + const source = new ExportSchemaSource(exportSchema); + return buildGraph({ + source, + initialItemTypes: exportSchema.rootItemTypes, + itemTypeIdsToSkip, + }); } diff --git a/import-export-schema/src/entrypoints/ImportPage/buildImportDoc.ts b/import-export-schema/src/entrypoints/ImportPage/buildImportDoc.ts index 341b8c2e..110d9cd0 100644 --- a/import-export-schema/src/entrypoints/ImportPage/buildImportDoc.ts +++ b/import-export-schema/src/entrypoints/ImportPage/buildImportDoc.ts @@ -1,8 +1,8 @@ +import type { SchemaTypes } from '@datocms/cma-client'; import { findLinkedItemTypeIds, findLinkedPluginIds, } from '@/utils/datocms/schema'; -import type { SchemaTypes } from '@datocms/cma-client'; import type { ExportSchema } from '../ExportPage/ExportSchema'; import type { Conflicts } from './ConflictsManager/buildConflicts'; import type { @@ -44,7 +44,7 @@ export async function buildImportDoc( }, }; - const queue: QueueItem[][] = [[exportSchema.rootItemType]]; + const queue: QueueItem[][] = [exportSchema.rootItemTypes]; const processedNodes = new Set(); // Process each level of the graph @@ -93,7 +93,10 @@ export async function buildImportDoc( nextLevelQueue.add(linkedItemType); } - for (const linkedPluginId of await findLinkedPluginIds(field)) { + for (const linkedPluginId of findLinkedPluginIds( + field, + new Set(Array.from(exportSchema.pluginsById.keys())), + )) { const linkedPlugin = exportSchema.pluginsById.get(linkedPluginId)!; nextLevelQueue.add(linkedPlugin); diff --git a/import-export-schema/src/entrypoints/ImportPage/importSchema.ts b/import-export-schema/src/entrypoints/ImportPage/importSchema.ts index c8898b19..7499bfdd 100644 --- a/import-export-schema/src/entrypoints/ImportPage/importSchema.ts +++ b/import-export-schema/src/entrypoints/ImportPage/importSchema.ts @@ -1,45 +1,151 @@ -import { - defaultAppearanceForFieldType, - isHardcodedEditor, -} from '@/utils/datocms/fieldTypeInfo'; +import { type Client, generateId, type SchemaTypes } from '@datocms/cma-client'; +import { find, get, isEqual, omit, pick, set, sortBy } from 'lodash-es'; +import { mapAppearanceToProject } from '@/utils/datocms/appearance'; import { validatorsContainingBlocks, validatorsContainingLinks, } from '@/utils/datocms/schema'; -import { type Client, type SchemaTypes, generateId } from '@datocms/cma-client'; -import { find, get, isEqual, omit, pick, set, sortBy } from 'lodash-es'; import type { ImportDoc } from './buildImportDoc'; -// biome-ignore lint/suspicious/noExplicitAny: Doesn't work with unknown :( -type PromiseGeneratorFn = (...args: any[]) => Promise; +function getOrThrow(map: Map, key: K, context: string): V { + const value = map.get(key); + if (value === undefined) { + throw new Error(`Missing mapping for ${String(key)} in ${context}`); + } + return value; +} + +function isDebug() { + try { + return ( + typeof window !== 'undefined' && + window.localStorage?.getItem('schemaDebug') === '1' + ); + } catch { + return false; + } +} + +export type ImportProgress = { + total: number; + finished: number; + label?: string; +}; -export type ImportProgress = { total: number; finished: number }; +export type ImportResult = { + itemTypeIdByExportId: Record; + fieldIdByExportId: Record; + fieldsetIdByExportId: Record; + pluginIdByExportId: Record; +}; export default async function importSchema( importDoc: ImportDoc, client: Client, updateProgress: (progress: ImportProgress) => void, -) { + opts?: { shouldCancel?: () => boolean }, +): Promise { // const [client, unsubscribe] = await withEventsSubscription(rawClient); - let total = 0; + // Precompute a fixed total so goal never grows + const pluginCreates = importDoc.plugins.entitiesToCreate.length; + const itemTypeCreates = importDoc.itemTypes.entitiesToCreate.length; + const fieldsetCreates = importDoc.itemTypes.entitiesToCreate.reduce( + (acc, it) => acc + it.fieldsets.length, + 0, + ); + const fieldCreates = importDoc.itemTypes.entitiesToCreate.reduce( + (acc, it) => acc + it.fields.length, + 0, + ); + const finalizeUpdates = itemTypeCreates; // one finalize step per created item type + const reorderBatches = itemTypeCreates; // one reorder batch per created item type + + const total = + pluginCreates + + itemTypeCreates + + fieldsetCreates + + fieldCreates + + finalizeUpdates + + reorderBatches; + let finished = 0; + updateProgress({ total, finished }); - function track(promiseGeneratorFn: T): T { - return (async (...args: Parameters) => { - total += 1; - updateProgress({ total, finished }); + const shouldCancel = opts?.shouldCancel ?? (() => false); + + function checkCancel() { + if (shouldCancel()) { + throw new Error('Import cancelled'); + } + } + + // debug helper is module-scoped to be available in helpers below + + function trackWithLabel( + labelForArgs: (...args: TArgs) => string, + promiseGeneratorFn: (...args: TArgs) => Promise, + ): (...args: TArgs) => Promise { + return async (...args: TArgs) => { + let label: string | undefined; try { + checkCancel(); + label = labelForArgs(...args); + updateProgress({ total, finished, label }); const result = await promiseGeneratorFn(...args); + checkCancel(); return result; } finally { finished += 1; - updateProgress({ total, finished }); + // Keep last known label for continuity + updateProgress({ total, finished, label }); + } + }; + } + + // Concurrency-limited mapper that preserves input order and + // stops scheduling new work after cancellation while letting + // in-flight jobs finish. It throws at the end if cancelled. + async function pMap( + items: readonly T[], + limit: number, + mapper: (item: T, index: number) => Promise, + ): Promise { + const results: R[] = new Array(items.length); + let nextIndex = 0; + let cancelledError: unknown | null = null; + + async function worker() { + while (true) { + if (cancelledError) return; + const current = nextIndex; + if (current >= items.length) return; + // Reserve index slot + nextIndex += 1; + try { + checkCancel(); + const res = await mapper(items[current], current); + results[current] = res; + } catch (e) { + // Stop scheduling more work; remember error to throw later + cancelledError = e; + return; + } } - }) as T; + } + + const workers = Array.from( + { length: Math.max(1, Math.min(limit, items.length)) }, + worker, + ); + await Promise.all(workers); + if (cancelledError) throw cancelledError; + return results; } + checkCancel(); const { locales } = await client.site.find(); + checkCancel(); const itemTypeIdMappings: Map = new Map(); const fieldIdMappings: Map = new Map(); @@ -74,18 +180,23 @@ export default async function importSchema( pluginIdMappings.set(exportId, projectId); } - // Create new plugins - await Promise.all( - importDoc.plugins.entitiesToCreate.map( - track(async (plugin) => { + // Create new plugins (parallel with limited concurrency) + checkCancel(); + await pMap(importDoc.plugins.entitiesToCreate, 4, async (plugin) => + trackWithLabel( + (p: SchemaTypes.Plugin) => + `Creating plugin: ${ + p.attributes.name || p.attributes.package_name || p.id + }`, + async (p: SchemaTypes.Plugin) => { const data: SchemaTypes.PluginCreateSchema['data'] = { type: 'plugin', - id: pluginIdMappings.get(plugin.id), - attributes: plugin.attributes.package_name - ? pick(plugin.attributes, ['package_name']) - : plugin.meta.version === '2' - ? omit(plugin.attributes, ['parameters']) - : omit(plugin.attributes, [ + id: pluginIdMappings.get(p.id), + attributes: p.attributes.package_name + ? pick(p.attributes, ['package_name']) + : p.meta.version === '2' + ? omit(p.attributes, ['parameters']) + : omit(p.attributes, [ 'parameter_definitions', 'field_types', 'plugin_type', @@ -94,128 +205,146 @@ export default async function importSchema( }; try { - console.log('Creating plugin', data); - const { data: plugin } = await client.plugins.rawCreate({ data }); + if (isDebug()) console.log('Creating plugin', data); + const { data: created } = await client.plugins.rawCreate({ data }); - if (!isEqual(plugin.attributes.parameters, {})) { + if (!isEqual(created.attributes.parameters, {})) { try { - await client.plugins.update(pluginIdMappings.get(plugin.id)!, { - parameters: plugin.attributes.parameters, + await client.plugins.update(created.id, { + parameters: created.attributes.parameters, }); - } catch (e) { - // NOP - // Legacy plugin parameters might be invalid + } catch (_e) { + // ignore invalid legacy parameters } } - console.log('Created plugin', plugin); + if (isDebug()) console.log('Created plugin', created); } catch (e) { console.error('Failed to create plugin', data, e); } - }), - ), + }, + )(plugin), ); - // Create new item types - const createdItemTypes = await Promise.all( - importDoc.itemTypes.entitiesToCreate.map( - track(async (toCreate) => { - const data: SchemaTypes.ItemTypeCreateSchema['data'] = { - type: 'item_type', - id: itemTypeIdMappings.get(toCreate.entity.id), - attributes: omit(toCreate.entity.attributes, [ - 'has_singleton_item', - 'ordering_direction', - 'ordering_meta', - ]), - }; - - try { - if (toCreate.rename) { - data.attributes.name = toCreate.rename.name; - data.attributes.api_key = toCreate.rename.apiKey; + // Create new item types (parallel with limited concurrency) + checkCancel(); + const createdItemTypes: Array = await pMap( + importDoc.itemTypes.entitiesToCreate, + 3, + async (toCreate) => + trackWithLabel( + (t: ImportDoc['itemTypes']['entitiesToCreate'][number]) => + `Creating ${t.entity.attributes.modular_block ? 'block' : 'model'}: ${ + t.rename?.name || t.entity.attributes.name + }`, + async (t: ImportDoc['itemTypes']['entitiesToCreate'][number]) => { + const data: SchemaTypes.ItemTypeCreateSchema['data'] = { + type: 'item_type', + id: itemTypeIdMappings.get(t.entity.id), + attributes: omit(t.entity.attributes, [ + 'has_singleton_item', + 'ordering_direction', + 'ordering_meta', + ]), + }; + + try { + if (t.rename) { + data.attributes.name = t.rename.name; + data.attributes.api_key = t.rename.apiKey; + } + if (isDebug()) console.log('Creating item type', data); + const { data: itemType } = await client.itemTypes.rawCreate({ + data, + }); + if (isDebug()) console.log('Created item type', itemType); + return itemType; + } catch (e) { + console.error('Failed to create item type', data, e); } - - console.log('Creating item type', data); - const { data: itemType } = await client.itemTypes.rawCreate({ data }); - console.log('Created item type', itemType); - - return itemType; - } catch (e) { - console.error('Failed to create item type', data, e); - } - }), - ), + }, + )(toCreate), ); - // Create fields and fieldsets - await Promise.all( - importDoc.itemTypes.entitiesToCreate.map( - async ({ entity: { id: itemTypeId }, fields, fieldsets }) => { - await Promise.all( - fieldsets.map( - track(async (fieldset) => { - const data: SchemaTypes.FieldsetCreateSchema['data'] = { - ...omit(fieldset, ['relationships']), - id: fieldsetIdMappings.get(fieldset.id), - }; + // Create fieldsets and fields (parallelized per stage, limited per item type) + checkCancel(); + await pMap( + importDoc.itemTypes.entitiesToCreate, + 2, + async ({ + entity: { id: itemTypeId, attributes: itemTypeAttrs }, + fields, + fieldsets, + }) => { + // Fieldsets first (required by fields referencing them) + await pMap(fieldsets, 4, async (fieldset) => + trackWithLabel( + (_fs: SchemaTypes.Fieldset) => + `Creating fieldset in ${itemTypeAttrs.name}`, + async (fs: SchemaTypes.Fieldset) => { + const data: SchemaTypes.FieldsetCreateSchema['data'] = { + ...omit(fs, ['relationships']), + id: fieldsetIdMappings.get(fs.id), + }; - try { - console.log('Creating fieldset', data); + try { + if (isDebug()) console.log('Creating fieldset', data); + const itemTypeProjectId = getOrThrow( + itemTypeIdMappings, + itemTypeId, + 'fieldset create', + ); + const { data: created } = await client.fieldsets.rawCreate( + itemTypeProjectId, + { data }, + ); + if (isDebug()) console.log('Created fieldset', created); + } catch (e) { + console.error('Failed to create fieldset', data, e); + } + }, + )(fieldset), + ); - const { data: fieldset } = await client.fieldsets.rawCreate( - itemTypeIdMappings.get(itemTypeId)!, - { - data, - }, - ); + const nonSlugFields = fields.filter( + (field) => field.attributes.field_type !== 'slug', + ); - console.log('Created fieldset', fieldset); - } catch (e) { - console.error('Failed to create fieldset', data, e); - } + await pMap(nonSlugFields, 6, async (field) => + trackWithLabel( + (f: SchemaTypes.Field) => + `Creating field ${f.attributes.label || f.attributes.api_key} in ${itemTypeAttrs.name}`, + (f: SchemaTypes.Field) => + importField(f, { + client, + locales, + fieldIdMappings, + pluginIdMappings, + fieldsetIdMappings, + itemTypeIdMappings, }), - ), - ); - - const nonSlugFields = fields.filter( - (field) => field.attributes.field_type !== 'slug', - ); - - await Promise.all( - nonSlugFields.map( - track((field) => - importField(field, { - client, - locales, - fieldIdMappings, - pluginIdMappings, - fieldsetIdMappings, - itemTypeIdMappings, - }), - ), - ), - ); + )(field), + ); - const slugFields = fields.filter( - (field) => field.attributes.field_type === 'slug', - ); + const slugFields = fields.filter( + (field) => field.attributes.field_type === 'slug', + ); - await Promise.all( - slugFields.map( - track((field) => - importField(field, { - client, - locales, - fieldIdMappings, - pluginIdMappings, - fieldsetIdMappings, - itemTypeIdMappings, - }), - ), - ), - ); - }, - ), + await pMap(slugFields, 4, async (field) => + trackWithLabel( + (f: SchemaTypes.Field) => + `Creating field ${f.attributes.label || f.attributes.api_key} in ${itemTypeAttrs.name}`, + (f: SchemaTypes.Field) => + importField(f, { + client, + locales, + fieldIdMappings, + pluginIdMappings, + fieldsetIdMappings, + itemTypeIdMappings, + }), + )(field), + ); + }, ); // Finalize new item types @@ -231,20 +360,30 @@ export default async function importSchema( const attributesToUpdate = ['ordering_direction', 'ordering_meta']; - await Promise.all( - importDoc.itemTypes.entitiesToCreate.map( - track(async (toCreate) => { - const id = itemTypeIdMappings.get(toCreate.entity.id)!; - const createdItemType = find(createdItemTypes, { id })!; + checkCancel(); + await pMap(importDoc.itemTypes.entitiesToCreate, 3, async (toCreate) => + trackWithLabel( + (t: ImportDoc['itemTypes']['entitiesToCreate'][number]) => + `Finalizing ${t.entity.attributes.modular_block ? 'block' : 'model'}: ${t.rename?.name || t.entity.attributes.name}`, + async (t: ImportDoc['itemTypes']['entitiesToCreate'][number]) => { + const id = getOrThrow( + itemTypeIdMappings, + t.entity.id, + 'finalize item type', + ); + const createdItemType = find(createdItemTypes, { id }); + if (!createdItemType) { + throw new Error(`Item type not found after creation: ${id}`); + } const data: SchemaTypes.ItemTypeUpdateSchema['data'] = { type: 'item_type', id, - attributes: pick(toCreate.entity.attributes, attributesToUpdate), + attributes: pick(t.entity.attributes, attributesToUpdate), relationships: relationshipsToUpdate.reduce( (acc, relationshipName) => { const handle = get( - toCreate.entity, + t.entity, `relationships.${relationshipName}.data`, ); @@ -254,7 +393,11 @@ export default async function importSchema( data: handle ? { type: 'field', - id: fieldIdMappings.get(handle.id)!, + id: getOrThrow( + fieldIdMappings, + handle.id, + 'finalize relationships', + ), } : null, }, @@ -267,11 +410,12 @@ export default async function importSchema( }; try { - console.log( - data.relationships, - pick(createdItemType.attributes, attributesToUpdate), - pick(createdItemType.relationships, relationshipsToUpdate), - ); + if (isDebug()) + console.log( + data.relationships, + pick(createdItemType.attributes, attributesToUpdate), + pick(createdItemType.relationships, relationshipsToUpdate), + ); if ( !isEqual( data.relationships, @@ -282,24 +426,30 @@ export default async function importSchema( pick(createdItemType.attributes, attributesToUpdate), ) ) { - console.log('Finalizing item type', data); + if (isDebug()) console.log('Finalizing item type', data); const { data: updatedItemType } = await client.itemTypes.rawUpdate( id, { data }, ); - console.log('Finalized item type', updatedItemType); + if (isDebug()) console.log('Finalized item type', updatedItemType); } } catch (e) { console.error('Failed to finalize item type', data, e); } - }), - ), + }, + )(toCreate), ); // Reorder fields and fieldsets - await Promise.all( - importDoc.itemTypes.entitiesToCreate.map( - track(async ({ entity: itemType, fields, fieldsets }) => { + checkCancel(); + await pMap(importDoc.itemTypes.entitiesToCreate, 3, async (obj) => + trackWithLabel( + (o: ImportDoc['itemTypes']['entitiesToCreate'][number]) => { + const { entity: itemType } = o; + return `Reordering fields/fieldsets for ${itemType.attributes.name}`; + }, + async (o: ImportDoc['itemTypes']['entitiesToCreate'][number]) => { + const { entity: itemType, fields, fieldsets } = o; const allEntities = [...fieldsets, ...fields]; if (allEntities.length <= 1) { @@ -307,39 +457,51 @@ export default async function importSchema( } try { - console.log( - 'Reordering fields/fieldsets for item type', - itemTypeIdMappings.get(itemType.id)!, - ); + if (isDebug()) + console.log( + 'Reordering fields/fieldsets for item type', + getOrThrow(itemTypeIdMappings, itemType.id, 'reorder start log'), + ); for (const entity of sortBy(allEntities, [ 'attributes', 'position', ])) { + checkCancel(); if (entity.type === 'fieldset') { await client.fieldsets.update( - fieldsetIdMappings.get(entity.id)!, + getOrThrow(fieldsetIdMappings, entity.id, 'fieldset reorder'), { position: entity.attributes.position, }, ); } else { - await client.fields.update(fieldIdMappings.get(entity.id)!, { - position: entity.attributes.position, - }); + await client.fields.update( + getOrThrow(fieldIdMappings, entity.id, 'field reorder'), + { + position: entity.attributes.position, + }, + ); } } - console.log( - 'Reordered fields/fieldsets for item type', - itemTypeIdMappings.get(itemType.id)!, - ); + if (isDebug()) + console.log( + 'Reordered fields/fieldsets for item type', + getOrThrow(itemTypeIdMappings, itemType.id, 'reorder log'), + ); } catch (e) { console.error('Failed to reorder fields/fieldsets', e); } - }), - ), + }, + )(obj), ); // unsubscribe(); + return { + itemTypeIdByExportId: Object.fromEntries(itemTypeIdMappings), + fieldIdByExportId: Object.fromEntries(fieldIdMappings), + fieldsetIdByExportId: Object.fromEntries(fieldsetIdMappings), + pluginIdByExportId: Object.fromEntries(pluginIdMappings), + }; } type ImportFieldOptions = { @@ -370,7 +532,11 @@ async function importField( data: field.relationships.fieldset.data ? { type: 'fieldset', - id: fieldsetIdMappings.get(field.relationships.fieldset.data.id)!, + id: getOrThrow( + fieldsetIdMappings, + field.relationships.fieldset.data.id, + 'field appearance fieldset mapping', + ), } : null, }, @@ -395,38 +561,41 @@ async function importField( const newIds: string[] = []; for (const fieldLinkedItemTypeId of fieldLinkedItemTypeIds) { - if (itemTypeIdMappings.has(fieldLinkedItemTypeId)) { - newIds.push(itemTypeIdMappings.get(fieldLinkedItemTypeId)!); - } + const maybe = itemTypeIdMappings.get(fieldLinkedItemTypeId); + if (maybe) newIds.push(maybe); } - set(data.attributes.validators!, validator, newIds); + const validatorsContainer = (data.attributes.validators ?? {}) as Record< + string, + unknown + >; + set(validatorsContainer, validator, newIds); + data.attributes.validators = + validatorsContainer as typeof data.attributes.validators; } const slugTitleFieldValidator = field.attributes.validators .slug_title_field as undefined | { title_field_id: string }; if (slugTitleFieldValidator) { - data.attributes.validators!.slug_title_field = { - title_field_id: fieldIdMappings.get( - slugTitleFieldValidator.title_field_id, - )!, + const mapped = getOrThrow( + fieldIdMappings, + slugTitleFieldValidator.title_field_id, + 'slug title field', + ); + (data.attributes.validators as Record).slug_title_field = { + title_field_id: mapped, }; } - data.attributes.appeareance = undefined; - - if (!(await isHardcodedEditor(field.attributes.appearance.editor))) { - if (pluginIdMappings.has(field.attributes.appearance.editor)) { - data.attributes.appearance!.editor = pluginIdMappings.get( - field.attributes.appearance.editor, - )!; - } else { - data.attributes.appearance = await defaultAppearanceForFieldType( - field.attributes.field_type, - ); - } - } + // Clear appearance to reconstruct a valid target-project configuration below + // (fixes typo 'appeareance' that prevented reset) + // Avoid delete operator; set to undefined to omit when serialized + (data.attributes as { appearance?: unknown }).appearance = undefined; + // Also clear legacy misspelled property if present + (data.attributes as { appeareance?: unknown }).appeareance = undefined; + // Build a safe appearance configuration regardless of source shape + const nextAppearance = await mapAppearanceToProject(field, pluginIdMappings); if (field.attributes.localized) { const oldDefaultValues = field.attributes.default_value as Record< @@ -438,21 +607,25 @@ async function importField( ); } - data.attributes.appearance!.addons = field.attributes.appearance.addons - .filter((addon) => pluginIdMappings.has(addon.id)) - .map((addon) => ({ ...addon, id: pluginIdMappings.get(addon.id)! })); + // mapAppearanceToProject already remaps editor/addons and ensures parameters + + data.attributes.appearance = nextAppearance; try { - console.log('Creating field', data); + if (isDebug()) console.log('Creating field', data); + const itemTypeProjectId = getOrThrow( + itemTypeIdMappings, + field.relationships.item_type.data.id, + 'field create', + ); const { data: createdField } = await client.fields.rawCreate( - itemTypeIdMappings.get(field.relationships.item_type.data.id)!, + itemTypeProjectId, { data, }, ); - console.log('Created field', createdField); + if (isDebug()) console.log('Created field', createdField); } catch (e) { - console.log('failed to create field', data, e); - throw e; + console.error('Failed to create field', data, e); } } diff --git a/import-export-schema/src/entrypoints/ImportPage/index.tsx b/import-export-schema/src/entrypoints/ImportPage/index.tsx index 49a8fb66..efa6ae00 100644 --- a/import-export-schema/src/entrypoints/ImportPage/index.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/index.tsx @@ -1,34 +1,53 @@ -import { ProjectSchema } from '@/utils/ProjectSchema'; -import type { ExportDoc } from '@/utils/types'; -import { buildClient } from '@datocms/cma-client'; -import { faXmark } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import type { SchemaTypes } from '@datocms/cma-client'; +// Removed unused icons import { ReactFlowProvider } from '@xyflow/react'; import type { RenderPageCtx } from 'datocms-plugin-sdk'; import { Button, Canvas, + SelectField, Spinner, - Toolbar, - ToolbarStack, - ToolbarTitle, + TextField, } from 'datocms-react-ui'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useId, useMemo, useRef, useState } from 'react'; +import type { GroupBase } from 'react-select'; +import ProgressStallNotice from '@/components/ProgressStallNotice'; +import { createCmaClient } from '@/utils/createCmaClient'; +import { downloadJSON } from '@/utils/downloadJson'; +import { ProjectSchema } from '@/utils/ProjectSchema'; +import type { ExportDoc } from '@/utils/types'; +import buildExportDoc from '../ExportPage/buildExportDoc'; import { ExportSchema } from '../ExportPage/ExportSchema'; -import { ConflictsContext } from './ConflictsManager/ConflictsContext'; +import ExportInner from '../ExportPage/Inner'; +import PostExportSummary from '../ExportPage/PostExportSummary'; +import type { ImportDoc } from './buildImportDoc'; +import { buildImportDoc } from './buildImportDoc'; import buildConflicts, { type Conflicts, } from './ConflictsManager/buildConflicts'; +import { ConflictsContext } from './ConflictsManager/ConflictsContext'; import FileDropZone from './FileDropZone'; import { Inner } from './Inner'; +import importSchema, { + type ImportProgress, + type ImportResult, +} from './importSchema'; +import PostImportSummary from './PostImportSummary'; import ResolutionsForm, { type Resolutions } from './ResolutionsForm'; -import { buildImportDoc } from './buildImportDoc'; -import importSchema, { type ImportProgress } from './importSchema'; + type Props = { ctx: RenderPageCtx; + initialMode?: 'import' | 'export'; + hideModeToggle?: boolean; }; -export function ImportPage({ ctx }: Props) { +export function ImportPage({ + ctx, + initialMode = 'import', + hideModeToggle = false, +}: Props) { + const exportInitialSelectId = useId(); + const confirmTextId = useId(); const params = new URLSearchParams(ctx.location.search); const recipeUrl = params.get('recipe_url'); const recipeTitle = params.get('recipe_title'); @@ -48,10 +67,8 @@ export function ImportPage({ ctx }: Props) { const body = await response.json(); const schema = new ExportSchema(body as ExportDoc); - setExportSchema([ - recipeTitle || uri.pathname.split('/').pop()!, - schema, - ]); + const fallbackName = uri.pathname.split('/').pop() || 'Imported schema'; + setExportSchema([recipeTitle || fallbackName, schema]); } finally { setLoadingRecipeByUrl(false); } @@ -64,9 +81,25 @@ export function ImportPage({ ctx }: Props) { [string, ExportSchema] | undefined >(); + // Local tab to switch between importing a file and exporting from selection + const [mode, setMode] = useState<'import' | 'export'>(initialMode); + const [importProgress, setImportProgress] = useState< ImportProgress | undefined >(undefined); + const [importCancelled, setImportCancelled] = useState(false); + const importCancelRef = useRef(false); + + const [postImportSummary, setPostImportSummary] = useState< + | { + importDoc: ImportDoc; + exportSchema: ExportSchema; + idByApiKey?: Record; + pluginIdByName?: Record; + fieldIdByExportId?: Record; + } + | undefined + >(undefined); async function handleDrop(filename: string, doc: ExportDoc) { try { @@ -79,35 +112,181 @@ export function ImportPage({ ctx }: Props) { } const client = useMemo( - () => - buildClient({ - apiToken: ctx.currentUserAccessToken!, - environment: ctx.environment, - }), + () => createCmaClient(ctx), [ctx.currentUserAccessToken, ctx.environment], ); const projectSchema = useMemo(() => new ProjectSchema(client), [client]); + const [adminDomain, setAdminDomain] = useState(); + useEffect(() => { + let active = true; + (async () => { + try { + const site = await client.site.find(); + const domain = site.internal_domain || site.domain || undefined; + if (active) setAdminDomain(domain); + console.log('[ImportPage] resolved admin domain:', domain, site); + } catch { + // ignore; links will simply not be shown + } + })(); + return () => { + active = false; + }; + }, [client]); + + // State used only in Export tab: choose initial model/block for graph + const [exportInitialItemTypeIds, setExportInitialItemTypeIds] = useState< + string[] + >([]); + const [exportInitialItemTypes, setExportInitialItemTypes] = useState< + SchemaTypes.ItemType[] + >([]); + const [exportStarted, setExportStarted] = useState(false); + const [postExportDoc, setPostExportDoc] = useState( + undefined, + ); + const [exportAllBusy, setExportAllBusy] = useState(false); + const [exportAllProgress, setExportAllProgress] = useState< + { done: number; total: number; label: string } | undefined + >(undefined); + const [exportAllCancelled, setExportAllCancelled] = useState(false); + const exportAllCancelRef = useRef(false); + // Show overlay while Export selection view prepares its graph + const [exportPreparingBusy, setExportPreparingBusy] = useState(false); + const [exportPreparingProgress, setExportPreparingProgress] = useState< + { done: number; total: number; label: string } | undefined + >(undefined); + // Selection export overlay state (when exporting from Start export flow) + const [exportSelectionBusy, setExportSelectionBusy] = useState(false); + const [exportSelectionProgress, setExportSelectionProgress] = useState< + { done: number; total: number; label: string } | undefined + >(undefined); + const [exportSelectionCancelled, setExportSelectionCancelled] = + useState(false); + const exportSelectionCancelRef = useRef(false); + const [allItemTypes, setAllItemTypes] = useState< + SchemaTypes.ItemType[] | undefined + >(undefined); + + useEffect(() => { + async function load() { + if (mode !== 'export') return; + const types = await projectSchema.getAllItemTypes(); + setAllItemTypes(types); + } + load(); + }, [mode, projectSchema]); + + useEffect(() => { + async function resolveInitial() { + if (!exportInitialItemTypeIds.length) { + setExportInitialItemTypes([]); + return; + } + const list: SchemaTypes.ItemType[] = []; + for (const id of exportInitialItemTypeIds) { + list.push(await projectSchema.getItemTypeById(id)); + } + setExportInitialItemTypes(list); + } + resolveInitial(); + }, [exportInitialItemTypeIds.join('-'), projectSchema]); + const [conflicts, setConflicts] = useState(); + const [conflictsBusy, setConflictsBusy] = useState(false); + const [conflictsProgress, setConflictsProgress] = useState< + { done: number; total: number; label: string } | undefined + >(undefined); + + // Typed confirmation gate state + const [confirmVisible, setConfirmVisible] = useState(false); + const [confirmExpected, setConfirmExpected] = useState(''); + const [confirmText, setConfirmText] = useState(''); + const [pendingResolutions, setPendingResolutions] = useState< + Resolutions | undefined + >(undefined); useEffect(() => { async function run() { if (!exportSchema) { return; } - setConflicts(await buildConflicts(exportSchema[1], projectSchema)); + try { + setConflictsBusy(true); + setConflictsProgress({ done: 0, total: 1, label: 'Preparing import…' }); + const c = await buildConflicts(exportSchema[1], projectSchema, (p) => + setConflictsProgress(p), + ); + setConflicts(c); + } finally { + setConflictsBusy(false); + } } run(); }, [exportSchema, projectSchema]); + // Listen for bottom Cancel action from ConflictsManager + useEffect(() => { + const onRequestCancel = async () => { + if (!exportSchema) return; + const result = await ctx.openConfirm({ + title: 'Cancel the import?', + content: `Do you really want to cancel the import process of "${exportSchema[0]}"?`, + choices: [ + { + label: 'Yes, cancel the import', + value: 'yes', + intent: 'negative', + }, + ], + cancel: { + label: 'Nevermind', + value: false, + intent: 'positive', + }, + }); + + if (result === 'yes') { + setExportSchema(undefined); + } + }; + + window.addEventListener( + 'import:request-cancel', + onRequestCancel as unknown as EventListener, + ); + return () => { + window.removeEventListener( + 'import:request-cancel', + onRequestCancel as unknown as EventListener, + ); + }; + }, [exportSchema, ctx]); + async function handleImport(resolutions: Resolutions) { if (!exportSchema || !conflicts) { throw new Error('Invariant'); } try { + setImportCancelled(false); + importCancelRef.current = false; + // If any rename operations are selected, require typed confirmation + const renameCount = Object.values(resolutions.itemTypes).filter( + (r) => r && 'strategy' in r && r.strategy === 'rename', + ).length; + + if (renameCount > 0) { + setPendingResolutions(resolutions); + setConfirmExpected(`RENAME ${renameCount}`); + setConfirmText(''); + setConfirmVisible(true); + return; + } + setImportProgress({ finished: 0, total: 1 }); const importDoc = await buildImportDoc( @@ -116,140 +295,983 @@ export function ImportPage({ ctx }: Props) { resolutions, ); - await importSchema(importDoc, client, setImportProgress); + const importResult: ImportResult = await importSchema( + importDoc, + client, + (p) => { + if (!importCancelRef.current) setImportProgress(p); + }, + { + shouldCancel: () => importCancelRef.current, + }, + ); ctx.notice('Import completed successfully!'); - ctx.navigateTo( - `${ctx.isEnvironmentPrimary ? '' : `/environments/${ctx.environment}`}/configuration/p/${ctx.plugin.id}/pages/import-export`, + // Refresh models list to build API key -> ID map for linking + let idByApiKey: Record | undefined; + let pluginIdByName: Record | undefined; + try { + const itemTypes = await client.itemTypes.list(); + idByApiKey = Object.fromEntries( + itemTypes.map((it) => [it.api_key, it.id]), + ); + const plugins = await client.plugins.list(); + pluginIdByName = Object.fromEntries( + plugins.map((pl) => [pl.name, pl.id]), + ); + } catch { + // ignore: links will still render without IDs + } + + setPostImportSummary({ + importDoc, + exportSchema: exportSchema[1], + idByApiKey, + pluginIdByName, + fieldIdByExportId: importResult.fieldIdByExportId, + }); + setImportProgress(undefined); + setExportSchema(undefined); + } catch (e) { + console.error(e); + if (e instanceof Error && e.message === 'Import cancelled') { + ctx.notice('Import canceled'); + } else { + ctx.alert('Import could not be completed successfully.'); + } + setImportProgress(undefined); + } + } + + async function proceedAfterConfirm() { + if (!pendingResolutions || !exportSchema || !conflicts) return; + + try { + setConfirmVisible(false); + setImportCancelled(false); + importCancelRef.current = false; + setImportProgress({ finished: 0, total: 1 }); + + const importDoc = await buildImportDoc( + exportSchema[1], + conflicts, + pendingResolutions, + ); + + const importResult: ImportResult = await importSchema( + importDoc, + client, + (p) => { + if (!importCancelRef.current) setImportProgress(p); + }, + { + shouldCancel: () => importCancelRef.current, + }, ); + ctx.notice('Import completed successfully!'); + // Refresh models list to build API key -> ID map for linking + let idByApiKey: Record | undefined; + let pluginIdByName: Record | undefined; + try { + const itemTypes = await client.itemTypes.list(); + idByApiKey = Object.fromEntries( + itemTypes.map((it) => [it.api_key, it.id]), + ); + const plugins = await client.plugins.list(); + pluginIdByName = Object.fromEntries( + plugins.map((pl) => [pl.name, pl.id]), + ); + } catch {} + + setPostImportSummary({ + importDoc, + exportSchema: exportSchema[1], + idByApiKey, + pluginIdByName, + fieldIdByExportId: importResult.fieldIdByExportId, + }); setImportProgress(undefined); setExportSchema(undefined); + setPendingResolutions(undefined); } catch (e) { console.error(e); - ctx.alert('Import could not be completed successfully.'); + if (e instanceof Error && e.message === 'Import cancelled') { + ctx.notice('Import canceled'); + } else { + ctx.alert('Import could not be completed successfully.'); + } + setImportProgress(undefined); } } return ( - {importProgress ? ( +
    - - - Import in progress -
    - - -
    -
    -
    + {exportSchema + ? null + : !hideModeToggle && (
    -
    -
    - - Import in progress: please do not close the window or change - section! 🙏 -
    -
    -
    -
    - ) : ( - -
    - {exportSchema ? ( - - - 📄 Import "{exportSchema[0]}" -
    - - - - ) : ( - - - Schema Import/Export -
    - - - )} -
    - - {(button) => - exportSchema ? ( - conflicts ? ( - - - - - - ) : ( + + +
    +
    +
    + )} +
    + {mode === 'import' ? ( + postImportSummary ? ( + { + setPostImportSummary(undefined); + ctx.navigateTo( + `${ctx.isEnvironmentPrimary ? '' : `/environments/${ctx.environment}`}/configuration/p/${ctx.plugin.id}/pages/import`, + ); + }} + /> + ) : ( + + {(button) => + exportSchema ? ( + conflicts ? ( + + + + + + ) : ( + + ) + ) : loadingRecipeByUrl ? ( + ) : ( +
    +
    +
    + Upload your schema export file +
    + +
    +

    + Drag and drop your exported JSON file here, or + click the button to select one from your computer. +

    + {button} +
    +
    +
    + {hideModeToggle + ? '💡 Need to bulk export your schema? Go to the Export page under Schema.' + : '💡 Need to bulk export your schema? Switch to the Export tab above.'} +
    +
    ) - ) : loadingRecipeByUrl ? ( - - ) : ( -
    -
    -
    - Upload your schema export file + } + + ) + ) : ( +
    + {postExportDoc ? ( + + downloadJSON(postExportDoc, { + fileName: 'export.json', + prettify: true, + }) + } + onClose={() => { + setPostExportDoc(undefined); + setMode('import'); + setExportStarted(false); + setExportInitialItemTypeIds([]); + setExportInitialItemTypes([]); + }} + /> + ) : !exportStarted ? ( +
    +
    + Start a new export +
    +
    +

    + Select one or more models/blocks to start selecting what + to export. +

    +
    +
    + + > + id={exportInitialSelectId} + name="export-initial-model" + label="Starting models/blocks" + selectInputProps={{ + isMulti: true, + isClearable: true, + isDisabled: !allItemTypes, + options: + allItemTypes?.map((it) => ({ + value: it.id, + label: `${it.attributes.name}${it.attributes.modular_block ? ' (Block)' : ''}`, + })) ?? [], + placeholder: 'Choose models/blocks…', + }} + value={ + allItemTypes + ? allItemTypes + .map((it) => ({ + value: it.id, + label: `${it.attributes.name}${it.attributes.modular_block ? ' (Block)' : ''}`, + })) + .filter((opt) => + exportInitialItemTypeIds.includes( + opt.value, + ), + ) + : [] + } + onChange={(options) => + setExportInitialItemTypeIds( + Array.isArray(options) + ? options.map((o) => o.value) + : [], + ) + } + /> +
    +
    + + +
    +
    +
    +
    +
    -
    - 💡 Need to export your schema? Go to one of your models - or blocks and choose "Export as JSON". -
    - ) - } - +
    + ) : ( + setExportPreparingBusy(false)} + onPrepareProgress={(p) => { + // ensure overlay shows determinate progress + setExportPreparingBusy(true); + setExportPreparingProgress(p); + }} + onClose={() => { + // Return to selection screen with current picks preserved + setExportStarted(false); + }} + onExport={async (itemTypeIds, pluginIds) => { + try { + setExportSelectionBusy(true); + setExportSelectionProgress(undefined); + setExportSelectionCancelled(false); + exportSelectionCancelRef.current = false; + + const total = pluginIds.length + itemTypeIds.length * 2; + setExportSelectionProgress({ + done: 0, + total, + label: 'Preparing export…', + }); + let done = 0; + + const exportDoc = await buildExportDoc( + projectSchema, + exportInitialItemTypeIds[0], + itemTypeIds, + pluginIds, + { + onProgress: (label: string) => { + done += 1; + setExportSelectionProgress({ + done, + total, + label, + }); + }, + shouldCancel: () => + exportSelectionCancelRef.current, + }, + ); + + if (exportSelectionCancelRef.current) { + throw new Error('Export cancelled'); + } + + downloadJSON(exportDoc, { + fileName: 'export.json', + prettify: true, + }); + setPostExportDoc(exportDoc); + ctx.notice('Export completed with success!'); + } catch (e) { + console.error('Selection export failed', e); + if ( + e instanceof Error && + e.message === 'Export cancelled' + ) { + ctx.notice('Export canceled'); + } else { + ctx.alert( + 'Could not complete the export. Please try again.', + ); + } + } finally { + setExportSelectionBusy(false); + setExportSelectionProgress(undefined); + setExportSelectionCancelled(false); + exportSelectionCancelRef.current = false; + } + }} + /> + )} + {/* Fallback note removed per UX request */} +
    + )} +
    +
    + + + {importProgress && ( +
    +
    +
    Import in progress
    +
    + {importCancelled + ? 'Cancelling import…' + : 'Sit tight, we’re applying models, fields, and plugins…'} +
    + +
    +
    +
    +
    +
    + {importCancelled + ? 'Stopping at next safe point…' + : importProgress.label || ''} +
    +
    + {importProgress.finished} / {importProgress.total} +
    +
    + +
    +
    - +
    + )} + {/* Blocking overlay while exporting all */} + {exportAllBusy && ( +
    +
    +
    Exporting entire schema
    +
    + Sit tight, we’re gathering models, blocks, and plugins… +
    + +
    +
    +
    +
    +
    + {exportAllProgress + ? exportAllProgress.label + : 'Loading project schema…'} +
    +
    + {exportAllProgress + ? `${exportAllProgress.done} / ${exportAllProgress.total}` + : ''} +
    +
    + +
    + +
    +
    +
    + )} + + {/* Overlay while preparing import conflicts after selecting a file */} + {conflictsBusy && ( +
    +
    +
    Preparing import
    +
    + Sit tight, we’re scanning your export against the project… +
    + +
    +
    +
    +
    +
    + {conflictsProgress + ? conflictsProgress.label + : 'Preparing import…'} +
    +
    + {conflictsProgress + ? `${conflictsProgress.done} / ${conflictsProgress.total}` + : ''} +
    +
    + +
    +
    + )} + + {/* Overlay while preparing the Export selection view */} + {exportPreparingBusy && ( +
    +
    +
    Preparing export
    +
    + Sit tight, we’re setting up your models, blocks, and plugins… +
    + +
    +
    +
    +
    +
    + {exportPreparingProgress + ? exportPreparingProgress.label + : 'Preparing export…'} +
    +
    + {exportPreparingProgress + ? `${exportPreparingProgress.done} / ${exportPreparingProgress.total}` + : ''} +
    +
    + +
    +
    + )} + + {/* Blocking overlay while exporting selection via Start export */} + {exportSelectionBusy && ( +
    +
    +
    Exporting selection
    +
    + Sit tight, we’re gathering models, blocks, and plugins… +
    + +
    +
    +
    +
    +
    + {exportSelectionProgress + ? exportSelectionProgress.label + : 'Preparing export…'} +
    +
    + {exportSelectionProgress + ? `${exportSelectionProgress.done} / ${exportSelectionProgress.total}` + : ''} +
    +
    + +
    + +
    +
    +
    + )} + + {/* Typed confirmation modal for renames */} + {confirmVisible && ( +
    +
    +
    + Confirm rename operations +
    +
    + You chose to import items with renamed models/blocks. To confirm, + type + + {' '} + {confirmExpected} + {' '} + below. +
    +
    + setConfirmText(val)} + textInputProps={{ + autoFocus: true, + onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && confirmText === confirmExpected) { + e.preventDefault(); + void proceedAfterConfirm(); + } + }, + }} + /> +
    +
    + + +
    +
    +
    )} ); diff --git a/import-export-schema/src/icons/fieldgroup-text.svg b/import-export-schema/src/icons/fieldgroup-text.svg deleted file mode 100644 index e226c4ac..00000000 --- a/import-export-schema/src/icons/fieldgroup-text.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/import-export-schema/src/icons/fielgroup-location.svg b/import-export-schema/src/icons/fielgroup-location.svg deleted file mode 100644 index 5edbe475..00000000 --- a/import-export-schema/src/icons/fielgroup-location.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/import-export-schema/src/index.css b/import-export-schema/src/index.css index 405d7b90..cd51f6fe 100644 --- a/import-export-schema/src/index.css +++ b/import-export-schema/src/index.css @@ -3,10 +3,17 @@ svg { } html, -body { +body, +#root { height: 100%; } +html, +body { + margin: 0; + padding: 0; +} + .dropzone, .export-wrapper { position: absolute; @@ -17,7 +24,7 @@ body { } .dropzone--pending { - &:before { + &::before { content: ""; position: absolute; top: 20px; @@ -48,12 +55,14 @@ body { cursor: pointer; border: 1px solid rgb(var(--color-components)); background: color-mix(in srgb, rgb(var(--color-components)), white 94%); +} +.app-node__body { &:hover { - box-shadow: 0 0 0 3px transparent, 0 0 0 3px - rgb(var(--color-components), 40%); + box-shadow: + 0 0 0 3px transparent, + 0 0 0 3px rgb(var(--color-components), 40%); } - * { text-overflow: ellipsis; overflow: hidden; @@ -109,8 +118,16 @@ body { opacity: 0.2; } -.app-node__focused .app-node__body { - box-shadow: 0 0 0 3px transparent, 0 0 0 3px rgb(var(--color-components), 50%); +/* New standardized modifier class; keep legacy for backward compatibility */ +.app-node--excluded { + opacity: 0.2; +} + +.app-node__focused .app-node__body, +.app-node--focused .app-node__body { + box-shadow: + 0 0 0 3px transparent, + 0 0 0 3px rgb(var(--color-components), 50%); } .tooltip { @@ -119,9 +136,12 @@ body { border-radius: 10px; /* http://smoothshadows.com/#djEsMSw2LDAuMTIsNzgsMCwwLCMwMzA3MTIsI2YzZjRmNiwjZmZmZmZmLDI%3D */ box-shadow: - 0px 0px 2px rgba(3, 7, 18, 0.02), 0px 0px 9px rgba(3, 7, 18, 0.04), - 0px 0px 20px rgba(3, 7, 18, 0.06), 0px 0px 35px rgba(3, 7, 18, 0.08), - 0px 0px 54px rgba(3, 7, 18, 0.1), 0px 0px 78px rgba(3, 7, 18, 0.12); + 0px 0px 2px rgba(3, 7, 18, 0.02), + 0px 0px 9px rgba(3, 7, 18, 0.04), + 0px 0px 20px rgba(3, 7, 18, 0.06), + 0px 0px 35px rgba(3, 7, 18, 0.08), + 0px 0px 54px rgba(3, 7, 18, 0.1), + 0px 0px 78px rgba(3, 7, 18, 0.12); display: flex; flex-direction: column; @@ -193,8 +213,10 @@ code { display: flex; align-items: center; justify-content: center; +} - &:before { +.fieldEdge { + &::before { display: block; content: ""; width: 10px; @@ -202,7 +224,6 @@ code { border-radius: 100px; background: #999; } - .fieldEdge__tooltip { display: none; position: absolute; @@ -213,21 +234,17 @@ code { backdrop-filter: blur(2px); padding: 10px; } - &:hover { - &:before { + &::before { background: #555; } - .fieldEdge__tooltip { display: block; } } - .field { font-size: 10px; } - .field__icon { min-width: 16px; min-height: 16px; @@ -246,8 +263,97 @@ code { flex-direction: column; } +/* Ensure export page never scrolls at the outer container */ +.page--export { + overflow: hidden; +} + .page__toolbar { - min-height: 65px; + /* Keep toolbar compact so content sits higher */ + min-height: 44px; + padding: 6px var(--spacing-l); + display: flex; + align-items: center; + border-bottom: none; /* remove separator line */ + box-shadow: none; /* ensure no shadow line */ +} + +/* Also remove any inner toolbar border/shadow if present */ +.page__toolbar > * { + border-bottom: none; + box-shadow: none; +} + +/* Centered, prominent Import/Export toggle */ +.mode-toggle { + --toggle-inset: 6px; + --blob-ease: cubic-bezier(0.22, 1, 0.36, 1); + + display: inline-flex; + position: relative; + gap: 0; + padding: var(--toggle-inset); + border: 1px solid var(--border-color); + border-radius: 999px; + background: #fff; + align-items: center; + justify-content: center; + box-shadow: 0 3px 12px rgba(0, 0, 0, 0.08); + overflow: hidden; +} + +/* Sliding blob background */ +.mode-toggle::before { + content: ""; + position: absolute; + top: var(--toggle-inset); + left: var(--toggle-inset); + width: calc((100% - (var(--toggle-inset) * 2)) / 2); + height: calc(100% - (var(--toggle-inset) * 2)); + border-radius: 999px; + transform: translateX(0%); + transition: transform 420ms var(--blob-ease); + pointer-events: none; +} + +/* Base blob */ +.mode-toggle::before { + background: var(--accent-color); + box-shadow: + 0 10px 20px rgba(0, 0, 0, 0.08), + 0 2px 6px rgba(0, 0, 0, 0.06) inset; +} + +/* Move blob when Export is active */ +.mode-toggle[data-mode="export"]::before { + transform: translateX(100%); +} + +.mode-toggle__button { + appearance: none; + border: 0; + margin: 0; + background: transparent; + padding: 8px 16px; /* more compact to fit toolbar */ + font: inherit; + font-size: 14px; + color: var(--base-body-color); + cursor: pointer; + border-radius: 999px; + position: relative; + z-index: 2; /* above blob */ + flex: 1 1 0; + text-align: center; + transition: color 200ms ease; +} + +.mode-toggle__button:hover { + color: color-mix(in srgb, var(--base-body-color), black 15%); +} + +.mode-toggle__button.is-active, +.mode-toggle__button[aria-selected="true"] { + color: #fff; } .page__toolbar__title { @@ -262,9 +368,519 @@ code { overflow: auto; } +/* Center the main blank-slate body on the page */ +.blank-slate { + height: 100%; +} + +/* Ensure centering fills the available content area in both pages and dropzones */ +.page__content > .blank-slate, +.dropzone > .blank-slate { + position: absolute; + inset: 0; +} + +/* Center only the main square, regardless of tips above/below */ +.blank-slate__body { + position: absolute; + top: 35%; + left: 50%; + transform: translate(-50%, -50%); +} + +.blank-slate__body__outside { + position: absolute; + left: 50%; + bottom: 20px; + transform: translateX(-50%); + text-align: center; +} + +/* Generic list resets for clean summaries */ +.list--plain { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 8px; /* consistent vertical rhythm */ +} + +/* Neutralize ad-hoc margins for items inside plain lists */ +.list--plain > li { + margin: 0; +} + +/* When using connection cards inside plain lists, rely on list gap for spacing */ +:where(.list--plain) .connection-card { + margin: 0; +} + +/* Default row styling for simple list items (not cards) */ +/* Non-card, non-link items styled as simple rows */ +.list--plain > li:not(.connection-card):not(.list-item--link) { + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: #fff; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + /* Allow contents to shrink and ellipsize within the tile */ + min-width: 0; +} + +/* Ensure direct children can shrink and truncate text properly */ +.list--plain > li:not(.connection-card):not(.list-item--link) > * { + min-width: 0; + flex: 1 1 auto; +} + +.list--plain > li:not(.connection-card):not(.list-item--link):hover { + background: rgba(0, 0, 0, 0.02); +} + +/* Linked items: move row chrome to the anchor for full-click area */ +.list--plain > li.list-item--link { + padding: 0; + border: 0; + background: transparent; + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +:where(.list--plain) .list-item--link > a { + display: block; + flex: 1 1 auto; + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: #fff; + color: inherit; + text-decoration: none; + box-sizing: border-box; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + position: relative; + z-index: 1; + pointer-events: auto; + cursor: pointer; +} + +:where(.list--plain) .list-item--link > a:hover { + background: rgba(0, 0, 0, 0.02); +} + +/* Slim variant for compact summaries */ +.list--slim { + gap: 4px; +} + +.list--slim > li:not(.connection-card):not(.list-item--link) { + padding: 6px 8px; + border-radius: 6px; +} + +.list--plain code { + background: #f6f7f9; + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 1px 6px; +} + +.list--dashed { + list-style: none; + padding: 0; + margin: 0; +} + +.list--dashed li { + position: relative; + padding-left: 12px; +} + +.list--dashed li::before { + content: "–"; + position: absolute; + left: 0; + color: var(--light-body-color); +} + +/* Summary toolbar meta and chips */ +.summary-toolbar__meta { + display: inline-flex; + gap: 6px; + align-items: center; + flex-wrap: wrap; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + border: 1px solid var(--border-color); + border-radius: 999px; + background: #fff; + font-size: 12px; + color: var(--base-body-color); +} + +.chip--muted { + color: var(--light-body-color); +} + +/* Make button-based chips look like pills, not default grey buttons */ +button.chip { + appearance: none; + background: #fff; + border: 1px solid var(--border-color); + border-radius: 999px; + color: var(--base-body-color); + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + font: inherit; + font-size: 12px; + line-height: 1; + padding: 3px 10px; +} + +button.chip:hover { + background: rgba(0, 0, 0, 0.02); +} + +/* Active/pressed state for toggle chips */ +button.chip.is-active, +button.chip[aria-pressed="true"] { + background: var(--accent-color); + color: #fff; + border-color: var(--accent-color); +} + +/* Softer variant still works on buttons */ +button.chip.chip--soft { + background: rgba(59, 130, 246, 0.06); + border-color: rgba(59, 130, 246, 0.25); + color: color-mix(in srgb, #3b82f6, black 35%); +} + +button.chip.chip--muted { + color: var(--light-body-color); +} + +/* Accessibility focus ring */ +button.chip:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: 2px; +} + +/* Connections UI */ +.summary { + background: linear-gradient(180deg, #fafcff, #f5f7ff); +} + +.summary__section { + padding: 16px; + display: grid; + gap: 12px; +} + +.summary__title { + font-weight: 700; + letter-spacing: -0.2px; +} + +.summary__grid { + display: grid; + /* Fluid columns that adapt from 1 → n nicely */ + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: var(--spacing-m, 12px); + align-items: stretch; /* equal height tiles per row */ + grid-auto-flow: row dense; +} + +/* Avoid overflow on long content inside tiles */ +.summary__grid > * { + min-width: 0; +} + +.surface { + border: 1px solid var(--border-color); + border-radius: 8px; + background: #fff; + padding: 12px; +} + +/* New summary layout: left nav + right content */ +.summary__layout { + display: grid; + grid-template-columns: 260px 1fr; + gap: var(--spacing-m, 12px); + align-items: start; +} + +.summary__nav { + display: grid; + gap: 6px; + position: sticky; + top: 8px; +} + +.summary__nav__item { + appearance: none; + border: 1px solid var(--border-color); + background: #fff; + border-radius: 8px; + padding: 8px 10px; + width: 100%; + text-align: left; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + cursor: pointer; +} + +.summary__nav__item.is-active { + border-color: var(--accent-color); + background: color-mix(in srgb, var(--accent-color), white 94%); +} + +.summary__nav__label { + font-weight: 600; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.summary__content { + min-width: 0; +} + +.summary__content__title { + font-weight: 700; + margin-bottom: 8px; +} + +@media (max-width: 900px) { + .summary__layout { + grid-template-columns: 1fr; + } + .summary__nav { + position: relative; + top: auto; + display: flex; + gap: 8px; + overflow-x: auto; + padding-bottom: 4px; + } + .summary__nav__item { + flex: 0 0 auto; + min-width: 160px; + } +} + +.box__title { + font-weight: 600; +} + +.box__meta { + color: var(--light-body-color); +} + +.collapsible__toggle { + display: grid; + grid-template-columns: 20px 1fr; + align-items: center; + gap: 8px; + width: 100%; + background: transparent; /* keep surface white */ + border: 0; + padding: 8px 10px; + border-radius: 6px; + cursor: pointer; + text-align: left; + position: relative; /* ensure stacking context so content doesn't overlay */ + z-index: 0; +} + +.collapsible__toggle:hover { + background: transparent; +} + +.collapsible__title { + font-weight: 600; +} + +.collapsible__content { + margin-top: 8px; + position: relative; + z-index: 0; +} + +.chip--soft { + background: color-mix(in srgb, var(--accent-color, #4f46e5), white 92%); + border-color: color-mix(in srgb, var(--accent-color, #4f46e5), white 70%); + color: var(--accent-color, #4f46e5); +} + +.connections { + display: grid; + gap: 12px; +} + +.connections__toolbar { + display: grid; + grid-template-columns: minmax(260px, 420px) 1fr auto; + align-items: end; + gap: 10px; + border-bottom: 1px solid var(--border-color); + padding-bottom: 8px; +} + +.connections__spacer { + width: 100%; +} + +.connections__actions { + display: grid; + gap: 6px; + align-items: end; + justify-items: end; +} + +.connections__count { + font-size: 12px; + color: var(--light-body-color); +} + +.button-group { + display: inline-flex; + gap: 8px; +} + +.connection-card { + margin: 0 0 8px 0; +} + +.connection-card__container { + border: 1px solid var(--border-color); + border-radius: 8px; + background: #fff; + overflow: hidden; + transition: + box-shadow 160ms ease, + border-color 160ms ease, + transform 160ms ease; +} + +.connection-card__toggle { + display: grid; + grid-template-columns: 20px 1fr auto; + align-items: center; + gap: 8px; + width: 100%; + background: transparent; + border: 0; + padding: 10px 12px; + cursor: pointer; + text-align: left; + transition: background-color 150ms ease; +} + +.connection-card__toggle:hover { + background: rgba(0, 0, 0, 0.02); +} + +.connection-card__toggle:focus-visible { + outline: none; + box-shadow: inset 0 0 0 2px var(--accent-color, #4f46e5); + border-radius: 6px; +} + +.connection-card__toggle[aria-expanded="true"] { + background: rgba(0, 0, 0, 0.02); + border-bottom: 1px solid var(--border-color); +} + +.caret { + display: inline-block; + width: 14px; + text-align: center; + color: var(--light-body-color); +} + +.connection-card__title { + font-weight: 600; +} + +.connection-card__apikey { + color: #666; + font-weight: 400; +} + +.connection-card__counts { + display: inline-flex; + gap: 6px; + align-items: center; +} + +.connection-card__body { + /* Align content with the start of the title column: 12 (container padding) + 20 (caret) + 8 (gap) */ + padding: var(--spacing-l, 16px) var(--spacing-l, 16px) var(--spacing-l, 16px) + calc(12px + 20px + 8px); + border-top: 1px solid var(--border-color); + background: #fafafa; +} + +/* Improve readability of lists within card body */ +.connection-card__body .list--dashed li { + margin: 4px 0; +} + +.connection-card__body code { + background: #f6f7f9; + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 1px 6px; +} + +/* Subtle elevation when open/hover */ +.connection-card__container.is-open, +.connection-card__container:hover { + border-color: color-mix(in srgb, var(--accent-color, #4f46e5), white 60%); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06); +} + +/* Export page: prevent outer content scrolling; only inner panes scroll */ +.page--export .page__content { + overflow: hidden; + overflow-y: hidden; + min-height: 0; + padding: 0; +} + +.page--export .export-wrapper { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + min-height: 0; +} + .page__actions { border-top: 1px solid var(--border-color); padding: var(--spacing-l); + display: grid; + gap: var(--spacing-s); } .blank-slate { @@ -273,11 +889,19 @@ code { left: 0; width: 100%; height: 100%; + box-sizing: border-box; /* prevent padding from causing overflow */ display: flex; flex-direction: column; gap: 15px; align-items: center; - justify-content: center; + /* Prefer near-top layout with a bit of breathing room */ + justify-content: flex-start; + padding-top: var(--spacing-l); +} + +/* Ensure blank slates on export never create outer scrollbars */ +.page--export .blank-slate { + overflow: hidden; } .blank-slate__body { @@ -286,6 +910,8 @@ code { border-radius: 4px; max-width: 600px; padding: var(--spacing-xxl); + /* Ensure some breathing room from toolbar/top */ + margin-top: var(--spacing-xl); } .blank-slate__body__title { @@ -301,6 +927,33 @@ code { color: var(--light-body-color); } +/* Export selection panel */ +.export-selector { + display: grid; + grid-template-columns: 1fr; + gap: var(--spacing-m); + align-items: start; + min-width: 420px; +} + +.export-selector__field { + width: 100%; +} + +.export-selector__actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-s); +} + +.export-selector__actions button { + width: 100%; +} + +.export-selector__cta button { + width: 100%; +} + .import__graph, .import__details { position: absolute; @@ -314,6 +967,160 @@ code { .import__details { border-left: 1px solid var(--border-color); box-sizing: border-box; + background: #fff; + /* Slightly tighter padding for redesigned conflicts UI */ +} + +/* Conflicts manager mass actions */ +.conflicts-setup { + display: grid; + gap: var(--spacing-m); + margin: var(--spacing-m) 0 var(--spacing-l); +} +.setup-inline { + display: grid; + gap: 12px; +} + +.setup-group { + display: grid; + grid-template-columns: 160px 1fr; + align-items: start; + gap: 12px; +} + +/* Vertical variant for compact, column-like layout */ +.setup-group--vertical { + grid-template-columns: 1fr; +} + +.setup-group__label { + font-weight: 600; + color: var(--light-body-color); + padding-top: 0; +} + +.setup-group__content { + display: grid; + gap: 6px; +} + +.setup__fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-m); + align-items: start; +} + +.setup__hint { + color: var(--light-body-color); + font-size: 12px; +} + +.segmented { + display: inline-grid; + grid-auto-flow: column; + gap: 6px; + background: #fff; + padding: 4px; + border-radius: 999px; + border: 1px solid var(--border-color); +} + +.segmented__button { + border-radius: 999px; +} + +.segmented__button.is-selected { + background: color-mix(in srgb, var(--accent-color), white 90%); + box-shadow: 0 0 0 2px var(--accent-color); +} + +/* New vertical choice list */ +.choice-list { + display: grid; + gap: 10px; +} + +.choice-button { + width: 100%; + justify-content: center; +} + +.choice-button.is-selected { + background: color-mix(in srgb, var(--accent-color), white 90%); + border-color: var(--accent-color); +} + +.mass-actions__grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-m); + align-items: start; +} + +.mass-actions__apply { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-s); + margin-top: var(--spacing-m); +} + +.mass-actions__plugin-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-s); + margin-top: var(--spacing-m); +} + +/* Clear, guided choice groups in conflicts manager */ +.mass-actions__section { + display: grid; + gap: var(--spacing-xs, 6px); + margin-top: var(--spacing-m); +} + +.mass-actions__section__label { + font-weight: 700; + color: var(--base-body-color); +} + +.choice-group { + display: grid; + grid-template-columns: 1fr 1fr; + align-items: stretch; + gap: var(--spacing-s); +} + +.choice-group__or { + color: var(--light-body-color); + font-weight: 600; +} + +.choice-button.is-selected { + box-shadow: + 0 0 0 3px var(--accent-color), + 0 0 0 6px color-mix(in srgb, var(--accent-color), white 65%); + transform: translateZ(0); +} + +@media (max-width: 900px) { + .setup-group { + grid-template-columns: 1fr; + } + + .setup__fields { + grid-template-columns: 1fr; + } + + .segmented { + grid-auto-flow: row; + justify-items: stretch; + } + + .choice-group { + grid-template-columns: 1fr; + } } .conflict { @@ -330,32 +1137,141 @@ code { } .conflict__title { - font-weight: bold; - padding: var(--spacing-m) var(--spacing-l); + font-weight: 700; + appearance: none; + background: transparent; + border: 0; + width: 100%; + text-align: left; cursor: pointer; display: flex; gap: 10px; align-items: center; + color: var(--base-body-color); + padding: 10px 12px; +} - svg { - color: var(--base-body-color); - } +.conflict__content { + padding: 12px 16px; /* reduce inner padding for a tighter look */ +} - &:hover { - background: var(--light-bg-color); - } +/* Pretty export overlay styles */ +.export-overlay__card { + background: #fff; + border: 1px solid var(--border-color); + border-radius: 14px; + box-shadow: 0 18px 60px rgba(0, 0, 0, 0.12); + padding: 28px 30px; + width: 720px; + max-width: 92vw; +} + +.export-overlay__title { + font-weight: 800; + font-size: 22px; + letter-spacing: -0.01em; + margin-bottom: 10px; +} + +.export-overlay__subtitle { + color: var(--light-body-color); + margin-bottom: 18px; + font-size: 14px; +} - .conflict--selected & { - border-bottom: 1px solid var(--border-color); +.export-overlay__bar { + height: 14px; + /* Lighter track to increase contrast with the fill */ + background: color-mix(in srgb, var(--accent-color, #4f46e5), white 96%); + border-radius: 999px; + overflow: hidden; + position: relative; + margin-bottom: 12px; +} + +.export-overlay__bar__fill { + position: relative; + height: 100%; + /* Solid base using project accent color */ + background-color: var(--accent-color, #4f46e5); + transition: width 180ms ease; + overflow: hidden; +} + +/* Independent shimmer overlay to avoid jitter during width updates */ +.export-overlay__bar__fill::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: -50%; + width: 50%; + pointer-events: none; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0.28) 45%, + rgba(255, 255, 255, 0.42) 50%, + rgba(255, 255, 255, 0.28) 55%, + rgba(255, 255, 255, 0) 100% + ); + animation: exportBarShimmer 1.4s linear infinite; + will-change: transform; +} + +@keyframes exportBarShimmer { + 0% { + transform: translateX(0%); + } + 100% { + transform: translateX(300%); } +} - .conflict--invalid & { - color: var(--alert-color); +@keyframes exportBarShift { + 0% { + background-position: 0% 0; + } + 100% { + background-position: -200% 0; } } -.conflict__content { - padding: var(--spacing-l); +.export-overlay__meta { + display: flex; + justify-content: space-between; + font-size: 13px; + color: var(--light-body-color); +} + +.export-overlay__steps { + margin-top: 12px; + font-size: 13px; + color: var(--base-body-color); +} + +.export-overlay__step { + display: flex; + align-items: center; + gap: 8px; + opacity: 0.9; +} + +.export-overlay__step::before { + content: ""; + display: inline-block; + width: 6px; + height: 6px; + border-radius: 999px; + background: #3b82f6; +} + +/* Tiny helper note shown when progress stalls due to rate-limiting */ +.rate-limit-notice { + margin-top: 8px; + font-size: 12px; + color: var(--light-body-color); + text-align: center; } .form__item { @@ -377,16 +1293,16 @@ code { .conflicts-manager__group { margin: 20px 0; - &:first-child { margin-top: 0; } } .conflicts-manager__group__title { - font-weight: bold; - font-size: var(--font-size-l); - padding: var(--spacing-m) var(--spacing-l); + font-weight: 700; + font-size: var(--font-size-m); + padding: 8px var(--spacing-l); + color: var(--light-body-color); } .no-text-wrap { @@ -427,9 +1343,12 @@ form { border: 3px solid white; /* http://smoothshadows.com/#djEsMSw2LDAuMTIsNzgsMCwwLCMwMzA3MTIsI2YzZjRmNiwjZmZmZmZmLDI%3D */ box-shadow: - 0px 0px 2px rgba(3, 7, 18, 0.02), 0px 0px 9px rgba(3, 7, 18, 0.04), - 0px 0px 20px rgba(3, 7, 18, 0.06), 0px 0px 35px rgba(3, 7, 18, 0.08), - 0px 0px 54px rgba(3, 7, 18, 0.1), 0px 0px 78px rgba(3, 7, 18, 0.12); + 0px 0px 2px rgba(3, 7, 18, 0.02), + 0px 0px 9px rgba(3, 7, 18, 0.04), + 0px 0px 20px rgba(3, 7, 18, 0.06), + 0px 0px 35px rgba(3, 7, 18, 0.08), + 0px 0px 54px rgba(3, 7, 18, 0.1), + 0px 0px 78px rgba(3, 7, 18, 0.12); } .progress__meter__track { @@ -447,7 +1366,6 @@ form { a { color: var(--accent-color); } - ul { padding: 0 0 0 20px; } diff --git a/import-export-schema/src/main.tsx b/import-export-schema/src/main.tsx index 0286d276..43515d9c 100644 --- a/import-export-schema/src/main.tsx +++ b/import-export-schema/src/main.tsx @@ -2,10 +2,19 @@ import { connect } from 'datocms-plugin-sdk'; import 'datocms-react-ui/styles.css'; import '@xyflow/react/dist/style.css'; import './index.css'; +import { Spinner } from 'datocms-react-ui'; +import { lazy, Suspense } from 'react'; import { render } from '@/utils/render'; -import { Config } from './entrypoints/Config'; -import ExportPage from './entrypoints/ExportPage'; -import { ImportPage } from './entrypoints/ImportPage'; + +// Lazy-load entrypoints to reduce initial bundle size +const LazyConfig = lazy(() => + import('./entrypoints/Config').then((m) => ({ default: m.Config })), +); +const LazyExportHome = lazy(() => import('./entrypoints/ExportHome')); +const LazyExportPage = lazy(() => import('./entrypoints/ExportPage')); +const LazyImportPage = lazy(() => + import('./entrypoints/ImportPage').then((m) => ({ default: m.ImportPage })), +); connect({ schemaItemTypeDropdownActions() { @@ -19,7 +28,7 @@ connect({ }, async executeSchemaItemTypeDropdownAction(_id, itemType, ctx) { ctx.navigateTo( - `${ctx.isEnvironmentPrimary ? '' : `/environments/${ctx.environment}`}/configuration/p/${ctx.plugin.id}/pages/import-export?itemTypeId=${itemType.id}`, + `${ctx.isEnvironmentPrimary ? '' : `/environments/${ctx.environment}`}/configuration/p/${ctx.plugin.id}/pages/export?itemTypeId=${itemType.id}`, ); }, settingsAreaSidebarItemGroups() { @@ -28,25 +37,59 @@ connect({ label: 'Schema', items: [ { - label: 'Import/Export', + label: 'Import', icon: 'file-import', - pointsTo: { pageId: 'import-export' }, + pointsTo: { pageId: 'import' }, + }, + { + label: 'Export', + icon: 'file-export', + pointsTo: { pageId: 'export' }, }, ], }, ]; }, - renderPage(_id, ctx) { + renderPage(pageId, ctx) { const params = new URLSearchParams(ctx.location.search); const itemTypeId = params.get('itemTypeId'); - if (!itemTypeId) { - return render(); + if (pageId === 'import') { + return render( + }> + + , + ); } - return render(); + if (pageId === 'export') { + if (itemTypeId) { + return render( + }> + + , + ); + } + // Export landing with selection flow + return render( + }> + + , + ); + } + + // Fallback for legacy pageId + return render( + }> + + , + ); }, renderConfigScreen(ctx) { - return render(); + return render( + }> + + , + ); }, }); diff --git a/import-export-schema/src/types/lodash-es.d.ts b/import-export-schema/src/types/lodash-es.d.ts new file mode 100644 index 00000000..0d5e8d5f --- /dev/null +++ b/import-export-schema/src/types/lodash-es.d.ts @@ -0,0 +1,18 @@ +declare module 'lodash-es' { + // Minimal ambient module to satisfy TS in this project. For richer types, install @types/lodash-es. + export const get: any; + export const set: any; + export const pick: any; + export const omit: any; + export const intersection: any; + export const keyBy: any; + export const sortBy: any; + export const find: any; + export const without: any; + export const map: any; + export const defaults: any; + export const mapValues: any; + export const groupBy: any; + export const isEqual: any; + export const cloneDeep: any; +} diff --git a/import-export-schema/src/utils/ProjectSchema.ts b/import-export-schema/src/utils/ProjectSchema.ts index be8389a8..2d77fa54 100644 --- a/import-export-schema/src/utils/ProjectSchema.ts +++ b/import-export-schema/src/utils/ProjectSchema.ts @@ -1,5 +1,4 @@ import type { Client, SchemaTypes } from '@datocms/cma-client'; -import { groupBy } from 'lodash-es'; export class ProjectSchema { public client: Client; @@ -12,9 +11,50 @@ export class ProjectSchema { private fieldsByItemType: Map = new Map(); private fieldsetsByItemType: Map = new Map(); private alreadyFetchedRelatedFields: Map = new Map(); + // In-flight promises to prevent duplicate requests per item type + private fieldsPromisesByItemType: Map> = + new Map(); + private fieldsetsPromisesByItemType: Map< + string, + Promise + > = new Map(); + + // Simple throttle to avoid hitting 429 when many models are selected + // Keep concurrency conservative: DatoCMS rate-limits bursty calls + // If needed, make this configurable later via constructor param + private throttleMax = 2; + private throttleActive = 0; + private throttleQueue: Array<() => void> = []; constructor(client: Client) { this.client = client; + try { + // Allow overriding throttle via localStorage for large schemas + const raw = + typeof window !== 'undefined' + ? window.localStorage?.getItem?.('schemaThrottleMax') + : undefined; + const parsed = raw ? parseInt(raw, 10) : NaN; + if (!Number.isNaN(parsed) && parsed > 0 && parsed < 16) { + this.throttleMax = parsed; + } + } catch { + // ignore + } + } + + private async withThrottle(fn: () => Promise): Promise { + if (this.throttleActive >= this.throttleMax) { + await new Promise((resolve) => this.throttleQueue.push(resolve)); + } + this.throttleActive += 1; + try { + return await fn(); + } finally { + this.throttleActive -= 1; + const next = this.throttleQueue.shift(); + if (next) next(); + } } private async loadItemTypes(): Promise { @@ -89,7 +129,7 @@ export class ProjectSchema { const itemType = this.itemTypesByName.get(name); if (!itemType) { - throw new Error(`Item type with API key '${name}' not found`); + throw new Error(`Item type with name '${name}' not found`); } return itemType; @@ -120,14 +160,20 @@ export class ProjectSchema { async getItemTypeFieldsAndFieldsets( itemType: SchemaTypes.ItemType, ): Promise<[SchemaTypes.Field[], SchemaTypes.Fieldset[]]> { - if ( - !itemType.attributes.modular_block && - !this.fieldsetsByItemType.get(itemType.id) - ) { - const { data: fieldsets } = await this.client.fieldsets.rawList( - itemType.id, - ); - this.fieldsetsByItemType.set(itemType.id, fieldsets); + if (!itemType.attributes.modular_block) { + if (!this.fieldsetsByItemType.get(itemType.id)) { + let promise = this.fieldsetsPromisesByItemType.get(itemType.id); + if (!promise) { + promise = this.withThrottle(async () => { + const { data } = await this.client.fieldsets.rawList(itemType.id); + return data; + }); + this.fieldsetsPromisesByItemType.set(itemType.id, promise); + } + const fieldsets = await promise; + this.fieldsetsByItemType.set(itemType.id, fieldsets); + this.fieldsetsPromisesByItemType.delete(itemType.id); + } } // Check if we already have the fields cached @@ -140,25 +186,22 @@ export class ProjectSchema { return [cachedFields, this.fieldsetsByItemType.get(itemType.id) || []]; } - // Fetch and cache the fields - const { data: fields } = await this.client.fields.rawRelated(itemType.id); - - this.alreadyFetchedRelatedFields.set(itemType.id, true); - - const fieldsByItemTypeId = groupBy( - fields, - 'relationships.item_type.data.id', - ); - - this.fieldsByItemType.set( - itemType.id, - fieldsByItemTypeId[itemType.id] || [], - ); - - for (const [itemTypeId, fields] of Object.entries(fieldsByItemTypeId)) { - this.fieldsByItemType.set(itemTypeId, fields); + let fields = this.fieldsByItemType.get(itemType.id); + if (!fields || !this.alreadyFetchedRelatedFields.get(itemType.id)) { + let promise = this.fieldsPromisesByItemType.get(itemType.id); + if (!promise) { + promise = this.withThrottle(async () => { + const { data } = await this.client.fields.rawList(itemType.id); + return data; + }); + this.fieldsPromisesByItemType.set(itemType.id, promise); + } + fields = await promise; + this.fieldsByItemType.set(itemType.id, fields); + this.alreadyFetchedRelatedFields.set(itemType.id, true); + this.fieldsPromisesByItemType.delete(itemType.id); } - return this.getItemTypeFieldsAndFieldsets(itemType); + return [fields, this.fieldsetsByItemType.get(itemType.id) || []]; } } diff --git a/import-export-schema/src/utils/createCmaClient.ts b/import-export-schema/src/utils/createCmaClient.ts new file mode 100644 index 00000000..8ffaa456 --- /dev/null +++ b/import-export-schema/src/utils/createCmaClient.ts @@ -0,0 +1,24 @@ +import { + buildClient, + type Client, + type ClientConfigOptions, +} from '@datocms/cma-client'; +import type { RenderConfigScreenCtx, RenderPageCtx } from 'datocms-plugin-sdk'; + +type CtxWithAuth = + | Pick + | Pick; + +export function createCmaClient( + ctx: CtxWithAuth, + overrides?: Partial, +): Client { + return buildClient({ + apiToken: ctx.currentUserAccessToken!, + environment: ctx.environment, + // Sensible defaults for plugin usage + autoRetry: true, + requestTimeout: 60000, + ...(overrides || {}), + }); +} diff --git a/import-export-schema/src/utils/datocms/appearance.ts b/import-export-schema/src/utils/datocms/appearance.ts new file mode 100644 index 00000000..04f0dd4b --- /dev/null +++ b/import-export-schema/src/utils/datocms/appearance.ts @@ -0,0 +1,81 @@ +import type { SchemaTypes } from '@datocms/cma-client'; +import { + defaultAppearanceForFieldType, + isHardcodedEditor, +} from '@/utils/datocms/fieldTypeInfo'; + +/** + * Return an appearance suitable for export. If the original field appearance is + * missing, or if it references a non-exported plugin editor, fallback to the + * default appearance for the field type. Also filters addons to those whose IDs + * are included in the allowlist. + */ +export async function ensureExportableAppearance( + field: SchemaTypes.Field, + allowedPluginIds: string[], +): Promise> { + const original = field.attributes.appearance; + const hasAppearance = !!original; + const editorId = original?.editor; + const editorIsHardcoded = editorId ? await isHardcodedEditor(editorId) : true; + + const appearance = + hasAppearance && (editorIsHardcoded || allowedPluginIds.includes(editorId!)) + ? { ...original } + : await defaultAppearanceForFieldType(field.attributes.field_type); + + // Filter addons by allowlist + appearance.addons = (original?.addons ?? []).filter((addon: { id: string }) => + allowedPluginIds.includes(addon.id), + ) as NonNullable; + + return appearance; +} + +/** + * Map a field appearance to the target project by translating any plugin-based + * editor/addon IDs using the provided mapping. If the editor is hardcoded, keep + * it as-is. Missing appearances will be replaced with a default for the field type. + */ +export async function mapAppearanceToProject( + field: SchemaTypes.Field, + pluginIdMappings: Map, +): Promise> { + const base = await defaultAppearanceForFieldType(field.attributes.field_type); + const original = field.attributes.appearance; + let next = { ...base } as NonNullable< + SchemaTypes.Field['attributes']['appearance'] + >; + + if (original) { + const editorId = original.editor; + const isHardcoded = await isHardcodedEditor(editorId); + if (isHardcoded) { + next = { + ...next, + editor: editorId, + parameters: original.parameters, + field_extension: original.field_extension, + }; + } else if (editorId && pluginIdMappings.has(editorId)) { + next = { + ...next, + editor: pluginIdMappings.get(editorId)!, + }; + } + + const sourceAddons = (original.addons ?? []) as Array< + { id: string } & Record + >; + next.addons = sourceAddons + .filter((addon) => pluginIdMappings.has(addon.id)) + .map((addon) => ({ + ...addon, + id: pluginIdMappings.get(addon.id)!, + parameters: + (addon as { parameters?: Record }).parameters ?? {}, + })); + } + + return next; +} diff --git a/import-export-schema/src/utils/datocms/fieldTypeInfo.ts b/import-export-schema/src/utils/datocms/fieldTypeInfo.ts index e1e3dc2f..cd57df46 100644 --- a/import-export-schema/src/utils/datocms/fieldTypeInfo.ts +++ b/import-export-schema/src/utils/datocms/fieldTypeInfo.ts @@ -10,15 +10,60 @@ type FieldTypeInfo = Record< let cached: Promise | undefined; -async function fetchFieldTypeInfo() { - if (cached) { - return cached; - } +// Built-in fallback for default editors when the remote metadata endpoint is +// unavailable (eg: offline, CORS/network issues). Parameters are kept empty +// unless the editor requires a specific shape. +const FALLBACK_DEFAULT_EDITORS: Record< + string, + { id: string; parameters: Record } +> = { + boolean: { id: 'boolean', parameters: {} }, + color: { id: 'color_picker', parameters: {} }, + date: { id: 'date_picker', parameters: {} }, + date_time: { id: 'date_time_picker', parameters: {} }, + file: { id: 'file', parameters: {} }, + float: { id: 'float', parameters: {} }, + gallery: { id: 'gallery', parameters: {} }, + integer: { id: 'integer', parameters: {} }, + json: { id: 'json', parameters: {} }, + lat_lon: { id: 'map', parameters: {} }, + link: { id: 'link_select', parameters: {} }, + links: { id: 'links_select', parameters: {} }, + rich_text: { id: 'rich_text', parameters: {} }, + seo: { id: 'seo', parameters: {} }, + single_block: { id: 'framed_single_block', parameters: {} }, + slug: { id: 'slug', parameters: {} }, + string: { id: 'single_line', parameters: {} }, + structured_text: { id: 'structured_text', parameters: {} }, + text: { id: 'textarea', parameters: {} }, + video: { id: 'video', parameters: {} }, +}; - cached = fetch('https://internal.datocms.com/field-types').then((response) => - response.json(), +function fallbackFieldTypeInfo(): FieldTypeInfo { + const entries = Object.entries(FALLBACK_DEFAULT_EDITORS).map( + ([fieldType, editor]) => [ + fieldType, + { + default_editor: editor, + other_editor_ids: [] as string[], + }, + ], ); + return Object.fromEntries(entries) as FieldTypeInfo; +} +async function fetchFieldTypeInfo() { + if (cached) return cached; + cached = (async () => { + try { + const response = await fetch('https://internal.datocms.com/field-types'); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return (await response.json()) as FieldTypeInfo; + } catch { + // Fall back to a local static map to keep flows working safely + return fallbackFieldTypeInfo(); + } + })(); return cached; } @@ -32,17 +77,29 @@ async function allEditors() { } export async function isHardcodedEditor(editor: string) { - return (await allEditors()).includes(editor); + try { + return (await allEditors()).includes(editor); + } catch { + // Fallback to a conservative check against known built-ins + return Object.values(FALLBACK_DEFAULT_EDITORS) + .map((e) => e.id) + .includes(editor); + } } export async function defaultAppearanceForFieldType( fieldType: string, ): Promise { const info = (await fetchFieldTypeInfo())[fieldType]; + const defaultEditor = info?.default_editor || + FALLBACK_DEFAULT_EDITORS[fieldType] || { + id: 'single_line', + parameters: {}, + }; return { - editor: info.default_editor.id, - parameters: info.default_editor.parameters, + editor: defaultEditor.id, + parameters: defaultEditor.parameters, field_extension: undefined, addons: [], }; diff --git a/import-export-schema/src/utils/datocms/schema.ts b/import-export-schema/src/utils/datocms/schema.ts index e48c0f52..0a78a8c8 100644 --- a/import-export-schema/src/utils/datocms/schema.ts +++ b/import-export-schema/src/utils/datocms/schema.ts @@ -1,3 +1,6 @@ +import type { SchemaTypes } from '@datocms/cma-client'; +import type { FieldAttributes } from '@datocms/cma-client/dist/types/generated/SchemaTypes'; +import { get } from 'lodash-es'; import boolean from '@/icons/fieldgroup-boolean.svg?react'; import color from '@/icons/fieldgroup-color.svg?react'; import datetime from '@/icons/fieldgroup-datetime.svg?react'; @@ -9,10 +12,10 @@ import reference from '@/icons/fieldgroup-reference.svg?react'; import richText from '@/icons/fieldgroup-rich_text.svg?react'; import seo from '@/icons/fieldgroup-seo.svg?react'; import structuredText from '@/icons/fieldgroup-structured_text.svg?react'; -import type { SchemaTypes } from '@datocms/cma-client'; -import type { FieldAttributes } from '@datocms/cma-client/dist/types/generated/SchemaTypes'; -import { get } from 'lodash-es'; -import { isHardcodedEditor } from './fieldTypeInfo'; + +// Note: Avoid network requests when resolving plugin dependencies. +// Call sites should pass the set of installed plugin IDs to determine +// whether an editor/addon refers to a plugin. type SvgComponent = React.FunctionComponent< React.ComponentProps<'svg'> & { @@ -217,15 +220,27 @@ export function findLinkedItemTypeIds(field: SchemaTypes.Field) { return fieldLinkedItemTypeIds; } -export async function findLinkedPluginIds(field: SchemaTypes.Field) { +export function findLinkedPluginIds( + field: SchemaTypes.Field, + installedPluginIds?: Set, +) { const fieldLinkedPluginIds = new Set(); + // Some fields may have no appearance set (older exports or defaults) + const editorId = field.attributes.appearance?.editor; + const hasInstalledList = !!installedPluginIds && installedPluginIds.size > 0; - if (!(await isHardcodedEditor(field.attributes.appearance.editor))) { - fieldLinkedPluginIds.add(field.attributes.appearance.editor); + // If we have a list of installed plugins, only collect editors that match. + // If not, skip editor to avoid false-positives for built-in editors. + if (editorId && hasInstalledList && installedPluginIds!.has(editorId)) { + fieldLinkedPluginIds.add(editorId); } - for (const addon of field.attributes.appearance.addons) { - fieldLinkedPluginIds.add(addon.id); + for (const addon of field.attributes.appearance?.addons ?? []) { + // If we don't have the installed list yet, include addons optimistically; + // otherwise include only if installed. + if (!hasInstalledList || installedPluginIds!.has(addon.id)) { + fieldLinkedPluginIds.add(addon.id); + } } return fieldLinkedPluginIds; diff --git a/import-export-schema/src/utils/datocms/validators.ts b/import-export-schema/src/utils/datocms/validators.ts new file mode 100644 index 00000000..dae617ce --- /dev/null +++ b/import-export-schema/src/utils/datocms/validators.ts @@ -0,0 +1,34 @@ +import type { SchemaTypes } from '@datocms/cma-client'; +import { get, set } from 'lodash-es'; +import { + validatorsContainingBlocks, + validatorsContainingLinks, +} from '@/utils/datocms/schema'; + +export function collectLinkValidatorPaths( + fieldType: SchemaTypes.Field['attributes']['field_type'], +): string[] { + return [ + ...validatorsContainingLinks.filter((i) => i.field_type === fieldType), + ...validatorsContainingBlocks.filter((i) => i.field_type === fieldType), + ].map((i) => i.validator); +} + +export function filterValidatorIds( + field: SchemaTypes.Field, + allowedItemTypeIds: string[], +): NonNullable { + const validators = (field.attributes.validators ?? {}) as Record< + string, + unknown + >; + const paths = collectLinkValidatorPaths(field.attributes.field_type); + for (const path of paths) { + const ids = (get(validators, path) as string[]) || []; + const filtered = ids.filter((id) => allowedItemTypeIds.includes(id)); + set(validators, path, filtered); + } + return validators as NonNullable< + SchemaTypes.Field['attributes']['validators'] + >; +} diff --git a/import-export-schema/src/utils/debug.ts b/import-export-schema/src/utils/debug.ts new file mode 100644 index 00000000..970fe4ba --- /dev/null +++ b/import-export-schema/src/utils/debug.ts @@ -0,0 +1,17 @@ +export function isDebug(flag = 'schemaDebug'): boolean { + try { + return ( + typeof window !== 'undefined' && + window.localStorage?.getItem(flag) === '1' + ); + } catch { + return false; + } +} + +export function debugLog(flag = 'schemaDebug', ...args: unknown[]) { + if (isDebug(flag)) { + // eslint-disable-next-line no-console + console.log(...args); + } +} diff --git a/import-export-schema/src/utils/exportDoc/normalize.ts b/import-export-schema/src/utils/exportDoc/normalize.ts new file mode 100644 index 00000000..91a1ea08 --- /dev/null +++ b/import-export-schema/src/utils/exportDoc/normalize.ts @@ -0,0 +1,57 @@ +import type { SchemaTypes } from '@datocms/cma-client'; +import type { ExportDoc, ExportDocV2 } from '@/utils/types'; + +function asStringId(e: T): T { + return { ...e, id: String(e.id) } as T; +} + +/** Upcast older export docs to V2 and normalize all IDs to strings. */ +export function normalizeExportDoc(doc: ExportDoc): ExportDocV2 { + const entities = ( + doc.entities as Array< + | SchemaTypes.ItemType + | SchemaTypes.Field + | SchemaTypes.Fieldset + | SchemaTypes.Plugin + > + ).map((e) => asStringId(e)); + + if (doc.version === '2') { + return { ...doc, entities, rootItemTypeId: String(doc.rootItemTypeId) }; + } + + // For V1 we cannot know the root with certainty; pick the first model with no inbound links + const itemTypes = entities.filter( + (e) => e.type === 'item_type', + ) as SchemaTypes.ItemType[]; + const fields = entities.filter( + (e) => e.type === 'field', + ) as SchemaTypes.Field[]; + + const linkTargets = new Set(); + for (const f of fields) { + const itemTypeId = String(f.relationships.item_type.data.id); + // Best-effort: add validators containing item type IDs + const validators = (f.attributes.validators ?? {}) as Record< + string, + unknown + >; + const maybeArrays = Object.values(validators).filter((v) => + Array.isArray(v), + ) as string[][]; + for (const arr of maybeArrays) { + for (const id of arr) { + linkTargets.add(String(id)); + } + } + linkTargets.add(itemTypeId); + } + + const root = + itemTypes.find((it) => !linkTargets.has(String(it.id))) || itemTypes[0]; + return { + version: '2', + rootItemTypeId: String(root?.id ?? ''), + entities, + }; +} diff --git a/import-export-schema/src/utils/fieldTypeGroups.ts b/import-export-schema/src/utils/fieldTypeGroups.ts deleted file mode 100644 index fb34989d..00000000 --- a/import-export-schema/src/utils/fieldTypeGroups.ts +++ /dev/null @@ -1,136 +0,0 @@ -import boolean from '@/icons/fieldgroup-boolean.svg?react'; -import color from '@/icons/fieldgroup-color.svg?react'; -import datetime from '@/icons/fieldgroup-datetime.svg?react'; -import json from '@/icons/fieldgroup-json.svg?react'; -import location from '@/icons/fieldgroup-location.svg?react'; -import media from '@/icons/fieldgroup-media.svg?react'; -import number from '@/icons/fieldgroup-number.svg?react'; -import reference from '@/icons/fieldgroup-reference.svg?react'; -import richText from '@/icons/fieldgroup-rich_text.svg?react'; -import seo from '@/icons/fieldgroup-seo.svg?react'; -import structuredText from '@/icons/fieldgroup-structured_text.svg?react'; -import type { FieldAttributes } from '@datocms/cma-client/dist/types/generated/SchemaTypes'; - -type SvgComponent = React.FunctionComponent< - React.ComponentProps<'svg'> & { - title?: string; - titleId?: string; - desc?: string; - descId?: string; - } ->; - -const groups: Array<{ name: string; types: FieldAttributes['field_type'][] }> = - [ - { - name: 'text', - types: ['string', 'text', 'structured_text'], - }, - { - name: 'rich_text', - types: ['single_block', 'rich_text'], - }, - { - name: 'media', - types: ['file', 'gallery', 'video'], - }, - { - name: 'datetime', - types: ['date', 'date_time'], - }, - { - name: 'number', - types: ['integer', 'float'], - }, - { - name: 'boolean', - types: ['boolean'], - }, - { - name: 'location', - types: ['lat_lon'], - }, - { - name: 'color', - types: ['color'], - }, - { - name: 'seo', - types: ['slug', 'seo'], - }, - { - name: 'reference', - types: ['link', 'links'], - }, - { - name: 'json', - types: ['json'], - }, - ]; - -export default groups; - -export const groupThemes: Record< - string, - { - IconComponent: SvgComponent; - fgColor: string; - bgColor: string; - } -> = { - boolean: { - IconComponent: boolean, - fgColor: '#c82b1d', - bgColor: '#fde5e3', - }, - color: { - IconComponent: color, - fgColor: '#b02857', - bgColor: '#fce2eb', - }, - datetime: { - IconComponent: datetime, - fgColor: '#d76f0e', - bgColor: '#fef0e2', - }, - json: { - IconComponent: json, - fgColor: '#80a617', - bgColor: '#f5fdde', - }, - location: { - IconComponent: location, - fgColor: '#1d9f2f', - bgColor: '#defce2', - }, - media: { - IconComponent: media, - fgColor: '#38ada3', - bgColor: '#e5fbf9', - }, - number: { - IconComponent: number, - fgColor: '#008499', - bgColor: '#d7faff', - }, - reference: { - IconComponent: reference, - fgColor: '#1b5899', - bgColor: '#ddecfc', - }, - rich_text: { - IconComponent: richText, - fgColor: '#38388d', - bgColor: '#e2e2fa', - }, - seo: { - IconComponent: seo, - fgColor: '#7e2e86', - bgColor: '#f8dffa', - }, - text: { - IconComponent: structuredText, - fgColor: '#998100', - bgColor: '#FFF8D6', - }, -}; diff --git a/import-export-schema/src/utils/graph/analysis.ts b/import-export-schema/src/utils/graph/analysis.ts new file mode 100644 index 00000000..41c9cd7c --- /dev/null +++ b/import-export-schema/src/utils/graph/analysis.ts @@ -0,0 +1,145 @@ +import type { ItemTypeNode } from '@/components/ItemTypeNodeRenderer'; +import type { PluginNode } from '@/components/PluginNodeRenderer'; +import type { AppEdge, Graph } from './types'; + +type Adjacency = Map>; + +function ensure(map: Map>, key: string) { + let set = map.get(key); + if (!set) { + set = new Set(); + map.set(key, set); + } + return set; +} + +export function buildDirectedAdjacency(graph: Graph): Adjacency { + const adj: Adjacency = new Map(); + for (const node of graph.nodes) { + ensure(adj, node.id); + } + for (const edge of graph.edges) { + ensure(adj, edge.source).add(edge.target); + // make sure target exists even if isolated + ensure(adj, edge.target); + } + return adj; +} + +export function buildUndirectedAdjacency(graph: Graph): Adjacency { + const adj: Adjacency = new Map(); + for (const node of graph.nodes) { + ensure(adj, node.id); + } + for (const edge of graph.edges) { + ensure(adj, edge.source).add(edge.target); + ensure(adj, edge.target).add(edge.source); + } + return adj; +} + +export function getConnectedComponents(graph: Graph): string[][] { + const adj = buildUndirectedAdjacency(graph); + const seen = new Set(); + const components: string[][] = []; + + for (const id of adj.keys()) { + if (seen.has(id)) continue; + const comp: string[] = []; + const queue: string[] = [id]; + seen.add(id); + while (queue.length) { + const cur = queue.shift()!; + comp.push(cur); + for (const nb of adj.get(cur)!) { + if (!seen.has(nb)) { + seen.add(nb); + queue.push(nb); + } + } + } + components.push(comp); + } + + return components; +} + +// Tarjan's algorithm for SCCs +export function getStronglyConnectedComponents(graph: Graph): string[][] { + const adj = buildDirectedAdjacency(graph); + let index = 0; + const indices = new Map(); + const lowlink = new Map(); + const onStack = new Set(); + const stack: string[] = []; + const sccs: string[][] = []; + + function strongconnect(v: string) { + indices.set(v, index); + lowlink.set(v, index); + index++; + stack.push(v); + onStack.add(v); + + for (const w of adj.get(v)!) { + if (!indices.has(w)) { + strongconnect(w); + lowlink.set(v, Math.min(lowlink.get(v)!, lowlink.get(w)!)); + } else if (onStack.has(w)) { + lowlink.set(v, Math.min(lowlink.get(v)!, indices.get(w)!)); + } + } + + if (lowlink.get(v) === indices.get(v)) { + const comp: string[] = []; + let w: string | undefined; + do { + w = stack.pop(); + if (w === undefined) break; + onStack.delete(w); + comp.push(w); + } while (w !== v); + sccs.push(comp); + } + } + + for (const v of adj.keys()) { + if (!indices.has(v)) strongconnect(v); + } + + return sccs; +} + +export function countCycles(graph: Graph): number { + const sccs = getStronglyConnectedComponents(graph); + return sccs.filter((comp) => comp.length > 1).length; +} + +export function splitNodesByType(graph: Graph): { + itemTypeNodes: ItemTypeNode[]; + pluginNodes: PluginNode[]; +} { + const itemTypeNodes = graph.nodes.filter( + (n) => n.type === 'itemType', + ) as ItemTypeNode[]; + const pluginNodes = graph.nodes.filter( + (n) => n.type === 'plugin', + ) as PluginNode[]; + return { itemTypeNodes, pluginNodes }; +} + +export function findInboundEdges( + graph: Graph, + targetId: string, + sourceWhitelist?: Set, +): AppEdge[] { + return graph.edges.filter((e) => { + if (e.target !== targetId) return false; + if (!sourceWhitelist) return true; + return sourceWhitelist.has(e.source); + }); +} + +export function findOutboundEdges(graph: Graph, sourceId: string): AppEdge[] { + return graph.edges.filter((e) => e.source === sourceId); +} diff --git a/import-export-schema/src/utils/graph/buildGraph.ts b/import-export-schema/src/utils/graph/buildGraph.ts new file mode 100644 index 00000000..8991fb63 --- /dev/null +++ b/import-export-schema/src/utils/graph/buildGraph.ts @@ -0,0 +1,178 @@ +import type { SchemaTypes } from '@datocms/cma-client'; +import { buildHierarchyNodes } from '@/utils/graph/buildHierarchyNodes'; +import { buildEdgesForItemType } from '@/utils/graph/edges'; +import { buildItemTypeNode, buildPluginNode } from '@/utils/graph/nodes'; +import { rebuildGraphWithPositionsFromHierarchy } from '@/utils/graph/rebuildGraphWithPositionsFromHierarchy'; +import { deterministicGraphSort } from '@/utils/graph/sort'; +import type { Graph } from '@/utils/graph/types'; +import type { ISchemaSource } from '@/utils/schema/ISchemaSource'; + +type BuildGraphOptions = { + source: ISchemaSource; + initialItemTypes: SchemaTypes.ItemType[]; + selectedItemTypeIds?: string[]; // export use-case to include edges + itemTypeIdsToSkip?: string[]; // import use-case to avoid edges + onProgress?: (update: { + done: number; + total: number; + label: string; + phase?: 'scan' | 'build'; + }) => void; +}; + +export async function buildGraph({ + source, + initialItemTypes, + selectedItemTypeIds = [], + itemTypeIdsToSkip = [], + onProgress, +}: BuildGraphOptions): Promise { + const graph: Graph = { nodes: [], edges: [] }; + + const knownPluginIds = await source.getKnownPluginIds(); + + const rootItemTypeIds = new Set(initialItemTypes.map((it) => it.id)); + const visitedItemTypeIds = new Set(Array.from(rootItemTypeIds)); + const itemTypesById = new Map( + initialItemTypes.map((it) => [it.id, it]), + ); + const fieldsByItemTypeId = new Map(); + const fieldsetsByItemTypeId = new Map(); + const discoveredPluginIdsInOrder: string[] = []; + const pluginsById = new Map(); + + const toExplore: SchemaTypes.ItemType[] = [...initialItemTypes]; + onProgress?.({ done: 0, total: 0, label: 'Scanning schema…', phase: 'scan' }); + + while (toExplore.length > 0) { + const current = toExplore.shift(); + if (!current) break; + const [fields, fieldsets] = + await source.getItemTypeFieldsAndFieldsets(current); + fieldsByItemTypeId.set(current.id, fields); + fieldsetsByItemTypeId.set(current.id, fieldsets); + + onProgress?.({ + done: 0, + total: 0, + label: `Scanning: ${current.attributes.name}`, + phase: 'scan', + }); + + // Discover neighbors via edges helper + const [, linkedItemTypeIds, linkedPluginIds] = buildEdgesForItemType( + current, + fields, + new Set(initialItemTypes.map((it) => it.id)), + knownPluginIds, + ); + + for (const linkedItemTypeId of linkedItemTypeIds) { + if (!visitedItemTypeIds.has(linkedItemTypeId)) { + const linked = await source.getItemTypeById(linkedItemTypeId); + visitedItemTypeIds.add(linkedItemTypeId); + itemTypesById.set(linkedItemTypeId, linked); + toExplore.push(linked); + } + } + for (const linkedPluginId of linkedPluginIds) { + if (!pluginsById.has(linkedPluginId)) { + const plugin = await source.getPluginById(linkedPluginId); + pluginsById.set(linkedPluginId, plugin); + discoveredPluginIdsInOrder.push(linkedPluginId); + } + } + } + + // Total is count of nodes to render + const total = visitedItemTypeIds.size + pluginsById.size; + let done = 0; + onProgress?.({ done, total, label: 'Preparing export…', phase: 'build' }); + + for (const itemTypeId of visitedItemTypeIds) { + const itemType = itemTypesById.get(itemTypeId); + if (!itemType) { + continue; + } + const fields = fieldsByItemTypeId.get(itemTypeId) || []; + const fieldsets = fieldsetsByItemTypeId.get(itemTypeId) || []; + + onProgress?.({ + done, + total, + label: `Model/Block: ${itemType.attributes.name}`, + phase: 'build', + }); + + graph.nodes.push(buildItemTypeNode(itemType, fields, fieldsets)); + + if (!itemTypeIdsToSkip.includes(itemType.id)) { + const [edges, linkedItemTypeIds, linkedPluginIds] = buildEdgesForItemType( + itemType, + fields, + rootItemTypeIds, + knownPluginIds, + ); + + // Include edges when: + // - No selection was provided (eg. Import graph) → include all edges + // - The source item type is selected + // - Any target item type of this edge set is selected + const includeEdges = + selectedItemTypeIds.length === 0 || + selectedItemTypeIds.includes(itemType.id) || + Array.from(linkedItemTypeIds).some((id) => + selectedItemTypeIds.includes(id), + ); + + if (includeEdges) { + graph.edges.push(...edges); + } + + // Queue discovered neighbors + for (const linkedItemTypeId of linkedItemTypeIds) { + if (!visitedItemTypeIds.has(linkedItemTypeId)) { + const linked = await source.getItemTypeById(linkedItemTypeId); + visitedItemTypeIds.add(linkedItemTypeId); + itemTypesById.set(linkedItemTypeId, linked); + toExplore.push(linked); + } + } + + for (const linkedPluginId of linkedPluginIds) { + if (!pluginsById.has(linkedPluginId)) { + const plugin = await source.getPluginById(linkedPluginId); + pluginsById.set(linkedPluginId, plugin); + discoveredPluginIdsInOrder.push(linkedPluginId); + } + } + } + + done += 1; + onProgress?.({ + done, + total, + label: `Fields/Fieldsets for ${itemType.attributes.name}`, + phase: 'build', + }); + } + + for (const pluginId of discoveredPluginIdsInOrder) { + const plugin = pluginsById.get(pluginId); + if (!plugin) continue; + graph.nodes.push(buildPluginNode(plugin)); + done += 1; + onProgress?.({ + done, + total, + label: `Plugin: ${plugin.attributes.name}`, + phase: 'build', + }); + } + + const sortedGraph = deterministicGraphSort(graph); + if (sortedGraph.nodes.length === 0) return sortedGraph; + + const hierarchy = buildHierarchyNodes(sortedGraph, selectedItemTypeIds); + return rebuildGraphWithPositionsFromHierarchy(hierarchy, sortedGraph.edges); +} diff --git a/import-export-schema/src/utils/graph/buildHierarchyNodes.ts b/import-export-schema/src/utils/graph/buildHierarchyNodes.ts index a83c5edd..7e840c49 100644 --- a/import-export-schema/src/utils/graph/buildHierarchyNodes.ts +++ b/import-export-schema/src/utils/graph/buildHierarchyNodes.ts @@ -5,12 +5,33 @@ export function buildHierarchyNodes( graph: Graph, priorityGivenToEdgesComingFromItemTypeIds?: string[], ) { + const nodeIds = new Set(graph.nodes.map((n) => n.id)); + const targets = new Set(graph.edges.map((e) => e.target)); + const rootIds = Array.from(nodeIds).filter((id) => !targets.has(id)); + + const hasMultipleRoots = rootIds.length > 1; + + const nodesForStratify: AppNode[] = hasMultipleRoots + ? ([ + // Synthetic root only used to satisfy single-root requirement + { + id: 'synthetic-root', + type: 'plugin', + position: { x: 0, y: 0 }, + data: {}, + } as unknown as AppNode, + ...graph.nodes, + ] as AppNode[]) + : graph.nodes; + return stratify() .id((d) => d.id) .parentId((d) => { - const edgesPointingToNode = graph.edges.filter((e) => { - return e.target === d.id; - }); + if (hasMultipleRoots && rootIds.includes(d.id)) { + return 'synthetic-root'; + } + + const edgesPointingToNode = graph.edges.filter((e) => e.target === d.id); if (!priorityGivenToEdgesComingFromItemTypeIds) { return edgesPointingToNode[0]?.source; @@ -20,14 +41,14 @@ export function buildHierarchyNodes( return edgesPointingToNode[0]?.source; } - const proprityEdges = edgesPointingToNode.filter((e) => { - return priorityGivenToEdgesComingFromItemTypeIds.includes(e.source); - }); + const proprityEdges = edgesPointingToNode.filter((e) => + priorityGivenToEdgesComingFromItemTypeIds.includes(e.source), + ); - const regularEdges = edgesPointingToNode.filter((e) => { - return !priorityGivenToEdgesComingFromItemTypeIds.includes(e.source); - }); + const regularEdges = edgesPointingToNode.filter( + (e) => !priorityGivenToEdgesComingFromItemTypeIds.includes(e.source), + ); return [...proprityEdges, ...regularEdges][0]?.source; - })(graph.nodes); + })(nodesForStratify); } diff --git a/import-export-schema/src/utils/graph/dependencies.ts b/import-export-schema/src/utils/graph/dependencies.ts new file mode 100644 index 00000000..43180c62 --- /dev/null +++ b/import-export-schema/src/utils/graph/dependencies.ts @@ -0,0 +1,44 @@ +import { + findLinkedItemTypeIds, + findLinkedPluginIds, +} from '@/utils/datocms/schema'; +import type { Graph } from '@/utils/graph/types'; + +export function collectDependencies( + graph: Graph, + selectedItemTypeIds: string[], + installedPluginIds?: Set, +) { + const beforeItemTypeIds = new Set(selectedItemTypeIds); + const nextItemTypeIds = new Set(selectedItemTypeIds); + const nextPluginIds = new Set(); + + const queue = [...selectedItemTypeIds]; + while (queue.length > 0) { + const popped = queue.pop(); + if (!popped) break; + const id = popped; + const node = graph.nodes.find((n) => n.id === `itemType--${id}`); + const fields = node?.type === 'itemType' ? node.data.fields : []; + for (const field of fields) { + for (const linkedId of findLinkedItemTypeIds(field)) { + if (!nextItemTypeIds.has(linkedId)) { + nextItemTypeIds.add(linkedId); + queue.push(linkedId); + } + } + for (const pluginId of findLinkedPluginIds(field, installedPluginIds)) { + nextPluginIds.add(pluginId); + } + } + } + + const addedItemTypeIds = Array.from(nextItemTypeIds).filter( + (id) => !beforeItemTypeIds.has(id), + ); + return { + itemTypeIds: nextItemTypeIds, + pluginIds: nextPluginIds, + addedItemTypeIds, + }; +} diff --git a/import-export-schema/src/utils/graph/edges.ts b/import-export-schema/src/utils/graph/edges.ts new file mode 100644 index 00000000..d03102a2 --- /dev/null +++ b/import-export-schema/src/utils/graph/edges.ts @@ -0,0 +1,68 @@ +import type { SchemaTypes } from '@datocms/cma-client'; +import { MarkerType } from '@xyflow/react'; +import { find } from 'lodash-es'; +import { + findLinkedItemTypeIds, + findLinkedPluginIds, +} from '@/utils/datocms/schema'; +import type { AppEdge } from '@/utils/graph/types'; + +export function buildEdgesForItemType( + itemType: SchemaTypes.ItemType, + fields: SchemaTypes.Field[], + rootItemTypeIds: Set, + installedPluginIds: Set, +) { + const edges: AppEdge[] = []; + const linkedItemTypeIds = new Set(); + const linkedPluginIds = new Set(); + + for (const field of fields) { + for (const linkedItemTypeId of findLinkedItemTypeIds(field)) { + if (rootItemTypeIds.has(linkedItemTypeId)) continue; + + const id = `toItemType--${itemType.id}->${linkedItemTypeId}`; + linkedItemTypeIds.add(linkedItemTypeId); + const existing = find(edges, { id }); + if (existing) { + const data = existing.data ?? { fields: [] }; + data.fields.push(field); + existing.data = data; + } else { + edges.push({ + id, + source: `itemType--${itemType.id}`, + target: `itemType--${linkedItemTypeId}`, + type: 'field', + data: { fields: [field] }, + markerEnd: { type: MarkerType.ArrowClosed }, + }); + } + } + + for (const linkedPluginId of findLinkedPluginIds( + field, + installedPluginIds, + )) { + const id = `toPlugin--${itemType.id}->${linkedPluginId}`; + const existing = find(edges, { id }); + linkedPluginIds.add(linkedPluginId); + if (existing) { + const data = existing.data ?? { fields: [] }; + data.fields.push(field); + existing.data = data; + } else { + edges.push({ + id, + source: `itemType--${itemType.id}`, + target: `plugin--${linkedPluginId}`, + type: 'field', + data: { fields: [field] }, + markerEnd: { type: MarkerType.ArrowClosed }, + }); + } + } + } + + return [edges, linkedItemTypeIds, linkedPluginIds] as const; +} diff --git a/import-export-schema/src/utils/graph/nodes.ts b/import-export-schema/src/utils/graph/nodes.ts new file mode 100644 index 00000000..1e5169ce --- /dev/null +++ b/import-export-schema/src/utils/graph/nodes.ts @@ -0,0 +1,25 @@ +import type { SchemaTypes } from '@datocms/cma-client'; +import type { ItemTypeNode } from '@/components/ItemTypeNodeRenderer'; +import type { PluginNode } from '@/components/PluginNodeRenderer'; + +export function buildPluginNode(plugin: SchemaTypes.Plugin): PluginNode { + return { + id: `plugin--${plugin.id}`, + position: { x: 0, y: 0 }, + type: 'plugin', + data: { plugin }, + }; +} + +export function buildItemTypeNode( + itemType: SchemaTypes.ItemType, + fields: SchemaTypes.Field[], + fieldsets: SchemaTypes.Fieldset[], +): ItemTypeNode { + return { + id: `itemType--${itemType.id}`, + position: { x: 0, y: 0 }, + type: 'itemType', + data: { itemType, fields, fieldsets }, + }; +} diff --git a/import-export-schema/src/utils/graph/rebuildGraphWithPositionsFromHierarchy.ts b/import-export-schema/src/utils/graph/rebuildGraphWithPositionsFromHierarchy.ts index 0d1a3941..5bbe8091 100644 --- a/import-export-schema/src/utils/graph/rebuildGraphWithPositionsFromHierarchy.ts +++ b/import-export-schema/src/utils/graph/rebuildGraphWithPositionsFromHierarchy.ts @@ -12,21 +12,19 @@ export function rebuildGraphWithPositionsFromHierarchy( const root = layout(hierarchy); return { - nodes: root.descendants().map((hierarchyNode) => { - return { - ...hierarchyNode.data, - // This bit is super important! We *mutated* the object in the `forEach` - // above so the reference is the same. React needs to see a new reference - // to trigger a re-render of the node. - data: { ...hierarchyNode.data.data }, - // targetPosition : 'left', - // sourcePosition : 'right', - position: { - x: hierarchyNode.x!, - y: hierarchyNode.y!, - }, - } as AppNode; - }), + nodes: root + .descendants() + .filter((n) => n.data.id !== 'synthetic-root') + .map((hierarchyNode) => { + return { + ...hierarchyNode.data, + data: { ...hierarchyNode.data.data }, + position: { + x: hierarchyNode.x!, + y: hierarchyNode.y!, + }, + } as AppNode; + }), edges, }; } diff --git a/import-export-schema/src/utils/graph/sort.ts b/import-export-schema/src/utils/graph/sort.ts new file mode 100644 index 00000000..989e65c6 --- /dev/null +++ b/import-export-schema/src/utils/graph/sort.ts @@ -0,0 +1,17 @@ +import { sortBy } from 'lodash-es'; +import type { AppNode, Graph } from '@/utils/graph/types'; + +export function deterministicGraphSort(graph: Graph) { + return { + nodes: sortBy(graph.nodes, [ + 'type', + (n: AppNode) => + 'itemType' in n.data ? n.data.itemType.attributes.api_key : undefined, + (n: AppNode) => + 'itemType' in n.data + ? n.data.itemType.attributes.name + : n.data.plugin.attributes.name, + ]), + edges: graph.edges, + }; +} diff --git a/import-export-schema/src/utils/graph/types.ts b/import-export-schema/src/utils/graph/types.ts index a1972395..d4d1394f 100644 --- a/import-export-schema/src/utils/graph/types.ts +++ b/import-export-schema/src/utils/graph/types.ts @@ -1,10 +1,10 @@ +import type { EdgeTypes } from '@xyflow/react'; import { type FieldEdge, FieldEdgeRenderer, } from '@/components/FieldEdgeRenderer'; import type { ItemTypeNode } from '@/components/ItemTypeNodeRenderer'; import type { PluginNode } from '@/components/PluginNodeRenderer'; -import type { EdgeTypes } from '@xyflow/react'; export type AppNode = ItemTypeNode | PluginNode; diff --git a/import-export-schema/src/utils/ids.ts b/import-export-schema/src/utils/ids.ts new file mode 100644 index 00000000..8fdd9e74 --- /dev/null +++ b/import-export-schema/src/utils/ids.ts @@ -0,0 +1,13 @@ +export function asIdString(e: T): T { + return { ...e, id: String(e.id) } as T; +} + +export function mapById(items: T[]): Map { + return new Map(items.map((e) => [e.id, e])); +} + +export function requireMap(map: Map, key: K, ctx: string): V { + const value = map.get(key); + if (value === undefined) throw new Error(`Missing ${String(key)} in ${ctx}`); + return value; +} diff --git a/import-export-schema/src/utils/progress.ts b/import-export-schema/src/utils/progress.ts new file mode 100644 index 00000000..604be9a4 --- /dev/null +++ b/import-export-schema/src/utils/progress.ts @@ -0,0 +1,47 @@ +export type ProgressUpdate = { + total: number; + finished: number; + label?: string; +}; + +export function createProgressTracker( + initialTotal: number, + onUpdate: (update: ProgressUpdate) => void, + shouldCancel?: () => boolean, +) { + let finished = 0; + let lastLabel: string | undefined; + const checkCancel = () => { + if (shouldCancel?.()) throw new Error('Operation cancelled'); + }; + const emit = (label?: string) => { + lastLabel = label ?? lastLabel; + onUpdate({ total: initialTotal, finished, label: lastLabel }); + }; + + const wrap = ( + labelForArgs: (...args: TArgs) => string, + fn: (...args: TArgs) => Promise, + ) => { + return async (...args: TArgs) => { + try { + checkCancel(); + emit(labelForArgs(...args)); + const result = await fn(...args); + checkCancel(); + return result; + } finally { + finished += 1; + emit(); + } + }; + }; + + const tick = (label?: string) => { + finished += 1; + emit(label); + }; + + emit(); + return { wrap, tick, checkCancel } as const; +} diff --git a/import-export-schema/src/utils/schema/ExportSchemaSource.ts b/import-export-schema/src/utils/schema/ExportSchemaSource.ts new file mode 100644 index 00000000..c3ed703f --- /dev/null +++ b/import-export-schema/src/utils/schema/ExportSchemaSource.ts @@ -0,0 +1,32 @@ +import type { SchemaTypes } from '@datocms/cma-client'; +import type { ExportSchema } from '@/entrypoints/ExportPage/ExportSchema'; +import type { ISchemaSource } from './ISchemaSource'; + +export class ExportSchemaSource implements ISchemaSource { + private schema: ExportSchema; + + constructor(schema: ExportSchema) { + this.schema = schema; + } + + async getItemTypeById(id: string): Promise { + return this.schema.getItemTypeById(id); + } + + async getPluginById(id: string): Promise { + return this.schema.getPluginById(id); + } + + async getItemTypeFieldsAndFieldsets( + itemType: SchemaTypes.ItemType, + ): Promise<[SchemaTypes.Field[], SchemaTypes.Fieldset[]]> { + return [ + this.schema.getItemTypeFields(itemType), + this.schema.getItemTypeFieldsets(itemType), + ]; + } + + getKnownPluginIds(): Set { + return new Set(Array.from(this.schema.pluginsById.keys())); + } +} diff --git a/import-export-schema/src/utils/schema/ISchemaSource.ts b/import-export-schema/src/utils/schema/ISchemaSource.ts new file mode 100644 index 00000000..dec92c84 --- /dev/null +++ b/import-export-schema/src/utils/schema/ISchemaSource.ts @@ -0,0 +1,10 @@ +import type { SchemaTypes } from '@datocms/cma-client'; + +export interface ISchemaSource { + getItemTypeById(id: string): Promise; + getPluginById(id: string): Promise; + getItemTypeFieldsAndFieldsets( + itemType: SchemaTypes.ItemType, + ): Promise<[SchemaTypes.Field[], SchemaTypes.Fieldset[]]>; + getKnownPluginIds(): Promise> | Set; +} diff --git a/import-export-schema/src/utils/schema/ProjectSchemaSource.ts b/import-export-schema/src/utils/schema/ProjectSchemaSource.ts new file mode 100644 index 00000000..9b2efd88 --- /dev/null +++ b/import-export-schema/src/utils/schema/ProjectSchemaSource.ts @@ -0,0 +1,30 @@ +import type { SchemaTypes } from '@datocms/cma-client'; +import type { ProjectSchema } from '@/utils/ProjectSchema'; +import type { ISchemaSource } from './ISchemaSource'; + +export class ProjectSchemaSource implements ISchemaSource { + private schema: ProjectSchema; + + constructor(schema: ProjectSchema) { + this.schema = schema; + } + + async getItemTypeById(id: string): Promise { + return this.schema.getItemTypeById(id); + } + + async getPluginById(id: string): Promise { + return this.schema.getPluginById(id); + } + + async getItemTypeFieldsAndFieldsets( + itemType: SchemaTypes.ItemType, + ): Promise<[SchemaTypes.Field[], SchemaTypes.Fieldset[]]> { + return this.schema.getItemTypeFieldsAndFieldsets(itemType); + } + + async getKnownPluginIds(): Promise> { + const plugins = await this.schema.getAllPlugins(); + return new Set(plugins.map((p) => p.id)); + } +} diff --git a/import-export-schema/tsconfig.app.json b/import-export-schema/tsconfig.app.json index 653d8e41..8a63a4a5 100644 --- a/import-export-schema/tsconfig.app.json +++ b/import-export-schema/tsconfig.app.json @@ -20,7 +20,7 @@ "noFallthroughCasesInSwitch": true, "paths": { - "@/*": ["./src/*"], + "@/*": ["./src/*"] } }, "include": ["src"] diff --git a/import-export-schema/tsconfig.app.tsbuildinfo b/import-export-schema/tsconfig.app.tsbuildinfo index 32433671..beda6918 100644 --- a/import-export-schema/tsconfig.app.tsbuildinfo +++ b/import-export-schema/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/field.tsx","./src/components/fieldedgerenderer.tsx","./src/components/itemtypenoderenderer.tsx","./src/components/pluginnoderenderer.tsx","./src/components/bezier.ts","./src/entrypoints/config/index.tsx","./src/entrypoints/exportpage/entitiestoexportcontext.ts","./src/entrypoints/exportpage/exportitemtypenoderenderer.tsx","./src/entrypoints/exportpage/exportpluginnoderenderer.tsx","./src/entrypoints/exportpage/exportschema.ts","./src/entrypoints/exportpage/inner.tsx","./src/entrypoints/exportpage/buildexportdoc.ts","./src/entrypoints/exportpage/buildgraphfromschema.ts","./src/entrypoints/exportpage/index.tsx","./src/entrypoints/exportpage/useanimatednodes.tsx","./src/entrypoints/importpage/filedropzone.tsx","./src/entrypoints/importpage/importitemtypenoderenderer.tsx","./src/entrypoints/importpage/importpluginnoderenderer.tsx","./src/entrypoints/importpage/inner.tsx","./src/entrypoints/importpage/resolutionsform.tsx","./src/entrypoints/importpage/selectedentitycontext.tsx","./src/entrypoints/importpage/buildgraphfromexportdoc.ts","./src/entrypoints/importpage/buildimportdoc.ts","./src/entrypoints/importpage/importschema.ts","./src/entrypoints/importpage/index.tsx","./src/entrypoints/importpage/conflictsmanager/collapsible.tsx","./src/entrypoints/importpage/conflictsmanager/conflictscontext.ts","./src/entrypoints/importpage/conflictsmanager/itemtypeconflict.tsx","./src/entrypoints/importpage/conflictsmanager/pluginconflict.tsx","./src/entrypoints/importpage/conflictsmanager/buildconflicts.ts","./src/entrypoints/importpage/conflictsmanager/index.tsx","./src/utils/projectschema.ts","./src/utils/downloadjson.ts","./src/utils/emojiagnosticsorter.ts","./src/utils/fieldtypegroups.ts","./src/utils/icons.tsx","./src/utils/isdefined.ts","./src/utils/render.tsx","./src/utils/types.ts","./src/utils/datocms/fieldtypeinfo.ts","./src/utils/datocms/schema.ts","./src/utils/graph/buildhierarchynodes.ts","./src/utils/graph/rebuildgraphwithpositionsfromhierarchy.ts","./src/utils/graph/types.ts"],"version":"5.7.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/field.tsx","./src/components/fieldedgerenderer.tsx","./src/components/fieldsandfieldsetssummary.tsx","./src/components/graphcanvas.tsx","./src/components/itemtypenoderenderer.tsx","./src/components/pluginnoderenderer.tsx","./src/components/progressstallnotice.tsx","./src/components/bezier.ts","./src/entrypoints/config/index.tsx","./src/entrypoints/exporthome/index.tsx","./src/entrypoints/exportpage/entitiestoexportcontext.ts","./src/entrypoints/exportpage/exportitemtypenoderenderer.tsx","./src/entrypoints/exportpage/exportpluginnoderenderer.tsx","./src/entrypoints/exportpage/exportschema.ts","./src/entrypoints/exportpage/inner.tsx","./src/entrypoints/exportpage/largeselectionview.tsx","./src/entrypoints/exportpage/postexportsummary.tsx","./src/entrypoints/exportpage/buildexportdoc.ts","./src/entrypoints/exportpage/buildgraphfromschema.ts","./src/entrypoints/exportpage/index.tsx","./src/entrypoints/exportpage/useanimatednodes.tsx","./src/entrypoints/importpage/filedropzone.tsx","./src/entrypoints/importpage/importitemtypenoderenderer.tsx","./src/entrypoints/importpage/importpluginnoderenderer.tsx","./src/entrypoints/importpage/inner.tsx","./src/entrypoints/importpage/largeselectionview.tsx","./src/entrypoints/importpage/postimportsummary.tsx","./src/entrypoints/importpage/resolutionsform.tsx","./src/entrypoints/importpage/selectedentitycontext.tsx","./src/entrypoints/importpage/buildgraphfromexportdoc.ts","./src/entrypoints/importpage/buildimportdoc.ts","./src/entrypoints/importpage/importschema.ts","./src/entrypoints/importpage/index.tsx","./src/entrypoints/importpage/conflictsmanager/collapsible.tsx","./src/entrypoints/importpage/conflictsmanager/conflictscontext.ts","./src/entrypoints/importpage/conflictsmanager/itemtypeconflict.tsx","./src/entrypoints/importpage/conflictsmanager/pluginconflict.tsx","./src/entrypoints/importpage/conflictsmanager/buildconflicts.ts","./src/entrypoints/importpage/conflictsmanager/index.tsx","./src/types/lodash-es.d.ts","./src/utils/projectschema.ts","./src/utils/createcmaclient.ts","./src/utils/debug.ts","./src/utils/downloadjson.ts","./src/utils/emojiagnosticsorter.ts","./src/utils/icons.tsx","./src/utils/ids.ts","./src/utils/isdefined.ts","./src/utils/progress.ts","./src/utils/render.tsx","./src/utils/types.ts","./src/utils/datocms/appearance.ts","./src/utils/datocms/fieldtypeinfo.ts","./src/utils/datocms/schema.ts","./src/utils/datocms/validators.ts","./src/utils/exportdoc/normalize.ts","./src/utils/graph/analysis.ts","./src/utils/graph/buildgraph.ts","./src/utils/graph/buildhierarchynodes.ts","./src/utils/graph/dependencies.ts","./src/utils/graph/edges.ts","./src/utils/graph/nodes.ts","./src/utils/graph/rebuildgraphwithpositionsfromhierarchy.ts","./src/utils/graph/sort.ts","./src/utils/graph/types.ts","./src/utils/schema/exportschemasource.ts","./src/utils/schema/ischemasource.ts","./src/utils/schema/projectschemasource.ts"],"version":"5.7.3"} \ No newline at end of file diff --git a/import-export-schema/tsconfig.node.json b/import-export-schema/tsconfig.node.json index a68ef43a..24ed5371 100644 --- a/import-export-schema/tsconfig.node.json +++ b/import-export-schema/tsconfig.node.json @@ -19,7 +19,7 @@ "noFallthroughCasesInSwitch": true, "paths": { - "@/*": ["./src/*"], + "@/*": ["./src/*"] } }, "include": ["vite.config.ts"] diff --git a/import-export-schema/vite.config.ts b/import-export-schema/vite.config.ts index 83b8b8f1..2e51e27f 100644 --- a/import-export-schema/vite.config.ts +++ b/import-export-schema/vite.config.ts @@ -1,12 +1,16 @@ +import { fileURLToPath, URL } from 'node:url'; import react from '@vitejs/plugin-react'; -import { URL, fileURLToPath } from 'url'; import { defineConfig } from 'vite'; import svgr from 'vite-plugin-svgr'; // https://vitejs.dev/config/ export default defineConfig({ base: './', - plugins: [react(), svgr()], + plugins: [ + react(), + // SVGR for SVG imports + svgr({ svgrOptions: {} }), + ], resolve: { alias: [ { @@ -15,4 +19,28 @@ export default defineConfig({ }, ], }, + build: { + sourcemap: false, + cssCodeSplit: true, + chunkSizeWarningLimit: 1024, + rollupOptions: { + output: { + manualChunks: { + 'vendor-react': ['react', 'react-dom'], + 'vendor-datocms': ['datocms-plugin-sdk', 'datocms-react-ui'], + 'vendor-xyflow': ['@xyflow/react', 'd3-hierarchy', 'd3-timer'], + 'vendor-icons': [ + '@fortawesome/react-fontawesome', + '@fortawesome/fontawesome-svg-core', + '@fortawesome/free-solid-svg-icons', + ], + 'vendor-lodash': ['lodash-es'], + }, + }, + }, + }, + // Drop consoles/debuggers in production bundles + esbuild: { + drop: ['console', 'debugger'], + }, }); From 02612fa44368caca34673a92e4ea09007c52dcb0 Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Thu, 14 Aug 2025 16:07:10 +0200 Subject: [PATCH 02/36] removed temp files --- .../components/FieldsAndFieldsetsSummary.tsx | 115 ------------------ import-export-schema/src/utils/debug.ts | 17 --- .../src/utils/exportDoc/normalize.ts | 57 --------- import-export-schema/src/utils/ids.ts | 13 -- import-export-schema/src/utils/progress.ts | 47 ------- import-export-schema/tsconfig.app.tsbuildinfo | 2 +- 6 files changed, 1 insertion(+), 250 deletions(-) delete mode 100644 import-export-schema/src/components/FieldsAndFieldsetsSummary.tsx delete mode 100644 import-export-schema/src/utils/debug.ts delete mode 100644 import-export-schema/src/utils/exportDoc/normalize.ts delete mode 100644 import-export-schema/src/utils/ids.ts delete mode 100644 import-export-schema/src/utils/progress.ts diff --git a/import-export-schema/src/components/FieldsAndFieldsetsSummary.tsx b/import-export-schema/src/components/FieldsAndFieldsetsSummary.tsx deleted file mode 100644 index 93ce19d4..00000000 --- a/import-export-schema/src/components/FieldsAndFieldsetsSummary.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import type { SchemaTypes } from '@datocms/cma-client'; -import { Button } from 'datocms-react-ui'; -import { useState } from 'react'; - -type Props = { - fields: SchemaTypes.Field[]; - fieldsets: SchemaTypes.Fieldset[]; - initialFields?: number; - initialFieldsets?: number; -}; - -export default function FieldsAndFieldsetsSummary({ - fields, - fieldsets, - initialFields = 10, - initialFieldsets = 6, -}: Props) { - const [fieldLimit, setFieldLimit] = useState(initialFields); - const [fieldsetLimit, setFieldsetLimit] = useState(initialFieldsets); - - return ( -
    -
    - {fields.length} fields • {fieldsets.length} fieldsets -
    - {fields.length > 0 && ( -
    -
    Fields
    -
      - {fields.slice(0, fieldLimit).map((f) => { - const label = f.attributes.label || f.attributes.api_key; - return ( -
    • - - {label}{' '} - - ({f.attributes.api_key}) - - -
    • - ); - })} -
    - {fields.length > initialFields && ( -
    - -
    - )} -
    - )} - - {fieldsets.length > 0 && ( -
    -
    Fieldsets
    -
      - {fieldsets.slice(0, fieldsetLimit).map((fs) => ( -
    • - - {fs.attributes.title} - -
    • - ))} -
    - {fieldsets.length > initialFieldsets && ( -
    - -
    - )} -
    - )} -
    - ); -} diff --git a/import-export-schema/src/utils/debug.ts b/import-export-schema/src/utils/debug.ts deleted file mode 100644 index 970fe4ba..00000000 --- a/import-export-schema/src/utils/debug.ts +++ /dev/null @@ -1,17 +0,0 @@ -export function isDebug(flag = 'schemaDebug'): boolean { - try { - return ( - typeof window !== 'undefined' && - window.localStorage?.getItem(flag) === '1' - ); - } catch { - return false; - } -} - -export function debugLog(flag = 'schemaDebug', ...args: unknown[]) { - if (isDebug(flag)) { - // eslint-disable-next-line no-console - console.log(...args); - } -} diff --git a/import-export-schema/src/utils/exportDoc/normalize.ts b/import-export-schema/src/utils/exportDoc/normalize.ts deleted file mode 100644 index 91a1ea08..00000000 --- a/import-export-schema/src/utils/exportDoc/normalize.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { SchemaTypes } from '@datocms/cma-client'; -import type { ExportDoc, ExportDocV2 } from '@/utils/types'; - -function asStringId(e: T): T { - return { ...e, id: String(e.id) } as T; -} - -/** Upcast older export docs to V2 and normalize all IDs to strings. */ -export function normalizeExportDoc(doc: ExportDoc): ExportDocV2 { - const entities = ( - doc.entities as Array< - | SchemaTypes.ItemType - | SchemaTypes.Field - | SchemaTypes.Fieldset - | SchemaTypes.Plugin - > - ).map((e) => asStringId(e)); - - if (doc.version === '2') { - return { ...doc, entities, rootItemTypeId: String(doc.rootItemTypeId) }; - } - - // For V1 we cannot know the root with certainty; pick the first model with no inbound links - const itemTypes = entities.filter( - (e) => e.type === 'item_type', - ) as SchemaTypes.ItemType[]; - const fields = entities.filter( - (e) => e.type === 'field', - ) as SchemaTypes.Field[]; - - const linkTargets = new Set(); - for (const f of fields) { - const itemTypeId = String(f.relationships.item_type.data.id); - // Best-effort: add validators containing item type IDs - const validators = (f.attributes.validators ?? {}) as Record< - string, - unknown - >; - const maybeArrays = Object.values(validators).filter((v) => - Array.isArray(v), - ) as string[][]; - for (const arr of maybeArrays) { - for (const id of arr) { - linkTargets.add(String(id)); - } - } - linkTargets.add(itemTypeId); - } - - const root = - itemTypes.find((it) => !linkTargets.has(String(it.id))) || itemTypes[0]; - return { - version: '2', - rootItemTypeId: String(root?.id ?? ''), - entities, - }; -} diff --git a/import-export-schema/src/utils/ids.ts b/import-export-schema/src/utils/ids.ts deleted file mode 100644 index 8fdd9e74..00000000 --- a/import-export-schema/src/utils/ids.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function asIdString(e: T): T { - return { ...e, id: String(e.id) } as T; -} - -export function mapById(items: T[]): Map { - return new Map(items.map((e) => [e.id, e])); -} - -export function requireMap(map: Map, key: K, ctx: string): V { - const value = map.get(key); - if (value === undefined) throw new Error(`Missing ${String(key)} in ${ctx}`); - return value; -} diff --git a/import-export-schema/src/utils/progress.ts b/import-export-schema/src/utils/progress.ts deleted file mode 100644 index 604be9a4..00000000 --- a/import-export-schema/src/utils/progress.ts +++ /dev/null @@ -1,47 +0,0 @@ -export type ProgressUpdate = { - total: number; - finished: number; - label?: string; -}; - -export function createProgressTracker( - initialTotal: number, - onUpdate: (update: ProgressUpdate) => void, - shouldCancel?: () => boolean, -) { - let finished = 0; - let lastLabel: string | undefined; - const checkCancel = () => { - if (shouldCancel?.()) throw new Error('Operation cancelled'); - }; - const emit = (label?: string) => { - lastLabel = label ?? lastLabel; - onUpdate({ total: initialTotal, finished, label: lastLabel }); - }; - - const wrap = ( - labelForArgs: (...args: TArgs) => string, - fn: (...args: TArgs) => Promise, - ) => { - return async (...args: TArgs) => { - try { - checkCancel(); - emit(labelForArgs(...args)); - const result = await fn(...args); - checkCancel(); - return result; - } finally { - finished += 1; - emit(); - } - }; - }; - - const tick = (label?: string) => { - finished += 1; - emit(label); - }; - - emit(); - return { wrap, tick, checkCancel } as const; -} diff --git a/import-export-schema/tsconfig.app.tsbuildinfo b/import-export-schema/tsconfig.app.tsbuildinfo index beda6918..e6a371a9 100644 --- a/import-export-schema/tsconfig.app.tsbuildinfo +++ b/import-export-schema/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/field.tsx","./src/components/fieldedgerenderer.tsx","./src/components/fieldsandfieldsetssummary.tsx","./src/components/graphcanvas.tsx","./src/components/itemtypenoderenderer.tsx","./src/components/pluginnoderenderer.tsx","./src/components/progressstallnotice.tsx","./src/components/bezier.ts","./src/entrypoints/config/index.tsx","./src/entrypoints/exporthome/index.tsx","./src/entrypoints/exportpage/entitiestoexportcontext.ts","./src/entrypoints/exportpage/exportitemtypenoderenderer.tsx","./src/entrypoints/exportpage/exportpluginnoderenderer.tsx","./src/entrypoints/exportpage/exportschema.ts","./src/entrypoints/exportpage/inner.tsx","./src/entrypoints/exportpage/largeselectionview.tsx","./src/entrypoints/exportpage/postexportsummary.tsx","./src/entrypoints/exportpage/buildexportdoc.ts","./src/entrypoints/exportpage/buildgraphfromschema.ts","./src/entrypoints/exportpage/index.tsx","./src/entrypoints/exportpage/useanimatednodes.tsx","./src/entrypoints/importpage/filedropzone.tsx","./src/entrypoints/importpage/importitemtypenoderenderer.tsx","./src/entrypoints/importpage/importpluginnoderenderer.tsx","./src/entrypoints/importpage/inner.tsx","./src/entrypoints/importpage/largeselectionview.tsx","./src/entrypoints/importpage/postimportsummary.tsx","./src/entrypoints/importpage/resolutionsform.tsx","./src/entrypoints/importpage/selectedentitycontext.tsx","./src/entrypoints/importpage/buildgraphfromexportdoc.ts","./src/entrypoints/importpage/buildimportdoc.ts","./src/entrypoints/importpage/importschema.ts","./src/entrypoints/importpage/index.tsx","./src/entrypoints/importpage/conflictsmanager/collapsible.tsx","./src/entrypoints/importpage/conflictsmanager/conflictscontext.ts","./src/entrypoints/importpage/conflictsmanager/itemtypeconflict.tsx","./src/entrypoints/importpage/conflictsmanager/pluginconflict.tsx","./src/entrypoints/importpage/conflictsmanager/buildconflicts.ts","./src/entrypoints/importpage/conflictsmanager/index.tsx","./src/types/lodash-es.d.ts","./src/utils/projectschema.ts","./src/utils/createcmaclient.ts","./src/utils/debug.ts","./src/utils/downloadjson.ts","./src/utils/emojiagnosticsorter.ts","./src/utils/icons.tsx","./src/utils/ids.ts","./src/utils/isdefined.ts","./src/utils/progress.ts","./src/utils/render.tsx","./src/utils/types.ts","./src/utils/datocms/appearance.ts","./src/utils/datocms/fieldtypeinfo.ts","./src/utils/datocms/schema.ts","./src/utils/datocms/validators.ts","./src/utils/exportdoc/normalize.ts","./src/utils/graph/analysis.ts","./src/utils/graph/buildgraph.ts","./src/utils/graph/buildhierarchynodes.ts","./src/utils/graph/dependencies.ts","./src/utils/graph/edges.ts","./src/utils/graph/nodes.ts","./src/utils/graph/rebuildgraphwithpositionsfromhierarchy.ts","./src/utils/graph/sort.ts","./src/utils/graph/types.ts","./src/utils/schema/exportschemasource.ts","./src/utils/schema/ischemasource.ts","./src/utils/schema/projectschemasource.ts"],"version":"5.7.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/field.tsx","./src/components/fieldedgerenderer.tsx","./src/components/graphcanvas.tsx","./src/components/itemtypenoderenderer.tsx","./src/components/pluginnoderenderer.tsx","./src/components/progressstallnotice.tsx","./src/components/bezier.ts","./src/entrypoints/config/index.tsx","./src/entrypoints/exporthome/index.tsx","./src/entrypoints/exportpage/entitiestoexportcontext.ts","./src/entrypoints/exportpage/exportitemtypenoderenderer.tsx","./src/entrypoints/exportpage/exportpluginnoderenderer.tsx","./src/entrypoints/exportpage/exportschema.ts","./src/entrypoints/exportpage/inner.tsx","./src/entrypoints/exportpage/largeselectionview.tsx","./src/entrypoints/exportpage/postexportsummary.tsx","./src/entrypoints/exportpage/buildexportdoc.ts","./src/entrypoints/exportpage/buildgraphfromschema.ts","./src/entrypoints/exportpage/index.tsx","./src/entrypoints/exportpage/useanimatednodes.tsx","./src/entrypoints/importpage/filedropzone.tsx","./src/entrypoints/importpage/importitemtypenoderenderer.tsx","./src/entrypoints/importpage/importpluginnoderenderer.tsx","./src/entrypoints/importpage/inner.tsx","./src/entrypoints/importpage/largeselectionview.tsx","./src/entrypoints/importpage/postimportsummary.tsx","./src/entrypoints/importpage/resolutionsform.tsx","./src/entrypoints/importpage/selectedentitycontext.tsx","./src/entrypoints/importpage/buildgraphfromexportdoc.ts","./src/entrypoints/importpage/buildimportdoc.ts","./src/entrypoints/importpage/importschema.ts","./src/entrypoints/importpage/index.tsx","./src/entrypoints/importpage/conflictsmanager/collapsible.tsx","./src/entrypoints/importpage/conflictsmanager/conflictscontext.ts","./src/entrypoints/importpage/conflictsmanager/itemtypeconflict.tsx","./src/entrypoints/importpage/conflictsmanager/pluginconflict.tsx","./src/entrypoints/importpage/conflictsmanager/buildconflicts.ts","./src/entrypoints/importpage/conflictsmanager/index.tsx","./src/types/lodash-es.d.ts","./src/utils/projectschema.ts","./src/utils/createcmaclient.ts","./src/utils/downloadjson.ts","./src/utils/emojiagnosticsorter.ts","./src/utils/icons.tsx","./src/utils/isdefined.ts","./src/utils/render.tsx","./src/utils/types.ts","./src/utils/datocms/appearance.ts","./src/utils/datocms/fieldtypeinfo.ts","./src/utils/datocms/schema.ts","./src/utils/datocms/validators.ts","./src/utils/graph/analysis.ts","./src/utils/graph/buildgraph.ts","./src/utils/graph/buildhierarchynodes.ts","./src/utils/graph/dependencies.ts","./src/utils/graph/edges.ts","./src/utils/graph/nodes.ts","./src/utils/graph/rebuildgraphwithpositionsfromhierarchy.ts","./src/utils/graph/sort.ts","./src/utils/graph/types.ts","./src/utils/schema/exportschemasource.ts","./src/utils/schema/ischemasource.ts","./src/utils/schema/projectschemasource.ts"],"version":"5.7.3"} \ No newline at end of file From df2205ac8254d13b96b9520175e45b2951c96d8d Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Mon, 15 Sep 2025 21:24:33 +0200 Subject: [PATCH 03/36] removed overviews, better global substitution rules --- .../src/entrypoints/ExportHome/index.tsx | 47 +- .../ExportPage/PostExportSummary.tsx | 703 ------------- .../src/entrypoints/ExportPage/index.tsx | 46 +- .../ConflictsManager/ItemTypeConflict.tsx | 140 ++- .../ConflictsManager/PluginConflict.tsx | 64 +- .../ImportPage/ConflictsManager/index.tsx | 544 +++++----- .../ImportPage/PostImportSummary.tsx | 995 ------------------ .../ImportPage/ResolutionsForm.tsx | 10 +- .../src/entrypoints/ImportPage/index.tsx | 291 +---- import-export-schema/src/index.css | 214 ++-- import-export-schema/tsconfig.app.tsbuildinfo | 2 +- 11 files changed, 601 insertions(+), 2455 deletions(-) delete mode 100644 import-export-schema/src/entrypoints/ExportPage/PostExportSummary.tsx delete mode 100644 import-export-schema/src/entrypoints/ImportPage/PostImportSummary.tsx diff --git a/import-export-schema/src/entrypoints/ExportHome/index.tsx b/import-export-schema/src/entrypoints/ExportHome/index.tsx index 022b97a7..73d553e5 100644 --- a/import-export-schema/src/entrypoints/ExportHome/index.tsx +++ b/import-export-schema/src/entrypoints/ExportHome/index.tsx @@ -8,10 +8,8 @@ import ProgressStallNotice from '@/components/ProgressStallNotice'; import { createCmaClient } from '@/utils/createCmaClient'; import { downloadJSON } from '@/utils/downloadJson'; import { ProjectSchema } from '@/utils/ProjectSchema'; -import type { ExportDoc } from '@/utils/types'; import buildExportDoc from '../ExportPage/buildExportDoc'; import ExportInner from '../ExportPage/Inner'; -import PostExportSummary from '../ExportPage/PostExportSummary'; type Props = { ctx: RenderPageCtx; @@ -26,22 +24,7 @@ export default function ExportHome({ ctx }: Props) { const projectSchema = useMemo(() => new ProjectSchema(client), [client]); - const [adminDomain, setAdminDomain] = useState(); - useEffect(() => { - let active = true; - (async () => { - try { - const site = await client.site.find(); - const domain = site.internal_domain || site.domain || undefined; - if (active) setAdminDomain(domain); - } catch { - // ignore; links will simply not be shown - } - })(); - return () => { - active = false; - }; - }, [client]); + // adminDomain and post-export overview removed; we download and toast only const [allItemTypes, setAllItemTypes] = useState< SchemaTypes.ItemType[] | undefined @@ -77,9 +60,6 @@ export default function ExportHome({ ctx }: Props) { }, [exportInitialItemTypeIds.join('-'), projectSchema]); const [exportStarted, setExportStarted] = useState(false); - const [postExportDoc, setPostExportDoc] = useState( - undefined, - ); const [exportAllBusy, setExportAllBusy] = useState(false); const [exportAllProgress, setExportAllProgress] = useState< @@ -109,24 +89,7 @@ export default function ExportHome({ ctx }: Props) {
    - {postExportDoc ? ( - - downloadJSON(postExportDoc, { - fileName: 'export.json', - prettify: true, - }) - } - onClose={() => { - setPostExportDoc(undefined); - setExportStarted(false); - setExportInitialItemTypeIds([]); - setExportInitialItemTypes([]); - }} - /> - ) : !exportStarted ? ( + {!exportStarted ? (
    Start a new export @@ -302,8 +265,7 @@ export default function ExportHome({ ctx }: Props) { fileName: 'export.json', prettify: true, }); - setPostExportDoc(exportDoc); - ctx.notice('Export completed with success!'); + ctx.notice('Export completed successfully.'); } catch (e) { console.error('Export-all failed', e); if ( @@ -395,8 +357,7 @@ export default function ExportHome({ ctx }: Props) { fileName: 'export.json', prettify: true, }); - setPostExportDoc(exportDoc); - ctx.notice('Export completed with success!'); + ctx.notice('Export completed successfully.'); } catch (e) { console.error('Selection export failed', e); if ( diff --git a/import-export-schema/src/entrypoints/ExportPage/PostExportSummary.tsx b/import-export-schema/src/entrypoints/ExportPage/PostExportSummary.tsx deleted file mode 100644 index a298e66a..00000000 --- a/import-export-schema/src/entrypoints/ExportPage/PostExportSummary.tsx +++ /dev/null @@ -1,703 +0,0 @@ -import type { SchemaTypes } from '@datocms/cma-client'; -import { Button, TextField } from 'datocms-react-ui'; -import { useId, useMemo, useState } from 'react'; -import { - findLinkedItemTypeIds, - findLinkedPluginIds, -} from '@/utils/datocms/schema'; -import type { ExportDoc } from '@/utils/types'; -import { ExportSchema } from './ExportSchema'; - -// Removed combined FieldsAndFieldsetsSummary in favor of separate panels - -type Props = { - exportDoc: ExportDoc; - adminDomain?: string; - onClose: () => void; - onDownload: () => void; -}; - -export default function PostExportSummary({ - exportDoc, - adminDomain, - onClose, - onDownload, -}: Props) { - const searchId = useId(); - const exportSchema = useMemo(() => new ExportSchema(exportDoc), [exportDoc]); - // Derive admin origin from provided domain or referrer as a robust fallback - const adminOrigin = useMemo( - () => (adminDomain ? `https://${adminDomain}` : undefined), - [adminDomain], - ); - console.log('[PostExportSummary] adminOrigin:', adminOrigin); - - const stats = useMemo(() => { - const models = exportSchema.itemTypes.filter( - (it) => !it.attributes.modular_block, - ); - const blocks = exportSchema.itemTypes.filter( - (it) => it.attributes.modular_block, - ); - const fields = exportSchema.fields; - const fieldsets = exportSchema.fieldsets; - const plugins = exportSchema.plugins; - return { models, blocks, fields, fieldsets, plugins }; - }, [exportSchema]); - - const connections = useMemo( - () => buildConnections(exportSchema), - [exportSchema], - ); - - const connectionsById = useMemo(() => { - const map = new Map< - string, - { - linkedItemTypes: Array<{ - target: SchemaTypes.ItemType; - fields: SchemaTypes.Field[]; - }>; - linkedPlugins: Array<{ - plugin: SchemaTypes.Plugin; - fields: SchemaTypes.Field[]; - }>; - } - >(); - for (const c of connections) { - map.set(c.itemType.id, { - linkedItemTypes: c.linkedItemTypes, - linkedPlugins: c.linkedPlugins, - }); - } - return map; - }, [connections]); - - const [contentQuery, setContentQuery] = useState(''); - const chipStyle = { - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - width: '72px', - height: '18px', - fontSize: '10px', - padding: '0 4px', - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - } as const; - - const allFields = exportSchema.fields; - const allFieldsets = exportSchema.fieldsets; - - const filteredModels = useMemo(() => { - const q = contentQuery.trim().toLowerCase(); - if (!q) return stats.models; - return stats.models.filter( - (it) => - it.attributes.name.toLowerCase().includes(q) || - it.attributes.api_key.toLowerCase().includes(q), - ); - }, [stats.models, contentQuery]); - - const filteredBlocks = useMemo(() => { - const q = contentQuery.trim().toLowerCase(); - if (!q) return stats.blocks; - return stats.blocks.filter( - (it) => - it.attributes.name.toLowerCase().includes(q) || - it.attributes.api_key.toLowerCase().includes(q), - ); - }, [stats.blocks, contentQuery]); - - const filteredPlugins = useMemo(() => { - const q = contentQuery.trim().toLowerCase(); - if (!q) return stats.plugins; - return stats.plugins.filter((pl) => - pl.attributes.name.toLowerCase().includes(q), - ); - }, [stats.plugins, contentQuery]); - - const filteredFields = useMemo(() => { - const q = contentQuery.trim().toLowerCase(); - if (!q) return allFields; - return allFields.filter((f) => { - const label = (f.attributes.label || '').toLowerCase(); - const apiKey = f.attributes.api_key.toLowerCase(); - return label.includes(q) || apiKey.includes(q); - }); - }, [allFields, contentQuery]); - - const filteredFieldsets = useMemo(() => { - const q = contentQuery.trim().toLowerCase(); - if (!q) return allFieldsets; - return allFieldsets.filter((fs) => - (fs.attributes.title || '').toLowerCase().includes(q), - ); - }, [allFieldsets, contentQuery]); - - const fieldsetParentById = useMemo(() => { - const map = new Map(); - for (const it of exportSchema.itemTypes) { - for (const fs of exportSchema.getItemTypeFieldsets(it)) { - map.set(String(fs.id), it); - } - } - return map; - }, [exportSchema]); - type SectionKey = 'models' | 'blocks' | 'plugins' | 'fields' | 'fieldsets'; - const [activeSection, setActiveSection] = useState('models'); - const sections: Array<{ - key: SectionKey; - label: string; - count: number; - }> = [ - { key: 'models', label: 'Models', count: stats.models.length }, - { key: 'blocks', label: 'Blocks', count: stats.blocks.length }, - { key: 'plugins', label: 'Plugins', count: stats.plugins.length }, - { key: 'fields', label: 'Fields', count: allFields.length }, - { key: 'fieldsets', label: 'Fieldsets', count: allFieldsets.length }, - ]; - - return ( -
    -
    -
    -
    -
    - {sections.map((s) => ( - - ))} -
    - - -
    -
    -
    - {activeSection === 'models' && ( - <> -
    - Models ({stats.models.length}) -
    -
    - setContentQuery(val)} - textInputProps={{ autoComplete: 'off' }} - /> -
    - ( -
  • - {adminOrigin ? ( - - - {it.attributes.name}{' '} - - {it.attributes.api_key} - - - - - {connectionsById.get(it.id)?.linkedItemTypes - .length ?? 0}{' '} - links - - - {connectionsById.get(it.id)?.linkedPlugins - .length ?? 0}{' '} - plugins - - - - ) : ( - <> - {it.attributes.name}{' '} - - {it.attributes.api_key} - - - - {connectionsById.get(it.id)?.linkedItemTypes - .length ?? 0}{' '} - links - - - {connectionsById.get(it.id)?.linkedPlugins - .length ?? 0}{' '} - plugins - - - - )} -
  • - )} - /> - - )} - {activeSection === 'blocks' && ( - <> -
    - Blocks ({stats.blocks.length}) -
    -
    - setContentQuery(val)} - textInputProps={{ autoComplete: 'off' }} - /> -
    - ( -
  • - {adminOrigin ? ( - - - {it.attributes.name}{' '} - - {it.attributes.api_key} - - - - - {connectionsById.get(it.id)?.linkedItemTypes - .length ?? 0}{' '} - links - - - {connectionsById.get(it.id)?.linkedPlugins - .length ?? 0}{' '} - plugins - - - - ) : ( - <> - {it.attributes.name}{' '} - - {it.attributes.api_key} - - - - {connectionsById.get(it.id)?.linkedItemTypes - .length ?? 0}{' '} - links - - - {connectionsById.get(it.id)?.linkedPlugins - .length ?? 0}{' '} - plugins - - - - )} -
  • - )} - /> - - )} - {activeSection === 'plugins' && ( - <> -
    - Plugins ({stats.plugins.length}) -
    -
    - setContentQuery(val)} - textInputProps={{ autoComplete: 'off' }} - /> -
    -
      - {filteredPlugins.length > 0 ? ( - filteredPlugins.map((pl) => ( -
    • - {adminOrigin ? ( - - {pl.attributes.name} - - ) : ( - pl.attributes.name - )} -
    • - )) - ) : ( -
    • No plugins
    • - )} -
    - - )} - {activeSection === 'fields' && ( - <> -
    - Fields ({allFields.length}) -
    -
    - setContentQuery(val)} - textInputProps={{ autoComplete: 'off' }} - /> -
    - { - const label = f.attributes.label || f.attributes.api_key; - const parentId = String( - (f as SchemaTypes.Field).relationships.item_type.data - .id, - ); - const parent = exportSchema.itemTypesById.get(parentId); - const isBlockParent = - parent?.attributes.modular_block === true; - const basePath = isBlockParent - ? '/schema/blocks_library' - : '/schema/item_types'; - const fieldUrl = `${adminOrigin}${basePath}/${parentId}#f${f.id}`; - return ( -
  • - {adminOrigin ? ( - - {label}{' '} - - ({f.attributes.api_key}) - - - ) : ( - - {label}{' '} - - ({f.attributes.api_key}) - - - )} -
  • - ); - }} - /> - - )} - {activeSection === 'fieldsets' && ( - <> -
    - Fieldsets ({allFieldsets.length}) -
    -
    - setContentQuery(val)} - textInputProps={{ autoComplete: 'off' }} - /> -
    - { - const parent = fieldsetParentById.get(String(fs.id)); - const isBlockParent = - parent?.attributes.modular_block === true; - const basePath = isBlockParent - ? '/schema/blocks_library' - : '/schema/item_types'; - const href = - parent && adminOrigin - ? `${adminOrigin}${basePath}/${parent.id}` - : undefined; - return ( -
  • - {href ? ( - - {fs.attributes.title} - - ) : ( - - {fs.attributes.title} - - )} -
  • - ); - }} - /> - - )} -
    -
    -
    - {/* Removed the old connections section; counts are now shown inline in Models/Blocks */} -
    -
    - ); -} - -// Removed SectionTitle in favor of summary__title for cleaner layout - -function buildConnections(exportSchema: ExportSchema) { - const pluginIds = new Set(Array.from(exportSchema.pluginsById.keys())); - const out = [] as Array<{ - itemType: SchemaTypes.ItemType; - linkedItemTypes: Array<{ - target: SchemaTypes.ItemType; - fields: SchemaTypes.Field[]; - }>; - linkedPlugins: Array<{ - plugin: SchemaTypes.Plugin; - fields: SchemaTypes.Field[]; - }>; - }>; - - for (const it of exportSchema.itemTypes) { - const fields = exportSchema.getItemTypeFields(it); - const byItemType = new Map(); - const byPlugin = new Map(); - - for (const field of fields) { - for (const linkedId of findLinkedItemTypeIds(field)) { - const arr = byItemType.get(String(linkedId)) || []; - arr.push(field); - byItemType.set(String(linkedId), arr); - } - for (const pluginId of findLinkedPluginIds(field, pluginIds)) { - const arr = byPlugin.get(String(pluginId)) || []; - arr.push(field); - byPlugin.set(String(pluginId), arr); - } - } - - const linkedItemTypes = Array.from(byItemType.entries()).flatMap( - ([targetId, fields]) => { - const target = exportSchema.itemTypesById.get(String(targetId)); - return target ? [{ target, fields }] : []; - }, - ); - - const linkedPlugins = Array.from(byPlugin.entries()).flatMap( - ([pid, fields]) => { - const plugin = exportSchema.pluginsById.get(String(pid)); - return plugin ? [{ plugin, fields }] : []; - }, - ); - - out.push({ itemType: it, linkedItemTypes, linkedPlugins }); - } - - return out; -} - -// Collapsible removed in favor of the new two-pane layout - -function LimitedList({ - items, - renderItem, - initial = 20, -}: { - items: T[]; - renderItem: (item: T) => React.ReactNode; - initial?: number; -}) { - const [limit, setLimit] = useState(initial); - const showingAll = limit >= items.length; - const visible = items.slice(0, limit); - return ( - <> -
      - {visible.map((it) => renderItem(it))} -
    - {items.length > initial && ( -
    - -
    - )} - - ); -} diff --git a/import-export-schema/src/entrypoints/ExportPage/index.tsx b/import-export-schema/src/entrypoints/ExportPage/index.tsx index 8bb6d47e..ddcb6495 100644 --- a/import-export-schema/src/entrypoints/ExportPage/index.tsx +++ b/import-export-schema/src/entrypoints/ExportPage/index.tsx @@ -7,10 +7,8 @@ import ProgressStallNotice from '@/components/ProgressStallNotice'; import { createCmaClient } from '@/utils/createCmaClient'; import { downloadJSON } from '@/utils/downloadJson'; import { ProjectSchema } from '@/utils/ProjectSchema'; -import type { ExportDoc } from '@/utils/types'; import buildExportDoc from './buildExportDoc'; import Inner from './Inner'; -import PostExportSummary from './PostExportSummary'; type Props = { ctx: RenderPageCtx; @@ -53,23 +51,7 @@ export default function ExportPage({ ctx, initialItemTypeId }: Props) { const schema = useMemo(() => new ProjectSchema(client), [client]); - const [adminDomain, setAdminDomain] = useState(); - useEffect(() => { - let active = true; - (async () => { - try { - const site = await client.site.find(); - console.log('[ExportPage] site:', site); - const domain = site.internal_domain || site.domain || undefined; - if (active) setAdminDomain(domain); - } catch { - // ignore; links will simply not be shown - } - })(); - return () => { - active = false; - }; - }, [client]); + // Removed adminDomain lookup; we no longer show a post-export overview // Preload installed plugin IDs once to avoid network calls during selection const [installedPluginIds, setInstalledPluginIds] = useState< @@ -130,9 +112,6 @@ export default function ExportPage({ ctx, initialItemTypeId }: Props) { >(); const [exportCancelled, setExportCancelled] = useState(false); const exportCancelRef = useRef(false); - const [postExportDoc, setPostExportDoc] = useState( - undefined, - ); async function handleExport(itemTypeIds: string[], pluginIds: string[]) { try { @@ -165,8 +144,7 @@ export default function ExportPage({ ctx, initialItemTypeId }: Props) { } downloadJSON(exportDoc, { fileName: 'export.json', prettify: true }); - setPostExportDoc(exportDoc); - ctx.notice('Export completed with success!'); + ctx.notice('Export completed successfully.'); } catch (e) { console.error('Export failed', e); if (e instanceof Error && e.message === 'Export cancelled') { @@ -195,24 +173,7 @@ export default function ExportPage({ ctx, initialItemTypeId }: Props) { return ( - {postExportDoc ? ( - - downloadJSON(postExportDoc, { - fileName: 'export.json', - prettify: true, - }) - } - onClose={() => - ctx.navigateTo( - `${ctx.isEnvironmentPrimary ? '' : `/environments/${ctx.environment}`}/configuration/p/${ctx.plugin.id}/pages/export`, - ) - } - /> - ) : ( - - )} {preparingBusy && !suppressPreparingOverlay && (
    + Global rename rule + This {exportType} will be renamed automatically using the suffix{' '} + {nameSuffix} and API key suffix {apiKeySuffix}. +
    + ); + } else if (massStrategy === 'reuseExisting') { + if (matchesType) { + massSummary = ( +
    + Global reuse rule + This {exportType} will reuse the existing {projectType} in your + project. +
    + ); + } else { + massSummary = ( +
    + Global reuse rule + This {exportType} can’t be reused because it conflicts with a{' '} + {projectType} already in your project. A new copy will be created + using the suffix {nameSuffix} and API key suffix{' '} + {apiKeySuffix}. +
    + ); + } + } + return ( {' '} ({projectItemType.attributes.api_key}).

    - - {({ input, meta: { error } }) => ( - > - {...input} - id={selectId} - label="To resolve this conflict:" - selectInputProps={{ - options, - }} - value={options.find((ft) => input.value.includes(ft.value))} - onChange={(option) => input.onChange(option ? option.value : null)} - error={error} - /> - )} - - {resolution.values.strategy === 'rename' && ( + {massSummary ? ( + massSummary + ) : ( <> -
    - - {({ input, meta: { error } }) => ( - - )} - -
    -
    - - {({ input, meta: { error } }) => ( - - )} - -
    + + {({ input, meta: { error } }) => ( + > + {...input} + id={selectId} + label="To resolve this conflict:" + selectInputProps={{ + options, + }} + value={ + options.find((option) => input.value === option.value) ?? null + } + onChange={(option) => + input.onChange(option ? option.value : null) + } + placeholder="Select..." + error={error} + /> + )} + + {resolution.values.strategy === 'rename' && ( + <> +
    + + {({ input, meta: { error } }) => ( + + )} + +
    +
    + + {({ input, meta: { error } }) => ( + + )} + +
    + + )} )}
    diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx index 001609fc..a1295f7a 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx @@ -4,7 +4,10 @@ import { SelectField } from 'datocms-react-ui'; import { useId } from 'react'; import { Field } from 'react-final-form'; import type { GroupBase } from 'react-select'; -import { useResolutionStatusForPlugin } from '../ResolutionsForm'; +import { + useMassStrategies, + useResolutionStatusForPlugin, +} from '../ResolutionsForm'; import Collapsible from './Collapsible'; type Option = { label: string; value: string }; @@ -27,11 +30,31 @@ export function PluginConflict({ exportPlugin, projectPlugin }: Props) { const fieldPrefix = `plugin-${exportPlugin.id}`; const resolution = useResolutionStatusForPlugin(exportPlugin.id)!; const node = useReactFlow().getNode(`plugin--${exportPlugin.id}`); + const mass = useMassStrategies(); if (!node) { return null; } + const massStrategy = mass.pluginsStrategy ?? null; + let massSummary: JSX.Element | null = null; + + if (massStrategy === 'reuseExisting') { + massSummary = ( +
    + Global plugin rule + This plugin will reuse the version already installed in this project. +
    + ); + } else if (massStrategy === 'skip') { + massSummary = ( +
    + Global plugin rule + This plugin will be skipped during import as per the global setting. +
    + ); + } + return ( {projectPlugin.attributes.name}.

    - - {({ input, meta: { error } }) => ( - > - {...input} - id={selectId} - label="To resolve this conflict:" - selectInputProps={{ - options, - }} - value={options.find((ft) => input.value.includes(ft.value))} - onChange={(option) => input.onChange(option ? option.value : null)} - error={error} - /> - )} - + {massSummary ? ( + massSummary + ) : ( + + {({ input, meta: { error } }) => ( + > + {...input} + id={selectId} + label="To resolve this conflict:" + selectInputProps={{ + options, + }} + value={ + options.find((option) => input.value === option.value) ?? null + } + onChange={(option) => + input.onChange(option ? option.value : null) + } + placeholder="Select..." + error={error} + /> + )} + + )}
    ); } diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx index f3285bfd..428270b9 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx @@ -1,10 +1,14 @@ import type { SchemaTypes } from '@datocms/cma-client'; import type { RenderPageCtx } from 'datocms-plugin-sdk'; -import { Button, Spinner, TextField } from 'datocms-react-ui'; +import { Button, TextField } from 'datocms-react-ui'; import { defaults, groupBy, map, mapValues, sortBy } from 'lodash-es'; -import { useContext, useId, useMemo, useState } from 'react'; -import { flushSync } from 'react-dom'; -import { useForm, useFormState } from 'react-final-form'; +import { type ReactNode, useContext, useId, useMemo } from 'react'; +import { + type FieldMetaState, + Field, + useForm, + useFormState, +} from 'react-final-form'; import type { ExportSchema } from '@/entrypoints/ExportPage/ExportSchema'; import { getTextWithoutRepresentativeEmojiAndPadding } from '@/utils/emojiAgnosticSorter'; import type { ProjectSchema } from '@/utils/ProjectSchema'; @@ -19,89 +23,202 @@ type Props = { ctx?: RenderPageCtx; }; +function resolveFieldError(meta: FieldMetaState | undefined) { + if (!meta) { + return undefined; + } + + const message = meta.error || meta.submitError; + if (!message) { + return undefined; + } + + if (meta.touched || meta.submitFailed || meta.dirtySinceLastSubmit) { + return message; + } + + return undefined; +} + +type MassStrategyCardProps = { + checked: boolean; + children?: ReactNode; + description: string; + disabled?: boolean; + id: string; + label: string; + onToggle: (checked: boolean) => void; +}; + +function formatConflictCount(count: number) { + return `${count} conflict${count === 1 ? '' : 's'}`; +} + +type MassStrategySectionProps = { + children: ReactNode; + conflictCount: number; + summary: string; + title: string; +}; + +function MassStrategyCard({ + checked, + children, + description, + disabled, + id, + label, + onToggle, +}: MassStrategyCardProps) { + const classNames = ['mass-choice']; + if (checked) { + classNames.push('mass-choice--active'); + } + if (disabled) { + classNames.push('mass-choice--disabled'); + } + + return ( +
    +
    + onToggle(event.target.checked)} + disabled={disabled} + /> + +
    +

    {description}

    + {checked && children ? ( +
    {children}
    + ) : null} +
    + ); +} + +function MassStrategySection({ + children, + conflictCount, + summary, + title, +}: MassStrategySectionProps) { + const classNames = ['mass-section']; + if (conflictCount === 0) { + classNames.push('mass-section--calm'); + } + + return ( +
    +
    +
    + {title} + {summary} +
    + + {formatConflictCount(conflictCount)} + +
    +
    {children}
    +
    + ); +} + export default function ConflictsManager({ exportSchema, schema: _schema, }: Props) { const conflicts = useContext(ConflictsContext); - const { submitting, valid, validating } = useFormState(); + const { submitting, valid, validating, values } = useFormState({ + subscription: { + submitting: true, + valid: true, + validating: true, + values: true, + }, + }); const form = useForm(); - const [nameSuffix, setNameSuffix] = useState(' (Import)'); - const [apiKeySuffix, setApiKeySuffix] = useState('import'); const nameSuffixId = useId(); const apiKeySuffixId = useId(); - // Mass action toggles — applied on submit only - const [selectedItemTypesAction, setSelectedItemTypesAction] = useState< - 'rename' | 'reuse' | null - >(null); - const [selectedPluginsAction, setSelectedPluginsAction] = useState< - 'reuse' | 'skip' | null - >(null); - const anyBusy = false; - const apiKeySuffixError = useMemo(() => { - // Canonical DatoCMS API key pattern for the final key: - // ^[a-z][a-z0-9_]*[a-z0-9]$ - // We validate the suffix independently but with equivalent character rules - if (!apiKeySuffix || apiKeySuffix.length === 0) { - return 'API key suffix is required'; - } - if (!/^[a-z0-9_]+$/.test(apiKeySuffix)) { - return 'Only lowercase letters, digits and underscores allowed'; - } - if (!/^[a-z]/.test(apiKeySuffix)) { - return 'Suffix must start with a lowercase letter'; + const massValues = values?.mass ?? {}; + const itemTypesStrategy = + (massValues.itemTypesStrategy as 'rename' | 'reuseExisting' | null) ?? null; + const pluginsStrategy = + (massValues.pluginsStrategy as 'reuseExisting' | 'skip' | null) ?? null; + + const groupedItemTypes = useMemo(() => { + if (!conflicts) { + return { blocks: [], models: [] }; } - if (!/[a-z0-9]$/.test(apiKeySuffix)) { - return 'Suffix must end with a letter or digit'; + + return defaults( + mapValues( + groupBy( + map( + conflicts.itemTypes, + ( + projectItemType: SchemaTypes.ItemType, + exportItemTypeId: string, + ) => { + const exportItemType = + exportSchema.getItemTypeById(exportItemTypeId); + return { exportItemTypeId, exportItemType, projectItemType }; + }, + ), + ({ exportItemType }: { exportItemType: SchemaTypes.ItemType }) => + exportItemType?.attributes.modular_block ? 'blocks' : 'models', + ), + (group: Array<{ exportItemType: SchemaTypes.ItemType }>) => + sortBy( + group, + ({ exportItemType }: { exportItemType: SchemaTypes.ItemType }) => + getTextWithoutRepresentativeEmojiAndPadding( + exportItemType.attributes.name, + ), + ), + ), + { blocks: [], models: [] }, + ); + }, [conflicts, exportSchema]); + + const sortedPlugins = useMemo(() => { + if (!conflicts) { + return [] as Array<{ + exportPluginId: string; + exportPlugin: SchemaTypes.Plugin; + projectPlugin: SchemaTypes.Plugin; + }>; } - return undefined; - }, [apiKeySuffix]); + + return sortBy( + map( + conflicts.plugins, + (projectPlugin: SchemaTypes.Plugin, exportPluginId: string) => { + const exportPlugin = exportSchema.getPluginById(exportPluginId); + return { exportPluginId, exportPlugin, projectPlugin }; + }, + ), + ({ exportPlugin }: { exportPlugin: SchemaTypes.Plugin }) => + exportPlugin.attributes.name, + ); + }, [conflicts, exportSchema]); if (!conflicts) { return null; } - const noPotentialConflicts = - Object.keys(conflicts.itemTypes).length === 0 && - Object.keys(conflicts.plugins).length === 0; + const itemTypeConflictCount = + groupedItemTypes.blocks.length + groupedItemTypes.models.length; + const pluginConflictCount = sortedPlugins.length; + const canApplyItemTypeMass = itemTypeConflictCount > 0; + const canApplyPluginMass = pluginConflictCount > 0; - const groupedItemTypes = defaults( - mapValues( - groupBy( - map( - conflicts.itemTypes, - (projectItemType: SchemaTypes.ItemType, exportItemTypeId: string) => { - const exportItemType = - exportSchema.getItemTypeById(exportItemTypeId); - return { exportItemTypeId, exportItemType, projectItemType }; - }, - ), - ({ exportItemType }: { exportItemType: SchemaTypes.ItemType }) => - exportItemType?.attributes.modular_block ? 'blocks' : 'models', - ), - (group: Array<{ exportItemType: SchemaTypes.ItemType }>) => - sortBy( - group, - ({ exportItemType }: { exportItemType: SchemaTypes.ItemType }) => - getTextWithoutRepresentativeEmojiAndPadding( - exportItemType.attributes.name, - ), - ), - ), - { blocks: [], models: [] }, - ); - - const sortedPlugins = sortBy( - map( - conflicts.plugins, - (projectPlugin: SchemaTypes.Plugin, exportPluginId: string) => { - const exportPlugin = exportSchema.getPluginById(exportPluginId); - return { exportPluginId, exportPlugin, projectPlugin }; - }, - ), - ({ exportPlugin }: { exportPlugin: SchemaTypes.Plugin }) => - exportPlugin.attributes.name, - ); + const noPotentialConflicts = + itemTypeConflictCount === 0 && pluginConflictCount === 0; return (
    @@ -111,181 +228,136 @@ export default function ConflictsManager({
    -
    - {noPotentialConflicts ? ( -

    - No conflicts have been found with the existing schema in this - project. -

    - ) : ( -

    - Some conflicts exist with the current schema in this project. - Before importing, choose how to handle them below. -

    - )} -
    {!noPotentialConflicts && ( -
    - {anyBusy && ( -
    +
    + - -
    - )} - -
    -
    -
    - Models & blocks (Select One) -
    -
    -
    - - -
    - - {selectedItemTypesAction === 'rename' && ( -
    -
    - Default suffixes -
    -
    + { + const nextValue = checked ? 'rename' : null; + form.change('mass.itemTypesStrategy', nextValue); + if (!checked) { + return; + } + if (!massValues.nameSuffix) { + form.change('mass.nameSuffix', ' (Import)'); + } + if (!massValues.apiKeySuffix) { + form.change('mass.apiKeySuffix', 'import'); + } + }} + > +
    + + {({ input, meta }) => ( { - setNameSuffix(val); - form.change('mass.nameSuffix', val); - }} + label="Name suffix" + placeholder="e.g. (Import)" + error={resolveFieldError(meta)} /> + )} + + + {({ input, meta }) => ( { - setApiKeySuffix(val); - form.change('mass.apiKeySuffix', val); - }} - error={apiKeySuffixError} + placeholder="e.g. import" + error={resolveFieldError(meta)} /> -
    -
    - These suffixes will be used if you choose to rename - conflicting models/blocks. -
    -
    - )} -
    -
    - -
    -
    Plugins (Select One)
    -
    -
    - - + )} +
    -
    -
    +

    + Unique combinations are generated automatically if a suffix + already exists in the project. +

    + + + form.change( + 'mass.itemTypesStrategy', + checked ? 'reuseExisting' : null, + ) + } + /> + + + + + form.change( + 'mass.pluginsStrategy', + checked ? 'reuseExisting' : null, + ) + } + /> + + form.change('mass.pluginsStrategy', checked ? 'skip' : null) + } + /> +
    )} @@ -391,7 +463,7 @@ export default function ConflictsManager({ Cancel {(() => { - const proceedDisabled = submitting || !valid || validating || anyBusy; + const proceedDisabled = submitting || !valid || validating; return (
    ; - pluginIdByName?: Record; - fieldIdByExportId?: Record; - onClose: () => void; -}; - -export default function PostImportSummary({ - exportSchema, - importDoc, - adminDomain, - idByApiKey, - pluginIdByName, - fieldIdByExportId, - onClose, -}: Props) { - const searchId = useId(); - const createdEntries = importDoc.itemTypes.entitiesToCreate; - const adminOrigin = useMemo( - () => (adminDomain ? `https://${adminDomain}` : undefined), - [adminDomain], - ); - // Map export item type ID -> final API key after rename (or original if unchanged) - const finalApiKeyByExportItemTypeId = useMemo(() => { - const map = new Map(); - for (const e of importDoc.itemTypes.entitiesToCreate) { - map.set( - String(e.entity.id), - e.rename?.apiKey || e.entity.attributes.api_key, - ); - } - return map; - }, [importDoc]); - const createdItemTypes = useMemo( - () => createdEntries.map((e) => e.entity), - [createdEntries], - ); - const createdPlugins = useMemo( - () => importDoc.plugins.entitiesToCreate, - [importDoc], - ); - // counts computed directly from arrays below - const reusedItemTypesCount = useMemo( - () => Object.keys(importDoc.itemTypes.idsToReuse).length, - [importDoc], - ); - const reusedPluginsCount = useMemo( - () => Object.keys(importDoc.plugins.idsToReuse).length, - [importDoc], - ); - - const createdModels = useMemo( - () => createdEntries.filter((e) => !e.entity.attributes.modular_block), - [createdEntries], - ); - const createdBlocks = useMemo( - () => createdEntries.filter((e) => e.entity.attributes.modular_block), - [createdEntries], - ); - - const pluginStateById = useMemo(() => { - const map = new Map(); - for (const pl of exportSchema.plugins) { - const id = String(pl.id); - if (importDoc.plugins.entitiesToCreate.find((p) => String(p.id) === id)) { - map.set(id, 'created'); - } else if (id in importDoc.plugins.idsToReuse) { - map.set(id, 'reused'); - } else { - map.set(id, 'skipped'); - } - } - return map; - }, [exportSchema, importDoc]); - - const createdItemTypeIdSet = useMemo(() => { - const set = new Set(); - for (const e of createdEntries) set.add(String(e.entity.id)); - return set; - }, [createdEntries]); - - const connections = useMemo( - () => - buildConnections( - exportSchema, - createdItemTypes, - pluginStateById, - createdItemTypeIdSet, - ), - [exportSchema, createdItemTypes, pluginStateById, createdItemTypeIdSet], - ); - - const connectionsById = useMemo(() => { - const map = new Map< - string, - { - linkedItemTypes: Array<{ - target: SchemaTypes.ItemType; - fields: SchemaTypes.Field[]; - }>; - linkedPlugins: Array<{ - plugin: SchemaTypes.Plugin; - fields: SchemaTypes.Field[]; - }>; - } - >(); - for (const c of connections) { - map.set(c.itemType.id, { - linkedItemTypes: c.linkedItemTypes, - linkedPlugins: c.linkedPlugins, - }); - } - return map; - }, [connections]); - - const renamedItems = useMemo( - () => - importDoc.itemTypes.entitiesToCreate.filter( - (e): e is typeof e & { rename: { name: string; apiKey: string } } => - Boolean(e.rename), - ), - [importDoc], - ); - - const createdFields = useMemo( - () => importDoc.itemTypes.entitiesToCreate.flatMap((e) => e.fields), - [importDoc], - ); - const createdFieldsets = useMemo( - () => importDoc.itemTypes.entitiesToCreate.flatMap((e) => e.fieldsets), - [importDoc], - ); - - const [contentQuery, setContentQuery] = useState(''); - const chipStyle = { - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - width: '72px', - height: '18px', - fontSize: '10px', - padding: '0 4px', - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - } as const; - - const filteredModels = useMemo(() => { - const q = contentQuery.trim().toLowerCase(); - if (!q) return createdModels; - return createdModels.filter((e) => { - const name = (e.rename?.name || e.entity.attributes.name).toLowerCase(); - const apiKey = ( - e.rename?.apiKey || e.entity.attributes.api_key - ).toLowerCase(); - return name.includes(q) || apiKey.includes(q); - }); - }, [createdModels, contentQuery]); - - const filteredBlocks = useMemo(() => { - const q = contentQuery.trim().toLowerCase(); - if (!q) return createdBlocks; - return createdBlocks.filter((e) => { - const name = (e.rename?.name || e.entity.attributes.name).toLowerCase(); - const apiKey = ( - e.rename?.apiKey || e.entity.attributes.api_key - ).toLowerCase(); - return name.includes(q) || apiKey.includes(q); - }); - }, [createdBlocks, contentQuery]); - - const filteredPlugins = useMemo(() => { - const q = contentQuery.trim().toLowerCase(); - if (!q) return createdPlugins; - return createdPlugins.filter((pl) => - pl.attributes.name.toLowerCase().includes(q), - ); - }, [createdPlugins, contentQuery]); - - const filteredFields = useMemo(() => { - const q = contentQuery.trim().toLowerCase(); - if (!q) return createdFields; - return createdFields.filter((f) => { - const label = (f.attributes.label || '').toLowerCase(); - const apiKey = f.attributes.api_key.toLowerCase(); - return label.includes(q) || apiKey.includes(q); - }); - }, [createdFields, contentQuery]); - - const filteredFieldsets = useMemo(() => { - const q = contentQuery.trim().toLowerCase(); - if (!q) return createdFieldsets; - return createdFieldsets.filter((fs) => - (fs.attributes.title || '').toLowerCase().includes(q), - ); - }, [createdFieldsets, contentQuery]); - - type SectionKey = - | 'models' - | 'blocks' - | 'plugins' - | 'fields' - | 'fieldsets' - | 'reused' - | 'renames'; - const [activeSection, setActiveSection] = useState('models'); - const sections: Array<{ key: SectionKey; label: string; count: number }> = [ - { key: 'models', label: 'Models', count: createdModels.length }, - { key: 'blocks', label: 'Blocks', count: createdBlocks.length }, - { key: 'plugins', label: 'Plugins', count: createdPlugins.length }, - { key: 'fields', label: 'Fields', count: createdFields.length }, - { key: 'fieldsets', label: 'Fieldsets', count: createdFieldsets.length }, - { - key: 'reused', - label: 'Reused', - count: reusedItemTypesCount + reusedPluginsCount, - }, - { key: 'renames', label: 'Renames', count: renamedItems.length }, - ]; - - return ( -
    -
    -
    -
    -
    - {sections.map((s) => ( - - ))} -
    - -
    -
    -
    - {activeSection === 'models' && ( - <> -
    - Models ({createdModels.length}) -
    -
    - setContentQuery(val)} - textInputProps={{ autoComplete: 'off' }} - /> -
    - { - const name = e.rename?.name || e.entity.attributes.name; - const apiKey = - e.rename?.apiKey || e.entity.attributes.api_key; - const targetId = idByApiKey?.[apiKey]; - return ( -
  • - {adminOrigin && targetId ? ( - - - {name}{' '} - - {apiKey} - - - - - {connectionsById.get(String(e.entity.id)) - ?.linkedItemTypes.length ?? 0}{' '} - links - - - {connectionsById.get(String(e.entity.id)) - ?.linkedPlugins.length ?? 0}{' '} - plugins - - - - ) : ( - <> - {name}{' '} - - {apiKey} - - - - {connectionsById.get(String(e.entity.id)) - ?.linkedItemTypes.length ?? 0}{' '} - links - - - {connectionsById.get(String(e.entity.id)) - ?.linkedPlugins.length ?? 0}{' '} - plugins - - - - )} -
  • - ); - }} - /> - - )} - {activeSection === 'blocks' && ( - <> -
    - Blocks ({createdBlocks.length}) -
    -
    - setContentQuery(val)} - textInputProps={{ autoComplete: 'off' }} - /> -
    - { - const name = e.rename?.name || e.entity.attributes.name; - const apiKey = - e.rename?.apiKey || e.entity.attributes.api_key; - const targetId = idByApiKey?.[apiKey]; - return ( -
  • - {adminOrigin && targetId ? ( - - - {name}{' '} - - {apiKey} - - - - - {connectionsById.get(String(e.entity.id)) - ?.linkedItemTypes.length ?? 0}{' '} - links - - - {connectionsById.get(String(e.entity.id)) - ?.linkedPlugins.length ?? 0}{' '} - plugins - - - - ) : ( - <> - {name}{' '} - - {apiKey} - - - - {connectionsById.get(String(e.entity.id)) - ?.linkedItemTypes.length ?? 0}{' '} - links - - - {connectionsById.get(String(e.entity.id)) - ?.linkedPlugins.length ?? 0}{' '} - plugins - - - - )} -
  • - ); - }} - /> - - )} - {activeSection === 'plugins' && ( - <> -
    - Plugins ({createdPlugins.length}) -
    -
    - setContentQuery(val)} - textInputProps={{ autoComplete: 'off' }} - /> -
    -
      - {filteredPlugins.length > 0 ? ( - filteredPlugins.map((pl) => { - const name = pl.attributes.name; - const pluginId = pluginIdByName?.[name]; - const href = - adminOrigin && pluginId - ? `${adminOrigin}/configuration/plugins/${pluginId}/edit` - : undefined; - return ( -
    • - {href ? ( - - {name} - - ) : ( - name - )} -
    • - ); - }) - ) : ( -
    • No plugins
    • - )} -
    - - )} - {activeSection === 'fields' && ( - <> -
    - Fields ({createdFields.length}) -
    -
    - setContentQuery(val)} - textInputProps={{ autoComplete: 'off' }} - /> -
    - { - const label = f.attributes.label || f.attributes.api_key; - const parentId = String( - (f as SchemaTypes.Field).relationships.item_type.data - .id, - ); - const parent = exportSchema.itemTypesById.get(parentId); - const isBlockParent = - parent?.attributes.modular_block === true; - const basePath = isBlockParent - ? '/schema/blocks_library' - : '/schema/item_types'; - const finalParentApiKey = - finalApiKeyByExportItemTypeId.get(parentId) || - parent?.attributes.api_key; - const targetParentId = finalParentApiKey - ? idByApiKey?.[finalParentApiKey] - : undefined; - const newFieldId = - fieldIdByExportId?.[String(f.id)] || String(f.id); - const href = - adminOrigin && targetParentId - ? `${adminOrigin}${basePath}/${targetParentId}#f${newFieldId}` - : undefined; - return ( -
  • - {href ? ( - - {label}{' '} - - ({f.attributes.api_key}) - - - ) : ( - - {label}{' '} - - ({f.attributes.api_key}) - - - )} -
  • - ); - }} - /> - - )} - {activeSection === 'fieldsets' && ( - <> -
    - Fieldsets ({createdFieldsets.length}) -
    -
    - setContentQuery(val)} - textInputProps={{ autoComplete: 'off' }} - /> -
    - { - const parent = exportSchema.itemTypes.find((it) => - exportSchema - .getItemTypeFieldsets(it) - .some((x) => String(x.id) === String(fs.id)), - ); - const isBlockParent = - parent?.attributes.modular_block === true; - const basePath = isBlockParent - ? '/schema/blocks_library' - : '/schema/item_types'; - const finalParentApiKey = parent - ? finalApiKeyByExportItemTypeId.get( - String(parent.id), - ) || parent.attributes.api_key - : undefined; - const targetParentId = finalParentApiKey - ? idByApiKey?.[finalParentApiKey] - : undefined; - const href = - parent && adminOrigin && targetParentId - ? `${adminOrigin}${basePath}/${targetParentId}` - : undefined; - return ( -
  • - {href ? ( - - {fs.attributes.title} - - ) : ( - - {fs.attributes.title} - - )} -
  • - ); - }} - /> - - )} - {activeSection === 'reused' && ( - <> -
    Reused
    -
    - {reusedItemTypesCount > 0 && ( - -
    Reused models/blocks
    -
    - {reusedItemTypesCount} reused -
    -
    - )} - {reusedPluginsCount > 0 && ( - -
    Reused plugins
    -
    - {reusedPluginsCount} reused -
    -
    - )} -
    - - )} - {activeSection === 'renames' && ( - <> -
    Renames
    -
    - {renamedItems.length > 0 ? ( -
    -
    Renamed models/blocks
    -
      - {renamedItems.map((r) => { - const from = exportSchema.getItemTypeById( - String(r.entity.id), - ); - const isBlock = - from.attributes.modular_block === true; - const basePath = isBlock - ? '/schema/blocks_library' - : '/schema/item_types'; - const finalApiKey = - r.rename?.apiKey || from.attributes.api_key; - const targetId = idByApiKey?.[finalApiKey]; - const href = - adminOrigin && targetId - ? `${adminOrigin}${basePath}/${targetId}` - : undefined; - return ( -
    • - {href ? ( - - - {from.attributes.name} - {' → '} - {r.rename?.name || ''} - - - ({from.attributes.api_key} - {' → '} - {r.rename?.apiKey || ''}) - - - ) : ( -
      - - {from.attributes.name} - {' → '} - {r.rename?.name || ''} - - - ({from.attributes.api_key} - {' → '} - {r.rename?.apiKey || ''}) - -
      - )} -
    • - ); - })} -
    -
    - ) : ( -
    -
    Renamed models/blocks
    -
    0 renamed
    -
    - )} -
    - - )} -
    -
    -
    -
    -
    - ); -} - -function Box({ children }: { children: React.ReactNode }) { - return
    {children}
    ; -} - -function buildConnections( - exportSchema: ExportSchema, - createdItemTypes: SchemaTypes.ItemType[], - pluginStateById: Map, - createdItemTypeIdSet: Set, -) { - const out = [] as Array<{ - itemType: SchemaTypes.ItemType; - linkedItemTypes: Array<{ - target: SchemaTypes.ItemType; - fields: SchemaTypes.Field[]; - status: 'created' | 'reused'; - }>; - linkedPlugins: Array<{ - plugin: SchemaTypes.Plugin; - fields: SchemaTypes.Field[]; - status: 'created' | 'reused' | 'skipped'; - }>; - }>; - - for (const it of createdItemTypes) { - const fields = exportSchema.getItemTypeFields(it); - const byItemType = new Map(); - const byPlugin = new Map(); - - for (const field of fields) { - for (const linkedId of findLinkedItemTypeIds(field)) { - const arr = byItemType.get(String(linkedId)) || []; - arr.push(field); - byItemType.set(String(linkedId), arr); - } - for (const pluginId of findLinkedPluginIds( - field, - new Set(exportSchema.plugins.map((p) => String(p.id))), - )) { - const arr = byPlugin.get(String(pluginId)) || []; - arr.push(field); - byPlugin.set(String(pluginId), arr); - } - } - - const linkedItemTypes = Array.from(byItemType.entries()) - .map(([targetId, fields]) => { - const target = exportSchema.itemTypesById.get(String(targetId)); - if (!target) return null; // target not in export doc; skip - const status: 'created' | 'reused' = createdItemTypeIdSet.has( - String(targetId), - ) - ? 'created' - : 'reused'; - return { target, fields, status }; - }) - .filter( - ( - v, - ): v is { - target: SchemaTypes.ItemType; - fields: SchemaTypes.Field[]; - status: 'created' | 'reused'; - } => !!v, - ); - - const linkedPlugins = Array.from(byPlugin.entries()).flatMap( - ([pid, fields]) => { - const plugin = exportSchema.pluginsById.get(String(pid)); - if (!plugin) return []; - return [ - { - plugin, - fields, - status: (pluginStateById.get(String(pid)) || 'skipped') as - | 'created' - | 'reused' - | 'skipped', - }, - ]; - }, - ); - - out.push({ itemType: it, linkedItemTypes, linkedPlugins }); - } - - return out; -} - -// Collapsible removed in import summary rework - -function LimitedList({ - items, - renderItem, - initial = 20, -}: { - items: T[]; - renderItem: (item: T) => React.ReactNode; - initial?: number; -}) { - const [limit, setLimit] = useState(initial); - const showingAll = limit >= items.length; - const visible = items.slice(0, limit); - return ( - <> -
      - {visible.map((it) => renderItem(it))} -
    - {items.length > initial && ( -
    - -
    - )} - - ); -} - -// ConnectionsPanel removed; inline counts are shown alongside models/blocks - -// StatusPill removed with ConnectionsPanel diff --git a/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx b/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx index 5f750b37..b17643b1 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx @@ -32,7 +32,7 @@ type ItemTypeValues = { }; type PluginValues = { strategy: 'reuseExisting' | 'skip' | null }; -type MassValues = { +export type MassValues = { itemTypesStrategy?: 'reuseExisting' | 'rename' | null; pluginsStrategy?: 'reuseExisting' | 'skip' | null; nameSuffix?: string; @@ -395,3 +395,11 @@ export function useSkippedItemsAndPluginIds() { return { skippedItemTypeIds, skippedPluginIds }; } + +export function useMassStrategies() { + const state = useFormState({ + subscription: { values: true }, + }); + + return state.values?.mass ?? {}; +} diff --git a/import-export-schema/src/entrypoints/ImportPage/index.tsx b/import-export-schema/src/entrypoints/ImportPage/index.tsx index efa6ae00..95028dc2 100644 --- a/import-export-schema/src/entrypoints/ImportPage/index.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/index.tsx @@ -2,13 +2,7 @@ import type { SchemaTypes } from '@datocms/cma-client'; // Removed unused icons import { ReactFlowProvider } from '@xyflow/react'; import type { RenderPageCtx } from 'datocms-plugin-sdk'; -import { - Button, - Canvas, - SelectField, - Spinner, - TextField, -} from 'datocms-react-ui'; +import { Button, Canvas, SelectField, Spinner } from 'datocms-react-ui'; import { useEffect, useId, useMemo, useRef, useState } from 'react'; import type { GroupBase } from 'react-select'; import ProgressStallNotice from '@/components/ProgressStallNotice'; @@ -19,8 +13,7 @@ import type { ExportDoc } from '@/utils/types'; import buildExportDoc from '../ExportPage/buildExportDoc'; import { ExportSchema } from '../ExportPage/ExportSchema'; import ExportInner from '../ExportPage/Inner'; -import PostExportSummary from '../ExportPage/PostExportSummary'; -import type { ImportDoc } from './buildImportDoc'; +// PostExportSummary removed: exports now download directly with a toast import { buildImportDoc } from './buildImportDoc'; import buildConflicts, { type Conflicts, @@ -28,11 +21,8 @@ import buildConflicts, { import { ConflictsContext } from './ConflictsManager/ConflictsContext'; import FileDropZone from './FileDropZone'; import { Inner } from './Inner'; -import importSchema, { - type ImportProgress, - type ImportResult, -} from './importSchema'; -import PostImportSummary from './PostImportSummary'; +import importSchema, { type ImportProgress } from './importSchema'; +// PostImportSummary removed: after import we just show a toast and reset import ResolutionsForm, { type Resolutions } from './ResolutionsForm'; type Props = { @@ -47,7 +37,6 @@ export function ImportPage({ hideModeToggle = false, }: Props) { const exportInitialSelectId = useId(); - const confirmTextId = useId(); const params = new URLSearchParams(ctx.location.search); const recipeUrl = params.get('recipe_url'); const recipeTitle = params.get('recipe_title'); @@ -90,16 +79,7 @@ export function ImportPage({ const [importCancelled, setImportCancelled] = useState(false); const importCancelRef = useRef(false); - const [postImportSummary, setPostImportSummary] = useState< - | { - importDoc: ImportDoc; - exportSchema: ExportSchema; - idByApiKey?: Record; - pluginIdByName?: Record; - fieldIdByExportId?: Record; - } - | undefined - >(undefined); + // Removed postImportSummary: no post-import overview screen async function handleDrop(filename: string, doc: ExportDoc) { try { @@ -118,23 +98,7 @@ export function ImportPage({ const projectSchema = useMemo(() => new ProjectSchema(client), [client]); - const [adminDomain, setAdminDomain] = useState(); - useEffect(() => { - let active = true; - (async () => { - try { - const site = await client.site.find(); - const domain = site.internal_domain || site.domain || undefined; - if (active) setAdminDomain(domain); - console.log('[ImportPage] resolved admin domain:', domain, site); - } catch { - // ignore; links will simply not be shown - } - })(); - return () => { - active = false; - }; - }, [client]); + // Removed adminDomain lookup; no post-import summary links needed // State used only in Export tab: choose initial model/block for graph const [exportInitialItemTypeIds, setExportInitialItemTypeIds] = useState< @@ -144,9 +108,7 @@ export function ImportPage({ SchemaTypes.ItemType[] >([]); const [exportStarted, setExportStarted] = useState(false); - const [postExportDoc, setPostExportDoc] = useState( - undefined, - ); + // Removed postExportDoc: no post-export overview for exports const [exportAllBusy, setExportAllBusy] = useState(false); const [exportAllProgress, setExportAllProgress] = useState< { done: number; total: number; label: string } | undefined @@ -200,13 +162,6 @@ export function ImportPage({ { done: number; total: number; label: string } | undefined >(undefined); - // Typed confirmation gate state - const [confirmVisible, setConfirmVisible] = useState(false); - const [confirmExpected, setConfirmExpected] = useState(''); - const [confirmText, setConfirmText] = useState(''); - const [pendingResolutions, setPendingResolutions] = useState< - Resolutions | undefined - >(undefined); useEffect(() => { async function run() { @@ -274,19 +229,6 @@ export function ImportPage({ try { setImportCancelled(false); importCancelRef.current = false; - // If any rename operations are selected, require typed confirmation - const renameCount = Object.values(resolutions.itemTypes).filter( - (r) => r && 'strategy' in r && r.strategy === 'rename', - ).length; - - if (renameCount > 0) { - setPendingResolutions(resolutions); - setConfirmExpected(`RENAME ${renameCount}`); - setConfirmText(''); - setConfirmVisible(true); - return; - } - setImportProgress({ finished: 0, total: 1 }); const importDoc = await buildImportDoc( @@ -295,7 +237,7 @@ export function ImportPage({ resolutions, ); - const importResult: ImportResult = await importSchema( + await importSchema( importDoc, client, (p) => { @@ -306,94 +248,11 @@ export function ImportPage({ }, ); - ctx.notice('Import completed successfully!'); - // Refresh models list to build API key -> ID map for linking - let idByApiKey: Record | undefined; - let pluginIdByName: Record | undefined; - try { - const itemTypes = await client.itemTypes.list(); - idByApiKey = Object.fromEntries( - itemTypes.map((it) => [it.api_key, it.id]), - ); - const plugins = await client.plugins.list(); - pluginIdByName = Object.fromEntries( - plugins.map((pl) => [pl.name, pl.id]), - ); - } catch { - // ignore: links will still render without IDs - } - - setPostImportSummary({ - importDoc, - exportSchema: exportSchema[1], - idByApiKey, - pluginIdByName, - fieldIdByExportId: importResult.fieldIdByExportId, - }); + // Success: notify and reset to initial idle state + ctx.notice('Import completed successfully.'); setImportProgress(undefined); setExportSchema(undefined); - } catch (e) { - console.error(e); - if (e instanceof Error && e.message === 'Import cancelled') { - ctx.notice('Import canceled'); - } else { - ctx.alert('Import could not be completed successfully.'); - } - setImportProgress(undefined); - } - } - - async function proceedAfterConfirm() { - if (!pendingResolutions || !exportSchema || !conflicts) return; - - try { - setConfirmVisible(false); - setImportCancelled(false); - importCancelRef.current = false; - setImportProgress({ finished: 0, total: 1 }); - - const importDoc = await buildImportDoc( - exportSchema[1], - conflicts, - pendingResolutions, - ); - - const importResult: ImportResult = await importSchema( - importDoc, - client, - (p) => { - if (!importCancelRef.current) setImportProgress(p); - }, - { - shouldCancel: () => importCancelRef.current, - }, - ); - - ctx.notice('Import completed successfully!'); - // Refresh models list to build API key -> ID map for linking - let idByApiKey: Record | undefined; - let pluginIdByName: Record | undefined; - try { - const itemTypes = await client.itemTypes.list(); - idByApiKey = Object.fromEntries( - itemTypes.map((it) => [it.api_key, it.id]), - ); - const plugins = await client.plugins.list(); - pluginIdByName = Object.fromEntries( - plugins.map((pl) => [pl.name, pl.id]), - ); - } catch {} - - setPostImportSummary({ - importDoc, - exportSchema: exportSchema[1], - idByApiKey, - pluginIdByName, - fieldIdByExportId: importResult.fieldIdByExportId, - }); - setImportProgress(undefined); - setExportSchema(undefined); - setPendingResolutions(undefined); + setConflicts(undefined); } catch (e) { console.error(e); if (e instanceof Error && e.message === 'Import cancelled') { @@ -450,23 +309,7 @@ export function ImportPage({ )}
    {mode === 'import' ? ( - postImportSummary ? ( - { - setPostImportSummary(undefined); - ctx.navigateTo( - `${ctx.isEnvironmentPrimary ? '' : `/environments/${ctx.environment}`}/configuration/p/${ctx.plugin.id}/pages/import`, - ); - }} - /> - ) : ( - + {(button) => exportSchema ? ( conflicts ? ( @@ -511,28 +354,9 @@ export function ImportPage({ ) } - ) ) : (
    - {postExportDoc ? ( - - downloadJSON(postExportDoc, { - fileName: 'export.json', - prettify: true, - }) - } - onClose={() => { - setPostExportDoc(undefined); - setMode('import'); - setExportStarted(false); - setExportInitialItemTypeIds([]); - setExportInitialItemTypes([]); - }} - /> - ) : !exportStarted ? ( + {!exportStarted ? (
    Start a new export @@ -706,8 +530,7 @@ export function ImportPage({ fileName: 'export.json', prettify: true, }); - setPostExportDoc(exportDoc); - ctx.notice('Export completed with success!'); + ctx.notice('Export completed successfully.'); } catch (e) { console.error('Export-all failed', e); if ( @@ -790,8 +613,7 @@ export function ImportPage({ fileName: 'export.json', prettify: true, }); - setPostExportDoc(exportDoc); - ctx.notice('Export completed with success!'); + ctx.notice('Export completed successfully.'); } catch (e) { console.error('Selection export failed', e); if ( @@ -1190,89 +1012,6 @@ export function ImportPage({
    )} - - {/* Typed confirmation modal for renames */} - {confirmVisible && ( -
    -
    -
    - Confirm rename operations -
    -
    - You chose to import items with renamed models/blocks. To confirm, - type - - {' '} - {confirmExpected} - {' '} - below. -
    -
    - setConfirmText(val)} - textInputProps={{ - autoFocus: true, - onKeyDown: (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && confirmText === confirmExpected) { - e.preventDefault(); - void proceedAfterConfirm(); - } - }, - }} - /> -
    -
    - - -
    -
    -
    - )} ); } diff --git a/import-export-schema/src/index.css b/import-export-schema/src/index.css index cd51f6fe..ac1873d5 100644 --- a/import-export-schema/src/index.css +++ b/import-export-schema/src/index.css @@ -973,154 +973,155 @@ button.chip:focus-visible { /* Conflicts manager mass actions */ .conflicts-setup { - display: grid; - gap: var(--spacing-m); - margin: var(--spacing-m) 0 var(--spacing-l); + border: none; + border-radius: 18px; + background: #fff; + padding: var(--spacing-l); + margin: var(--spacing-l) 0; + box-shadow: 0 18px 36px -28px rgba(17, 24, 39, 0.32); } -.setup-inline { + +.mass-strategy-grid { display: grid; - gap: 12px; + grid-template-columns: 1fr; + gap: var(--spacing-l); } -.setup-group { + + +.mass-section { display: grid; - grid-template-columns: 160px 1fr; - align-items: start; - gap: 12px; + gap: var(--spacing-m); + padding: var(--spacing-m); + border: 1px solid color-mix(in srgb, var(--border-color), transparent 55%); + border-radius: 14px; + background: #fff; + width: 100%; } -/* Vertical variant for compact, column-like layout */ -.setup-group--vertical { - grid-template-columns: 1fr; +.mass-section--calm { + border-color: color-mix(in srgb, var(--border-color), transparent 70%); } -.setup-group__label { - font-weight: 600; - color: var(--light-body-color); - padding-top: 0; +.mass-section__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--spacing-m); } -.setup-group__content { +.mass-section__header-copy { display: grid; - gap: 6px; + gap: 4px; } -.setup__fields { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--spacing-m); - align-items: start; +.mass-section__title { + font-weight: 600; + font-size: var(--font-size-m); } -.setup__hint { +.mass-section__summary { color: var(--light-body-color); - font-size: 12px; -} - -.segmented { - display: inline-grid; - grid-auto-flow: column; - gap: 6px; - background: #fff; - padding: 4px; - border-radius: 999px; - border: 1px solid var(--border-color); + font-size: var(--font-size-s); + line-height: 1.4; } -.segmented__button { +.mass-section__badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 8px; border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--accent-color), white 50%); + font-size: 12px; + font-weight: 600; + line-height: 1; + color: var(--accent-color); + background: color-mix(in srgb, var(--accent-color), white 95%); + flex-shrink: 0; + white-space: nowrap; } -.segmented__button.is-selected { - background: color-mix(in srgb, var(--accent-color), white 90%); - box-shadow: 0 0 0 2px var(--accent-color); +.mass-section__actions { + display: grid; + gap: var(--spacing-s); } -/* New vertical choice list */ -.choice-list { +.mass-choice { + border: 1px solid color-mix(in srgb, var(--border-color), transparent 45%); + border-radius: 12px; + padding: 16px 18px; + background: #fff; display: grid; gap: 10px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; } -.choice-button { - width: 100%; - justify-content: center; +.mass-choice:hover { + border-color: color-mix(in srgb, var(--accent-color), white 65%); } -.choice-button.is-selected { - background: color-mix(in srgb, var(--accent-color), white 90%); +.mass-choice--active { border-color: var(--accent-color); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--accent-color), white 50%), + 0 8px 18px -12px rgba(18, 45, 94, 0.45); } -.mass-actions__grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--spacing-m); - align-items: start; -} - -.mass-actions__apply { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--spacing-s); - margin-top: var(--spacing-m); +.mass-choice--disabled { + opacity: 0.6; + pointer-events: none; } -.mass-actions__plugin-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--spacing-s); - margin-top: var(--spacing-m); +.mass-choice__header { + display: flex; + align-items: center; + gap: 12px; } -/* Clear, guided choice groups in conflicts manager */ -.mass-actions__section { - display: grid; - gap: var(--spacing-xs, 6px); - margin-top: var(--spacing-m); +.mass-choice__checkbox { + width: 18px; + height: 18px; + accent-color: var(--accent-color); } -.mass-actions__section__label { - font-weight: 700; +.mass-choice__label { + cursor: pointer; + font-weight: 600; + font-size: var(--font-size-s); color: var(--base-body-color); + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); } -.choice-group { - display: grid; - grid-template-columns: 1fr 1fr; - align-items: stretch; - gap: var(--spacing-s); +.mass-choice__label-text { + display: block; } -.choice-group__or { +.mass-choice__description { + margin: 0; + font-size: var(--font-size-s); color: var(--light-body-color); - font-weight: 600; } -.choice-button.is-selected { - box-shadow: - 0 0 0 3px var(--accent-color), - 0 0 0 6px color-mix(in srgb, var(--accent-color), white 65%); - transform: translateZ(0); +.mass-choice__details { + display: grid; + gap: var(--spacing-s); + padding-top: var(--spacing-sm); + border-top: 1px solid color-mix(in srgb, var(--border-color), transparent 40%); } -@media (max-width: 900px) { - .setup-group { - grid-template-columns: 1fr; - } - - .setup__fields { - grid-template-columns: 1fr; - } - - .segmented { - grid-auto-flow: row; - justify-items: stretch; - } +.mass-choice__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: var(--spacing-s); +} - .choice-group { - grid-template-columns: 1fr; - } +.mass-choice__hint { + margin: 0; + font-size: 12px; + color: var(--light-body-color); } .conflict { @@ -1155,6 +1156,23 @@ button.chip:focus-visible { padding: 12px 16px; /* reduce inner padding for a tighter look */ } +.conflict__mass-rule { + margin-top: 12px; + padding: 12px 14px; + border-radius: 10px; + border: 1px solid color-mix(in srgb, var(--border-color), transparent 35%); + background: color-mix(in srgb, #fefefe, var(--border-color) 12%); + font-size: var(--font-size-s); + line-height: 1.5; + color: var(--base-body-color); +} + +.conflict__mass-rule strong { + display: block; + margin-bottom: 4px; + color: var(--accent-color); +} + /* Pretty export overlay styles */ .export-overlay__card { background: #fff; diff --git a/import-export-schema/tsconfig.app.tsbuildinfo b/import-export-schema/tsconfig.app.tsbuildinfo index e6a371a9..a6ccafa2 100644 --- a/import-export-schema/tsconfig.app.tsbuildinfo +++ b/import-export-schema/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/field.tsx","./src/components/fieldedgerenderer.tsx","./src/components/graphcanvas.tsx","./src/components/itemtypenoderenderer.tsx","./src/components/pluginnoderenderer.tsx","./src/components/progressstallnotice.tsx","./src/components/bezier.ts","./src/entrypoints/config/index.tsx","./src/entrypoints/exporthome/index.tsx","./src/entrypoints/exportpage/entitiestoexportcontext.ts","./src/entrypoints/exportpage/exportitemtypenoderenderer.tsx","./src/entrypoints/exportpage/exportpluginnoderenderer.tsx","./src/entrypoints/exportpage/exportschema.ts","./src/entrypoints/exportpage/inner.tsx","./src/entrypoints/exportpage/largeselectionview.tsx","./src/entrypoints/exportpage/postexportsummary.tsx","./src/entrypoints/exportpage/buildexportdoc.ts","./src/entrypoints/exportpage/buildgraphfromschema.ts","./src/entrypoints/exportpage/index.tsx","./src/entrypoints/exportpage/useanimatednodes.tsx","./src/entrypoints/importpage/filedropzone.tsx","./src/entrypoints/importpage/importitemtypenoderenderer.tsx","./src/entrypoints/importpage/importpluginnoderenderer.tsx","./src/entrypoints/importpage/inner.tsx","./src/entrypoints/importpage/largeselectionview.tsx","./src/entrypoints/importpage/postimportsummary.tsx","./src/entrypoints/importpage/resolutionsform.tsx","./src/entrypoints/importpage/selectedentitycontext.tsx","./src/entrypoints/importpage/buildgraphfromexportdoc.ts","./src/entrypoints/importpage/buildimportdoc.ts","./src/entrypoints/importpage/importschema.ts","./src/entrypoints/importpage/index.tsx","./src/entrypoints/importpage/conflictsmanager/collapsible.tsx","./src/entrypoints/importpage/conflictsmanager/conflictscontext.ts","./src/entrypoints/importpage/conflictsmanager/itemtypeconflict.tsx","./src/entrypoints/importpage/conflictsmanager/pluginconflict.tsx","./src/entrypoints/importpage/conflictsmanager/buildconflicts.ts","./src/entrypoints/importpage/conflictsmanager/index.tsx","./src/types/lodash-es.d.ts","./src/utils/projectschema.ts","./src/utils/createcmaclient.ts","./src/utils/downloadjson.ts","./src/utils/emojiagnosticsorter.ts","./src/utils/icons.tsx","./src/utils/isdefined.ts","./src/utils/render.tsx","./src/utils/types.ts","./src/utils/datocms/appearance.ts","./src/utils/datocms/fieldtypeinfo.ts","./src/utils/datocms/schema.ts","./src/utils/datocms/validators.ts","./src/utils/graph/analysis.ts","./src/utils/graph/buildgraph.ts","./src/utils/graph/buildhierarchynodes.ts","./src/utils/graph/dependencies.ts","./src/utils/graph/edges.ts","./src/utils/graph/nodes.ts","./src/utils/graph/rebuildgraphwithpositionsfromhierarchy.ts","./src/utils/graph/sort.ts","./src/utils/graph/types.ts","./src/utils/schema/exportschemasource.ts","./src/utils/schema/ischemasource.ts","./src/utils/schema/projectschemasource.ts"],"version":"5.7.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/field.tsx","./src/components/fieldedgerenderer.tsx","./src/components/graphcanvas.tsx","./src/components/itemtypenoderenderer.tsx","./src/components/pluginnoderenderer.tsx","./src/components/progressstallnotice.tsx","./src/components/bezier.ts","./src/entrypoints/config/index.tsx","./src/entrypoints/exporthome/index.tsx","./src/entrypoints/exportpage/entitiestoexportcontext.ts","./src/entrypoints/exportpage/exportitemtypenoderenderer.tsx","./src/entrypoints/exportpage/exportpluginnoderenderer.tsx","./src/entrypoints/exportpage/exportschema.ts","./src/entrypoints/exportpage/inner.tsx","./src/entrypoints/exportpage/largeselectionview.tsx","./src/entrypoints/exportpage/buildexportdoc.ts","./src/entrypoints/exportpage/buildgraphfromschema.ts","./src/entrypoints/exportpage/index.tsx","./src/entrypoints/exportpage/useanimatednodes.tsx","./src/entrypoints/importpage/filedropzone.tsx","./src/entrypoints/importpage/importitemtypenoderenderer.tsx","./src/entrypoints/importpage/importpluginnoderenderer.tsx","./src/entrypoints/importpage/inner.tsx","./src/entrypoints/importpage/largeselectionview.tsx","./src/entrypoints/importpage/resolutionsform.tsx","./src/entrypoints/importpage/selectedentitycontext.tsx","./src/entrypoints/importpage/buildgraphfromexportdoc.ts","./src/entrypoints/importpage/buildimportdoc.ts","./src/entrypoints/importpage/importschema.ts","./src/entrypoints/importpage/index.tsx","./src/entrypoints/importpage/conflictsmanager/collapsible.tsx","./src/entrypoints/importpage/conflictsmanager/conflictscontext.ts","./src/entrypoints/importpage/conflictsmanager/itemtypeconflict.tsx","./src/entrypoints/importpage/conflictsmanager/pluginconflict.tsx","./src/entrypoints/importpage/conflictsmanager/buildconflicts.ts","./src/entrypoints/importpage/conflictsmanager/index.tsx","./src/types/lodash-es.d.ts","./src/utils/projectschema.ts","./src/utils/createcmaclient.ts","./src/utils/downloadjson.ts","./src/utils/emojiagnosticsorter.ts","./src/utils/icons.tsx","./src/utils/isdefined.ts","./src/utils/render.tsx","./src/utils/types.ts","./src/utils/datocms/appearance.ts","./src/utils/datocms/fieldtypeinfo.ts","./src/utils/datocms/schema.ts","./src/utils/datocms/validators.ts","./src/utils/graph/analysis.ts","./src/utils/graph/buildgraph.ts","./src/utils/graph/buildhierarchynodes.ts","./src/utils/graph/dependencies.ts","./src/utils/graph/edges.ts","./src/utils/graph/nodes.ts","./src/utils/graph/rebuildgraphwithpositionsfromhierarchy.ts","./src/utils/graph/sort.ts","./src/utils/graph/types.ts","./src/utils/schema/exportschemasource.ts","./src/utils/schema/ischemasource.ts","./src/utils/schema/projectschemasource.ts"],"version":"5.7.3"} \ No newline at end of file From 2b6078c165abb17faf4f55e03607dd2977b5431e Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Mon, 15 Sep 2025 23:07:22 +0200 Subject: [PATCH 04/36] better conflict manager --- import-export-schema/src/index.css | 77 ++++++++++++++++-------------- 1 file changed, 42 insertions(+), 35 deletions(-) diff --git a/import-export-schema/src/index.css b/import-export-schema/src/index.css index ac1873d5..270caf71 100644 --- a/import-export-schema/src/index.css +++ b/import-export-schema/src/index.css @@ -974,45 +974,46 @@ button.chip:focus-visible { /* Conflicts manager mass actions */ .conflicts-setup { border: none; - border-radius: 18px; + border-radius: 16px; background: #fff; - padding: var(--spacing-l); - margin: var(--spacing-l) 0; - box-shadow: 0 18px 36px -28px rgba(17, 24, 39, 0.32); + padding: var(--spacing-m); + margin: calc(var(--spacing-m) * 0.75) 0; + box-shadow: 0 12px 34px -26px rgba(15, 23, 42, 0.28); } .mass-strategy-grid { display: grid; grid-template-columns: 1fr; - gap: var(--spacing-l); + gap: var(--spacing-m); } + .mass-section { display: grid; - gap: var(--spacing-m); - padding: var(--spacing-m); - border: 1px solid color-mix(in srgb, var(--border-color), transparent 55%); + gap: var(--spacing-sm); + padding: calc(var(--spacing-m) - 2px); + border: 1px solid color-mix(in srgb, var(--border-color), transparent 65%); border-radius: 14px; background: #fff; width: 100%; } .mass-section--calm { - border-color: color-mix(in srgb, var(--border-color), transparent 70%); + border-color: color-mix(in srgb, var(--border-color), transparent 80%); } .mass-section__header { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; - gap: var(--spacing-m); + gap: var(--spacing-sm); } .mass-section__header-copy { display: grid; - gap: 4px; + gap: 2px; } .mass-section__title { @@ -1023,14 +1024,14 @@ button.chip:focus-visible { .mass-section__summary { color: var(--light-body-color); font-size: var(--font-size-s); - line-height: 1.4; + line-height: 1.35; } .mass-section__badge { display: inline-flex; align-items: center; justify-content: center; - padding: 2px 8px; + padding: 2px 10px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--accent-color), white 50%); font-size: 12px; @@ -1044,28 +1045,31 @@ button.chip:focus-visible { .mass-section__actions { display: grid; - gap: var(--spacing-s); + gap: var(--spacing-sm); } .mass-choice { - border: 1px solid color-mix(in srgb, var(--border-color), transparent 45%); + border: 1px solid color-mix(in srgb, var(--border-color), transparent 55%); border-radius: 12px; - padding: 16px 18px; + padding: 12px 16px; background: #fff; display: grid; - gap: 10px; - transition: border-color 0.2s ease, box-shadow 0.2s ease; + grid-template-columns: auto 1fr; + column-gap: 12px; + row-gap: 4px; + align-items: flex-start; + transition: border-color 0.2s ease, box-shadow 0.2s ease, + background-color 0.2s ease; } .mass-choice:hover { - border-color: color-mix(in srgb, var(--accent-color), white 65%); + border-color: color-mix(in srgb, var(--accent-color), white 70%); } .mass-choice--active { - border-color: var(--accent-color); - box-shadow: - 0 0 0 1px color-mix(in srgb, var(--accent-color), white 50%), - 0 8px 18px -12px rgba(18, 45, 94, 0.45); + border-color: color-mix(in srgb, var(--accent-color), white 30%); + background: color-mix(in srgb, var(--accent-color), white 96%); + box-shadow: 0 6px 16px -14px rgba(17, 24, 39, 0.32); } .mass-choice--disabled { @@ -1074,15 +1078,14 @@ button.chip:focus-visible { } .mass-choice__header { - display: flex; - align-items: center; - gap: 12px; + display: contents; } .mass-choice__checkbox { - width: 18px; - height: 18px; + width: 16px; + height: 16px; accent-color: var(--accent-color); + margin-top: 2px; } .mass-choice__label { @@ -1093,6 +1096,7 @@ button.chip:focus-visible { display: inline-flex; align-items: center; gap: var(--spacing-xs); + grid-column: 2; } .mass-choice__label-text { @@ -1103,19 +1107,22 @@ button.chip:focus-visible { margin: 0; font-size: var(--font-size-s); color: var(--light-body-color); + grid-column: 2; + line-height: 1.35; } .mass-choice__details { display: grid; - gap: var(--spacing-s); - padding-top: var(--spacing-sm); - border-top: 1px solid color-mix(in srgb, var(--border-color), transparent 40%); + gap: var(--spacing-xs); + padding-top: var(--spacing-xs); + border-top: 1px solid color-mix(in srgb, var(--border-color), transparent 45%); + grid-column: 2; } .mass-choice__grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); - gap: var(--spacing-s); + grid-template-columns: repeat(auto-fit, minmax(152px, 1fr)); + gap: var(--spacing-xs); } .mass-choice__hint { @@ -1297,7 +1304,7 @@ button.chip:focus-visible { } .conflicts-manager__actions { - padding: var(--spacing-l); + padding: var(--spacing-m) var(--spacing-l) var(--spacing-s); } .conflicts-manager__actions__reassurance { From ba6da8f4a01f3f2385180d08eba7188bb5153a78 Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Wed, 17 Sep 2025 17:50:07 +0200 Subject: [PATCH 05/36] simplify --- import-export-schema/AGENTS.md | 28 ++ .../ConflictsManager/ItemTypeConflict.tsx | 143 ++---- .../ConflictsManager/PluginConflict.tsx | 67 +-- .../ImportPage/ConflictsManager/index.tsx | 427 ++++-------------- .../ImportPage/ResolutionsForm.tsx | 204 ++------- import-export-schema/src/index.css | 177 -------- 6 files changed, 220 insertions(+), 826 deletions(-) create mode 100644 import-export-schema/AGENTS.md diff --git a/import-export-schema/AGENTS.md b/import-export-schema/AGENTS.md new file mode 100644 index 00000000..0d380c6d --- /dev/null +++ b/import-export-schema/AGENTS.md @@ -0,0 +1,28 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `src/entrypoints/`: Plugin pages (`Config`, `ExportPage`, `ImportPage`) with local helpers and `index.tsx`. +- `src/components/`: Reusable React components shared across entrypoints. +- `src/utils/`: Helpers (schema builders, rendering, types, download utilities). +- `src/icons/`: SVG assets. +- `public/`, `index.html`: Vite app shell; production build in `dist/` (plugin entry is `dist/index.html`). +- `docs/`: Cover/preview assets included in the package. + +## Build, Test, and Development Commands +- `npm run build` — check for errors +Notes: Use Node 18+ and npm (repo uses `package-lock.json`). + +## Coding Style & Naming Conventions +- Language: TypeScript + React 18; Vite. +- Formatting: Biome; 2-space indent, single quotes, organized imports. +- Naming: PascalCase for components/files (e.g., `ExportPluginNodeRenderer.tsx`); camelCase for functions/vars; PascalCase for types. +- Styles: Prefer CSS modules when present (e.g., `styles.module.css`). +- UI: Follow DatoCMS-like design using `datocms-react-ui` and `ctx.theme` vars. + +I want to make a refactor to make this whole code way smaller, as DRY as possible, and as legible and simple as possible + +## Security & Configuration Tips +- Never hardcode or log tokens; rely on `@datocms/cma-client`. +- Avoid mutating existing schema objects; make additive, safe changes. +- Do not commit secrets or personal access tokens. Review diffs for sensitive data. + diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx index e52cb4f5..6c0e7442 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx @@ -4,10 +4,7 @@ import { SelectField, TextField } from 'datocms-react-ui'; import { useId } from 'react'; import { Field } from 'react-final-form'; import type { GroupBase } from 'react-select'; -import { - useMassStrategies, - useResolutionStatusForItemType, -} from '../ResolutionsForm'; +import { useResolutionStatusForItemType } from '../ResolutionsForm'; import Collapsible from './Collapsible'; type Option = { label: string; value: string }; @@ -24,7 +21,6 @@ export function ItemTypeConflict({ exportItemType, projectItemType }: Props) { const fieldPrefix = `itemType-${exportItemType.id}`; const resolution = useResolutionStatusForItemType(exportItemType.id)!; const node = useReactFlow().getNode(`itemType--${exportItemType.id}`); - const mass = useMassStrategies(); const exportType = exportItemType.attributes.modular_block ? 'block' @@ -51,45 +47,6 @@ export function ItemTypeConflict({ exportItemType, projectItemType }: Props) { return null; } - const massStrategy = mass.itemTypesStrategy ?? null; - const nameSuffix = mass.nameSuffix ?? ' (Import)'; - const apiKeySuffix = mass.apiKeySuffix ?? 'import'; - const matchesType = - exportItemType.attributes.modular_block === - projectItemType.attributes.modular_block; - - let massSummary: JSX.Element | null = null; - - if (massStrategy === 'rename') { - massSummary = ( -
    - Global rename rule - This {exportType} will be renamed automatically using the suffix{' '} - {nameSuffix} and API key suffix {apiKeySuffix}. -
    - ); - } else if (massStrategy === 'reuseExisting') { - if (matchesType) { - massSummary = ( -
    - Global reuse rule - This {exportType} will reuse the existing {projectType} in your - project. -
    - ); - } else { - massSummary = ( -
    - Global reuse rule - This {exportType} can’t be reused because it conflicts with a{' '} - {projectType} already in your project. A new copy will be created - using the suffix {nameSuffix} and API key suffix{' '} - {apiKeySuffix}. -
    - ); - } - } - return ( {' '} ({projectItemType.attributes.api_key}).

    - {massSummary ? ( - massSummary - ) : ( + + {({ input, meta: { error } }) => ( + > + {...input} + id={selectId} + label="To resolve this conflict:" + selectInputProps={{ + options, + }} + value={ + options.find((option) => input.value === option.value) ?? null + } + onChange={(option) => input.onChange(option ? option.value : null)} + placeholder="Select..." + error={error} + /> + )} + + {resolution.values.strategy === 'rename' && ( <> - - {({ input, meta: { error } }) => ( - > - {...input} - id={selectId} - label="To resolve this conflict:" - selectInputProps={{ - options, - }} - value={ - options.find((option) => input.value === option.value) ?? null - } - onChange={(option) => - input.onChange(option ? option.value : null) - } - placeholder="Select..." - error={error} - /> - )} - - {resolution.values.strategy === 'rename' && ( - <> -
    - - {({ input, meta: { error } }) => ( - - )} - -
    -
    - - {({ input, meta: { error } }) => ( - - )} - -
    - - )} +
    + + {({ input, meta: { error } }) => ( + + )} + +
    +
    + + {({ input, meta: { error } }) => ( + + )} + +
    )}
    diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx index a1295f7a..179e5e77 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx @@ -4,10 +4,7 @@ import { SelectField } from 'datocms-react-ui'; import { useId } from 'react'; import { Field } from 'react-final-form'; import type { GroupBase } from 'react-select'; -import { - useMassStrategies, - useResolutionStatusForPlugin, -} from '../ResolutionsForm'; +import { useResolutionStatusForPlugin } from '../ResolutionsForm'; import Collapsible from './Collapsible'; type Option = { label: string; value: string }; @@ -30,31 +27,11 @@ export function PluginConflict({ exportPlugin, projectPlugin }: Props) { const fieldPrefix = `plugin-${exportPlugin.id}`; const resolution = useResolutionStatusForPlugin(exportPlugin.id)!; const node = useReactFlow().getNode(`plugin--${exportPlugin.id}`); - const mass = useMassStrategies(); if (!node) { return null; } - const massStrategy = mass.pluginsStrategy ?? null; - let massSummary: JSX.Element | null = null; - - if (massStrategy === 'reuseExisting') { - massSummary = ( -
    - Global plugin rule - This plugin will reuse the version already installed in this project. -
    - ); - } else if (massStrategy === 'skip') { - massSummary = ( -
    - Global plugin rule - This plugin will be skipped during import as per the global setting. -
    - ); - } - return ( {projectPlugin.attributes.name}.

    - {massSummary ? ( - massSummary - ) : ( - - {({ input, meta: { error } }) => ( - > - {...input} - id={selectId} - label="To resolve this conflict:" - selectInputProps={{ - options, - }} - value={ - options.find((option) => input.value === option.value) ?? null - } - onChange={(option) => - input.onChange(option ? option.value : null) - } - placeholder="Select..." - error={error} - /> - )} - - )} + + {({ input, meta: { error } }) => ( + > + {...input} + id={selectId} + label="To resolve this conflict:" + selectInputProps={{ + options, + }} + value={ + options.find((option) => input.value === option.value) ?? null + } + onChange={(option) => input.onChange(option ? option.value : null)} + placeholder="Select..." + error={error} + /> + )} +
    ); } diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx index 428270b9..f5cd6309 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx @@ -1,14 +1,9 @@ import type { SchemaTypes } from '@datocms/cma-client'; import type { RenderPageCtx } from 'datocms-plugin-sdk'; -import { Button, TextField } from 'datocms-react-ui'; +import { Button } from 'datocms-react-ui'; import { defaults, groupBy, map, mapValues, sortBy } from 'lodash-es'; -import { type ReactNode, useContext, useId, useMemo } from 'react'; -import { - type FieldMetaState, - Field, - useForm, - useFormState, -} from 'react-final-form'; +import { useContext, useMemo } from 'react'; +import { useFormState } from 'react-final-form'; import type { ExportSchema } from '@/entrypoints/ExportPage/ExportSchema'; import { getTextWithoutRepresentativeEmojiAndPadding } from '@/utils/emojiAgnosticSorter'; import type { ProjectSchema } from '@/utils/ProjectSchema'; @@ -23,132 +18,19 @@ type Props = { ctx?: RenderPageCtx; }; -function resolveFieldError(meta: FieldMetaState | undefined) { - if (!meta) { - return undefined; - } - - const message = meta.error || meta.submitError; - if (!message) { - return undefined; - } - - if (meta.touched || meta.submitFailed || meta.dirtySinceLastSubmit) { - return message; - } - - return undefined; -} - -type MassStrategyCardProps = { - checked: boolean; - children?: ReactNode; - description: string; - disabled?: boolean; - id: string; - label: string; - onToggle: (checked: boolean) => void; -}; - -function formatConflictCount(count: number) { - return `${count} conflict${count === 1 ? '' : 's'}`; -} - -type MassStrategySectionProps = { - children: ReactNode; - conflictCount: number; - summary: string; - title: string; -}; - -function MassStrategyCard({ - checked, - children, - description, - disabled, - id, - label, - onToggle, -}: MassStrategyCardProps) { - const classNames = ['mass-choice']; - if (checked) { - classNames.push('mass-choice--active'); - } - if (disabled) { - classNames.push('mass-choice--disabled'); - } - - return ( -
    -
    - onToggle(event.target.checked)} - disabled={disabled} - /> - -
    -

    {description}

    - {checked && children ? ( -
    {children}
    - ) : null} -
    - ); -} - -function MassStrategySection({ - children, - conflictCount, - summary, - title, -}: MassStrategySectionProps) { - const classNames = ['mass-section']; - if (conflictCount === 0) { - classNames.push('mass-section--calm'); - } - - return ( -
    -
    -
    - {title} - {summary} -
    - - {formatConflictCount(conflictCount)} - -
    -
    {children}
    -
    - ); -} export default function ConflictsManager({ exportSchema, schema: _schema, }: Props) { const conflicts = useContext(ConflictsContext); - const { submitting, valid, validating, values } = useFormState({ + const { submitting, valid, validating } = useFormState({ subscription: { submitting: true, valid: true, validating: true, - values: true, }, }); - const form = useForm(); - const nameSuffixId = useId(); - const apiKeySuffixId = useId(); - const massValues = values?.mass ?? {}; - const itemTypesStrategy = - (massValues.itemTypesStrategy as 'rename' | 'reuseExisting' | null) ?? null; - const pluginsStrategy = - (massValues.pluginsStrategy as 'reuseExisting' | 'skip' | null) ?? null; const groupedItemTypes = useMemo(() => { if (!conflicts) { @@ -214,8 +96,6 @@ export default function ConflictsManager({ const itemTypeConflictCount = groupedItemTypes.blocks.length + groupedItemTypes.models.length; const pluginConflictCount = sortedPlugins.length; - const canApplyItemTypeMass = itemTypeConflictCount > 0; - const canApplyPluginMass = pluginConflictCount > 0; const noPotentialConflicts = itemTypeConflictCount === 0 && pluginConflictCount === 0; @@ -228,222 +108,97 @@ export default function ConflictsManager({
    - - {!noPotentialConflicts && ( -
    -
    - - { - const nextValue = checked ? 'rename' : null; - form.change('mass.itemTypesStrategy', nextValue); - if (!checked) { - return; - } - if (!massValues.nameSuffix) { - form.change('mass.nameSuffix', ' (Import)'); - } - if (!massValues.apiKeySuffix) { - form.change('mass.apiKeySuffix', 'import'); - } - }} - > -
    - - {({ input, meta }) => ( - - )} - - - {({ input, meta }) => ( - - )} - -
    -

    - Unique combinations are generated automatically if a suffix - already exists in the project. -

    -
    - - form.change( - 'mass.itemTypesStrategy', - checked ? 'reuseExisting' : null, - ) - } - /> -
    - - - - form.change( - 'mass.pluginsStrategy', - checked ? 'reuseExisting' : null, - ) - } - /> - - form.change('mass.pluginsStrategy', checked ? 'skip' : null) - } - /> - -
    + {noPotentialConflicts ? ( +
    +

    + All set — no conflicting models, blocks, or plugins were found in + this import. +

    - )} - -
    - {groupedItemTypes.models.length > 0 && ( -
    -
    - Models ({groupedItemTypes.models.length}) + ) : ( +
    + {groupedItemTypes.models.length > 0 && ( +
    +
    + Models ({groupedItemTypes.models.length}) +
    +
    + {groupedItemTypes.models.map( + ({ + exportItemTypeId, + exportItemType, + projectItemType, + }: { + exportItemTypeId: string; + exportItemType: SchemaTypes.ItemType; + projectItemType: SchemaTypes.ItemType; + }) => ( + + ), + )} +
    -
    - {groupedItemTypes.models.map( - ({ - exportItemTypeId, - exportItemType, - projectItemType, - }: { - exportItemTypeId: string; - exportItemType: SchemaTypes.ItemType; - projectItemType: SchemaTypes.ItemType; - }) => ( - - ), - )} + )} + + {groupedItemTypes.blocks.length > 0 && ( +
    +
    + Block models ({groupedItemTypes.blocks.length}) +
    +
    + {groupedItemTypes.blocks.map( + ({ + exportItemTypeId, + exportItemType, + projectItemType, + }: { + exportItemTypeId: string; + exportItemType: SchemaTypes.ItemType; + projectItemType: SchemaTypes.ItemType; + }) => ( + + ), + )} +
    -
    - )} - - {groupedItemTypes.blocks.length > 0 && ( -
    -
    - Block models ({groupedItemTypes.blocks.length}) -
    -
    - {groupedItemTypes.blocks.map( - ({ - exportItemTypeId, - exportItemType, - projectItemType, - }: { - exportItemTypeId: string; - exportItemType: SchemaTypes.ItemType; - projectItemType: SchemaTypes.ItemType; - }) => ( - - ), - )} -
    -
    - )} - - {sortedPlugins.length > 0 && ( -
    -
    - Plugins ({sortedPlugins.length}) + )} + + {sortedPlugins.length > 0 && ( +
    +
    + Plugins ({sortedPlugins.length}) +
    +
    + {sortedPlugins.map( + ({ + exportPluginId, + exportPlugin, + projectPlugin, + }: { + exportPluginId: string; + exportPlugin: SchemaTypes.Plugin; + projectPlugin: SchemaTypes.Plugin; + }) => ( + + ), + )} +
    -
    - {sortedPlugins.map( - ({ - exportPluginId, - exportPlugin, - projectPlugin, - }: { - exportPluginId: string; - exportPlugin: SchemaTypes.Plugin; - projectPlugin: SchemaTypes.Plugin; - }) => ( - - ), - )} -
    -
    - )} -
    + )} +
    + )}
    {/** Precompute disabled state to attach tooltip when needed */} diff --git a/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx b/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx index b17643b1..af846b04 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx @@ -2,7 +2,6 @@ import { useNodes, useReactFlow } from '@xyflow/react'; import { get, keyBy, set } from 'lodash-es'; import { type ReactNode, useContext, useMemo } from 'react'; import { Form as FormHandler, useFormState } from 'react-final-form'; -import type { ItemTypeNode } from '@/components/ItemTypeNodeRenderer'; import type { ProjectSchema } from '@/utils/ProjectSchema'; import { ConflictsContext } from './ConflictsManager/ConflictsContext'; @@ -32,16 +31,7 @@ type ItemTypeValues = { }; type PluginValues = { strategy: 'reuseExisting' | 'skip' | null }; -export type MassValues = { - itemTypesStrategy?: 'reuseExisting' | 'rename' | null; - pluginsStrategy?: 'reuseExisting' | 'skip' | null; - nameSuffix?: string; - apiKeySuffix?: string; -}; - -type FormValues = Record & { - mass?: MassValues; -}; +type FormValues = Record; type Props = { children: ReactNode; @@ -87,12 +77,6 @@ export default function ResolutionsForm({ schema, children, onSubmit }: Props) { () => conflicts ? { - mass: { - itemTypesStrategy: null, - pluginsStrategy: null, - nameSuffix: ' (Import)', - apiKeySuffix: 'import', - }, ...Object.fromEntries( Object.keys(conflicts.plugins).map((id) => [ `plugin-${id}`, @@ -123,54 +107,16 @@ export default function ResolutionsForm({ schema, children, onSubmit }: Props) { return resolutions; } - const mass = values.mass; - // Preload project names/apiKeys once to guarantee uniqueness for mass-renames - const projectItemTypes = await schema.getAllItemTypes(); - const usedNames = new Set(projectItemTypes.map((it) => it.attributes.name)); - const usedApiKeys = new Set( - projectItemTypes.map((it) => it.attributes.api_key), - ); - - function computeUniqueRename( - baseName: string, - baseApiKey: string, - nameSuffix: string, - apiKeySuffix: string, - usedNames: Set, - usedApiKeys: Set, - ) { - let name = `${baseName}${nameSuffix}`; - let apiKey = `${baseApiKey}${apiKeySuffix}`; - let i = 2; - while (usedNames.has(name)) { - name = `${baseName}${nameSuffix} ${i}`; - i += 1; - } - i = 2; - while (usedApiKeys.has(apiKey)) { - apiKey = `${baseApiKey}${apiKeySuffix}${i}`; - i += 1; - } - usedNames.add(name); - usedApiKeys.add(apiKey); - return { name, apiKey }; - } - for (const pluginId of Object.keys(conflicts.plugins)) { if (!getNode(`plugin--${pluginId}`)) { continue; } - // Apply mass plugin strategy if set; otherwise use per-plugin selection - if (mass?.pluginsStrategy) { - resolutions.plugins[pluginId] = { strategy: mass.pluginsStrategy }; - } else { - const result = get(values, [`plugin-${pluginId}`]) as PluginValues; - if (result?.strategy) { - resolutions.plugins[pluginId] = { - strategy: result.strategy as 'reuseExisting' | 'skip', - }; - } + const result = get(values, [`plugin-${pluginId}`]) as PluginValues; + if (result?.strategy) { + resolutions.plugins[pluginId] = { + strategy: result.strategy as 'reuseExisting' | 'skip', + }; } } @@ -180,62 +126,17 @@ export default function ResolutionsForm({ schema, children, onSubmit }: Props) { continue; } - const exportItemType = (node.data as ItemTypeNode['data']) - .itemType as import('@datocms/cma-client').SchemaTypes.ItemType; - - if (mass?.itemTypesStrategy) { - if (mass.itemTypesStrategy === 'reuseExisting') { - // Reuse only when modular_block matches; otherwise mass-rename fallback with suffixes - const projectItemType = conflicts.itemTypes[itemTypeId]; - const compatible = - exportItemType.attributes.modular_block === - projectItemType.attributes.modular_block; - if (compatible) { - resolutions.itemTypes[itemTypeId] = { strategy: 'reuseExisting' }; - } else { - // Ensure unique names using suffixes - const { name, apiKey } = computeUniqueRename( - exportItemType.attributes.name, - exportItemType.attributes.api_key, - mass.nameSuffix || ' (Import)', - mass.apiKeySuffix || 'import', - usedNames, - usedApiKeys, - ); - resolutions.itemTypes[itemTypeId] = { - strategy: 'rename', - name, - apiKey, - }; - } - } else if (mass.itemTypesStrategy === 'rename') { - const { name, apiKey } = computeUniqueRename( - exportItemType.attributes.name, - exportItemType.attributes.api_key, - mass.nameSuffix || ' (Import)', - mass.apiKeySuffix || 'import', - usedNames, - usedApiKeys, - ); - resolutions.itemTypes[itemTypeId] = { - strategy: 'rename', - name, - apiKey, - }; - } - } else { - const fieldPrefix = `itemType-${itemTypeId}`; - const result = get(values, fieldPrefix) as ItemTypeValues; - - if (result?.strategy === 'reuseExisting') { - resolutions.itemTypes[itemTypeId] = { strategy: 'reuseExisting' }; - } else if (result?.strategy === 'rename') { - resolutions.itemTypes[itemTypeId] = { - strategy: 'rename', - apiKey: result.apiKey!, - name: result.name!, - }; - } + const fieldPrefix = `itemType-${itemTypeId}`; + const result = get(values, fieldPrefix) as ItemTypeValues; + + if (result?.strategy === 'reuseExisting') { + resolutions.itemTypes[itemTypeId] = { strategy: 'reuseExisting' }; + } else if (result?.strategy === 'rename') { + resolutions.itemTypes[itemTypeId] = { + strategy: 'rename', + apiKey: result.apiKey!, + name: result.name!, + }; } } @@ -260,18 +161,14 @@ export default function ResolutionsForm({ schema, children, onSubmit }: Props) { const itemTypesByName = keyBy(projectItemTypes, 'attributes.name'); const itemTypesByApiKey = keyBy(projectItemTypes, 'attributes.api_key'); - const mass = values.mass; - for (const pluginId of Object.keys(conflicts.plugins)) { if (!getNode(`plugin--${pluginId}`)) { continue; } const fieldPrefix = `plugin-${pluginId}`; - if (!mass?.pluginsStrategy) { - if (!get(values, [fieldPrefix, 'strategy'])) { - set(errors, [fieldPrefix, 'strategy'], 'Required!'); - } + if (!get(values, [fieldPrefix, 'strategy'])) { + set(errors, [fieldPrefix, 'strategy'], 'Required!'); } } @@ -281,45 +178,24 @@ export default function ResolutionsForm({ schema, children, onSubmit }: Props) { } const fieldPrefix = `itemType-${itemTypeId}`; - if (!mass?.itemTypesStrategy) { - const strategy = get(values, [fieldPrefix, 'strategy']); - if (!strategy) { - set(errors, [fieldPrefix, 'strategy'], 'Required!'); - } - if (strategy === 'rename') { - const name = get(values, [fieldPrefix, 'name']); - if (!name) { - set(errors, [fieldPrefix, 'name'], 'Required!'); - } else if (name in itemTypesByName) { - set(errors, [fieldPrefix, 'name'], 'Already used in project!'); - } - const apiKey = get(values, [fieldPrefix, 'apiKey']); - if (!apiKey) { - set(errors, [fieldPrefix, 'apiKey'], 'Required!'); - } else if (!isValidApiKey(apiKey)) { - set(errors, [fieldPrefix, 'apiKey'], 'Invalid format'); - } else if (apiKey in itemTypesByApiKey) { - set( - errors, - [fieldPrefix, 'apiKey'], - 'Already used in project!', - ); - } - } - } else if (mass.itemTypesStrategy === 'rename') { - // Validate mass suffixes - const nameSuffix = mass.nameSuffix || ' (Import)'; - const apiKeySuffix = mass.apiKeySuffix || 'import'; - // Basic validation of apiKeySuffix - if ( - !/^[a-z0-9_]+$/.test(apiKeySuffix) || - !/^[a-z]/.test(apiKeySuffix) || - !/[a-z0-9]$/.test(apiKeySuffix) - ) { - set(errors, ['mass', 'apiKeySuffix'], 'Invalid API key suffix'); + const strategy = get(values, [fieldPrefix, 'strategy']); + if (!strategy) { + set(errors, [fieldPrefix, 'strategy'], 'Required!'); + } + if (strategy === 'rename') { + const name = get(values, [fieldPrefix, 'name']); + if (!name) { + set(errors, [fieldPrefix, 'name'], 'Required!'); + } else if (name in itemTypesByName) { + set(errors, [fieldPrefix, 'name'], 'Already used in project!'); } - if (nameSuffix === undefined) { - set(errors, ['mass', 'nameSuffix'], 'Name suffix required'); + const apiKey = get(values, [fieldPrefix, 'apiKey']); + if (!apiKey) { + set(errors, [fieldPrefix, 'apiKey'], 'Required!'); + } else if (!isValidApiKey(apiKey)) { + set(errors, [fieldPrefix, 'apiKey'], 'Invalid format'); + } else if (apiKey in itemTypesByApiKey) { + set(errors, [fieldPrefix, 'apiKey'], 'Already used in project!'); } } } @@ -395,11 +271,3 @@ export function useSkippedItemsAndPluginIds() { return { skippedItemTypeIds, skippedPluginIds }; } - -export function useMassStrategies() { - const state = useFormState({ - subscription: { values: true }, - }); - - return state.values?.mass ?? {}; -} diff --git a/import-export-schema/src/index.css b/import-export-schema/src/index.css index 270caf71..429307c3 100644 --- a/import-export-schema/src/index.css +++ b/import-export-schema/src/index.css @@ -971,166 +971,6 @@ button.chip:focus-visible { /* Slightly tighter padding for redesigned conflicts UI */ } -/* Conflicts manager mass actions */ -.conflicts-setup { - border: none; - border-radius: 16px; - background: #fff; - padding: var(--spacing-m); - margin: calc(var(--spacing-m) * 0.75) 0; - box-shadow: 0 12px 34px -26px rgba(15, 23, 42, 0.28); -} - -.mass-strategy-grid { - display: grid; - grid-template-columns: 1fr; - gap: var(--spacing-m); -} - - - - -.mass-section { - display: grid; - gap: var(--spacing-sm); - padding: calc(var(--spacing-m) - 2px); - border: 1px solid color-mix(in srgb, var(--border-color), transparent 65%); - border-radius: 14px; - background: #fff; - width: 100%; -} - -.mass-section--calm { - border-color: color-mix(in srgb, var(--border-color), transparent 80%); -} - -.mass-section__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-sm); -} - -.mass-section__header-copy { - display: grid; - gap: 2px; -} - -.mass-section__title { - font-weight: 600; - font-size: var(--font-size-m); -} - -.mass-section__summary { - color: var(--light-body-color); - font-size: var(--font-size-s); - line-height: 1.35; -} - -.mass-section__badge { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 2px 10px; - border-radius: 999px; - border: 1px solid color-mix(in srgb, var(--accent-color), white 50%); - font-size: 12px; - font-weight: 600; - line-height: 1; - color: var(--accent-color); - background: color-mix(in srgb, var(--accent-color), white 95%); - flex-shrink: 0; - white-space: nowrap; -} - -.mass-section__actions { - display: grid; - gap: var(--spacing-sm); -} - -.mass-choice { - border: 1px solid color-mix(in srgb, var(--border-color), transparent 55%); - border-radius: 12px; - padding: 12px 16px; - background: #fff; - display: grid; - grid-template-columns: auto 1fr; - column-gap: 12px; - row-gap: 4px; - align-items: flex-start; - transition: border-color 0.2s ease, box-shadow 0.2s ease, - background-color 0.2s ease; -} - -.mass-choice:hover { - border-color: color-mix(in srgb, var(--accent-color), white 70%); -} - -.mass-choice--active { - border-color: color-mix(in srgb, var(--accent-color), white 30%); - background: color-mix(in srgb, var(--accent-color), white 96%); - box-shadow: 0 6px 16px -14px rgba(17, 24, 39, 0.32); -} - -.mass-choice--disabled { - opacity: 0.6; - pointer-events: none; -} - -.mass-choice__header { - display: contents; -} - -.mass-choice__checkbox { - width: 16px; - height: 16px; - accent-color: var(--accent-color); - margin-top: 2px; -} - -.mass-choice__label { - cursor: pointer; - font-weight: 600; - font-size: var(--font-size-s); - color: var(--base-body-color); - display: inline-flex; - align-items: center; - gap: var(--spacing-xs); - grid-column: 2; -} - -.mass-choice__label-text { - display: block; -} - -.mass-choice__description { - margin: 0; - font-size: var(--font-size-s); - color: var(--light-body-color); - grid-column: 2; - line-height: 1.35; -} - -.mass-choice__details { - display: grid; - gap: var(--spacing-xs); - padding-top: var(--spacing-xs); - border-top: 1px solid color-mix(in srgb, var(--border-color), transparent 45%); - grid-column: 2; -} - -.mass-choice__grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(152px, 1fr)); - gap: var(--spacing-xs); -} - -.mass-choice__hint { - margin: 0; - font-size: 12px; - color: var(--light-body-color); -} - .conflict { border-bottom: 1px solid var(--border-color); @@ -1163,23 +1003,6 @@ button.chip:focus-visible { padding: 12px 16px; /* reduce inner padding for a tighter look */ } -.conflict__mass-rule { - margin-top: 12px; - padding: 12px 14px; - border-radius: 10px; - border: 1px solid color-mix(in srgb, var(--border-color), transparent 35%); - background: color-mix(in srgb, #fefefe, var(--border-color) 12%); - font-size: var(--font-size-s); - line-height: 1.5; - color: var(--base-body-color); -} - -.conflict__mass-rule strong { - display: block; - margin-bottom: 4px; - color: var(--accent-color); -} - /* Pretty export overlay styles */ .export-overlay__card { background: #fff; From db508ba5725be16da4c198dc926e69a2bd66c6cf Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Thu, 18 Sep 2025 00:51:55 +0200 Subject: [PATCH 06/36] refactor --- import-export-schema/AGENTS.md | 47 + import-export-schema/README.md | 11 + .../docs/refactor-baseline.md | 61 ++ import-export-schema/package-lock.json | 383 +------- import-export-schema/package.json | 11 +- .../src/components/ExportStartPanel.tsx | 125 +++ .../src/components/ProgressOverlay.tsx | 125 +++ .../src/components/TaskProgressOverlay.tsx | 63 ++ .../src/entrypoints/ExportHome/index.tsx | 619 +++--------- .../src/entrypoints/ExportPage/Inner.tsx | 72 +- .../ExportPage/buildGraphFromSchema.ts | 9 +- .../src/entrypoints/ExportPage/index.tsx | 274 ++---- .../ExportPage/useAnimatedNodes.tsx | 50 +- .../entrypoints/ExportPage/useExportGraph.ts | 92 ++ .../ConflictsManager/ItemTypeConflict.tsx | 7 +- .../ConflictsManager/PluginConflict.tsx | 7 +- .../src/entrypoints/ImportPage/index.tsx | 901 ++++-------------- import-export-schema/src/index.css | 20 + .../src/shared/hooks/useCmaClient.ts | 29 + .../src/shared/hooks/useConflictsBuilder.ts | 53 ++ .../src/shared/hooks/useExportAllHandler.ts | 82 ++ .../src/shared/hooks/useExportSelection.ts | 113 +++ .../src/shared/hooks/useProjectSchema.ts | 36 + .../src/shared/tasks/useLongTask.ts | 120 +++ .../src/utils/graph/buildGraph.ts | 9 +- import-export-schema/src/utils/graph/index.ts | 9 + import-export-schema/src/utils/graph/types.ts | 7 + import-export-schema/tsconfig.app.tsbuildinfo | 2 +- 28 files changed, 1435 insertions(+), 1902 deletions(-) create mode 100644 import-export-schema/docs/refactor-baseline.md create mode 100644 import-export-schema/src/components/ExportStartPanel.tsx create mode 100644 import-export-schema/src/components/ProgressOverlay.tsx create mode 100644 import-export-schema/src/components/TaskProgressOverlay.tsx create mode 100644 import-export-schema/src/entrypoints/ExportPage/useExportGraph.ts create mode 100644 import-export-schema/src/shared/hooks/useCmaClient.ts create mode 100644 import-export-schema/src/shared/hooks/useConflictsBuilder.ts create mode 100644 import-export-schema/src/shared/hooks/useExportAllHandler.ts create mode 100644 import-export-schema/src/shared/hooks/useExportSelection.ts create mode 100644 import-export-schema/src/shared/hooks/useProjectSchema.ts create mode 100644 import-export-schema/src/shared/tasks/useLongTask.ts create mode 100644 import-export-schema/src/utils/graph/index.ts diff --git a/import-export-schema/AGENTS.md b/import-export-schema/AGENTS.md index 0d380c6d..6418babf 100644 --- a/import-export-schema/AGENTS.md +++ b/import-export-schema/AGENTS.md @@ -26,3 +26,50 @@ I want to make a refactor to make this whole code way smaller, as DRY as possibl - Avoid mutating existing schema objects; make additive, safe changes. - Do not commit secrets or personal access tokens. Review diffs for sensitive data. +# Refactor Roadmap (Sept 2025) + +## Stage Overview +1. Baseline & Safeguards + - Capture current UX (screenshots, flows, manual QA list). + - Ensure `npm run build` passes; note regressions. +2. Shared Infrastructure Layer + - Hooks for CMA/schema access and async state. + - Shared progress/task utilities and contexts. +3. UI Composition Cleanup + - Shared layout, blank-slate, and selector components. + - Replace inline overlay markup with reusable components. +4. Export Workflow Refactor + - Consolidate ExportHome/ExportPage logic via hooks. + - Break `Inner` into smaller focused components/utilities. +5. Import Workflow Refactor + - Mirror export improvements; simplify conflicts UI. +6. Graph Utilities Consolidation + - Centralize analysis helpers; document graph contracts. +7. Styling Rationalization + - Move inline styles to CSS modules or tokens. + - Normalize color/spacing variables. +8. Types & Utilities Cleanup + - Strengthen progress/event types; remove duplication. +9. Validation & Documentation + - Run build + manual QA per stage; update README/notes. + +## Active Checklist +- [x] Stage 0: Baseline docs + QA scenarios recorded +- [x] Stage 1: Shared infrastructure primitives extracted +- [x] Stage 2: Common UI components introduced +- [x] Stage 3: Export workflow streamlined +- [x] Stage 4: Import workflow streamlined +- [x] Stage 5: Graph utilities consolidated +- [x] Stage 6: Styling centralized +- [x] Stage 7: Type/util cleanup complete +- [x] Stage 8: Validation + docs refreshed + +## Worklog +- 2025-09-17: Created refactor roadmap, documented baseline QA in `docs/refactor-baseline.md`, introduced shared hooks (`useCmaClient`, `useProjectSchema`) plus long-task controller (`useLongTask`), and updated export/import entrypoints to rely on the shared schema hook. +- 2025-09-17: Replaced bespoke busy/progress state in ExportHome, ExportPage, and ImportPage with `useLongTask`, unified cancel handling, and refreshed overlays to read from shared controllers (build verified). +- 2025-09-17: Added reusable `ProgressOverlay`, `ExportStartPanel`, and `useExportAllHandler` to DRY export/import entrypoints; refactored `ExportPage/Inner` to use `useExportGraph` for graph prep. +- 2025-09-17: Streamlined import/export panels via shared hooks (`useExportAllHandler`, `useConflictsBuilder`) and components, leaving ImportPage/ExportHome with leaner startup flows. +- 2025-09-17: Centralized graph progress wiring via `useExportGraph`, shared `SchemaProgressUpdate` type, and index exports for graph utilities. +- 2025-09-17: Introduced `ProgressOverlay` styling tokens (`--overlay-gradient`, `.progress-overlay`) and DRY `ExportStartPanel`, eliminating repeated inline overlay/selector styles. +- 2025-09-17: Refreshed README + docs with new shared infrastructure and updated baseline observations; build validated via `npm run build`. +- 2025-09-17: Added reusable progress types (`SchemaProgressUpdate`, `LongTaskProgress`) and shared hooks (`useConflictsBuilder`, `useExportGraph`) to remove duplicated state management and tighten typing across workflows. diff --git a/import-export-schema/README.md b/import-export-schema/README.md index 0ef2e40b..e98c8a72 100644 --- a/import-export-schema/README.md +++ b/import-export-schema/README.md @@ -69,6 +69,17 @@ Powerful, safe schema migration for DatoCMS. Export models/blocks and plugins as - Appearance portability: if an editor plugin is not selected, that field falls back to a valid built‑in editor; addons are included only if selected or already installed. - Rate limiting: long operations show a gentle notice if progress stalls; they usually resume automatically. You can cancel exports/imports at any time. +## Development Notes + +- Shared hooks: + - `useProjectSchema` memoizes CMA access per context. + - `useLongTask` drives all long-running progress overlays. + - `useExportGraph`, `useExportAllHandler`, and `useConflictsBuilder` encapsulate schema loading logic. +- Shared UI: + - `ProgressOverlay` renders the full-screen overlay with accessible ARIA props and cancel handling. + - `ExportStartPanel` powers the initial export selector in both ExportHome and ImportPage. +- Graph utilities expose a single entry point (`@/utils/graph`) with `SchemaProgressUpdate` progress typing. + ## Export File Format - Version 2 (current): `{ version: '2', rootItemTypeId, entities: […] }` — preserves the explicit root model/block used to seed the export, to re-generate the export graph deterministically. diff --git a/import-export-schema/docs/refactor-baseline.md b/import-export-schema/docs/refactor-baseline.md new file mode 100644 index 00000000..3963ba16 --- /dev/null +++ b/import-export-schema/docs/refactor-baseline.md @@ -0,0 +1,61 @@ +# Refactor Baseline (September 17, 2025) + +## Current Critical Flows + +### Export: Start From Selection +- Launch Export page with no preselected item type. +- Select one or more models/blocks via multiselect. +- Press `Start export` and wait for graph to render. +- Toggle dependency selection; confirm auto-selection adds linked models/plugins. +- Export selection; expect download named `export.json` and success toast. + +### Export: From Schema Dropdown +- From schema dropdown action (`Export as JSON...`) load `ExportPage` with initial item type. +- Confirm overlay progresses through scan/build phases and hides when graph ready. +- Export without modifying selection; ensure download + toast. +- Trigger cancel during export; verify notice and overlay update. + +### Export: Entire Schema +- On Export landing, choose `Export entire current schema`. +- Confirm confirmation dialog text. +- Ensure overlay tracks progress and cancel immediately hides overlay while still cancelling. +- Validate success toast when done, or graceful alert if schema empty. + +### Import: File Upload Flow +- Drop valid export file; spinner shows while parsing. +- Conflicts list populates with models/blocks/plugins grouped and sorted. +- Adjust resolutions (reuse/rename) and submit. +- Import progress overlay updates counts and finishes with success toast. + +### Import: Recipe URL Parameters +- Open Import page with `?recipe_url=...` query parameters. +- Verify remote JSON fetch, fallback name assignment, and conflict build once loaded. +- Cancel import via bottom action; ensure confirmation dialog resets state. + +### Import: Cancel During Import +- Start import and trigger cancel; confirm warning dialog and partial state handling. +- Verify overlay message switches to "Stopping" label while waiting. + +### Import: Export Tab Within Import Page +- Switch to Export tab, select models/blocks, run export. +- Ensure shared overlays behave like Export page variant. +- Confirm back navigation keeps selections when returning. + +## Manual QA Checklist +- [x] `npm run build` succeeds (baseline). +- [ ] Export: Start from selection flow works (selection, dependency toggle, download). +- [ ] Export: Schema dropdown entry works (overlay, cancel path). +- [ ] Export: Entire schema exports all models/plugins without crash. +- [ ] Import: Upload flow handles conflicts and completes import (test with sandbox project). +- [ ] Import: Recipe URL auto-load works and sets title. +- [ ] Import: Cancel during import stops gracefully without crashing. +- [ ] Import Page Export tab mirrors main export flow. + +## Observations / Known Debt +- Dependency auto-selection logic in `ExportInner` remains complex; consider extracting into a dedicated hook with tests. +- Graph QA still manual; adding smoke tests for `useExportGraph` and `useConflictsBuilder` would improve confidence. +- Global CSS (`index.css`) still houses most styles; future work could migrate node/toolbar styling to CSS modules. + +## Next Steps +- Execute manual QA checklist before release (export flows, import flows, cancel paths). +- Update component docs/readme snippets if any UX tweaks occur during QA. diff --git a/import-export-schema/package-lock.json b/import-export-schema/package-lock.json index f3fd4294..74be9bf8 100644 --- a/import-export-schema/package-lock.json +++ b/import-export-schema/package-lock.json @@ -9,8 +9,6 @@ "version": "0.1.15", "dependencies": { "@datocms/cma-client": "^3.4.5", - "@datocms/cma-client-browser": "^3.4.5", - "@datocms/rest-api-events": "^3.4.3", "@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", @@ -18,7 +16,6 @@ "@xyflow/react": "^12.3.6", "classnames": "^2.5.1", "d3-hierarchy": "^3.1.2", - "d3-timer": "^3.0.1", "datocms-plugin-sdk": "^2.0.13", "datocms-react-ui": "^2.0.13", "emoji-regex": "^10.4.0", @@ -26,19 +23,15 @@ "lodash-es": "^4.17.21", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-final-form": "^6.5.9", - "ts-easing": "^0.2.0" + "react-final-form": "^6.5.9" }, "devDependencies": { "@biomejs/biome": "^2.2.0", - "@types/d3-timer": "^3.0.2", "@types/node": "^22.13.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", - "globals": "^15.9.0", "postcss-preset-env": "^9.6.0", - "rollup-plugin-visualizer": "^5.12.0", "typescript": "^5.5.3", "vite": "^5.4.1", "vite-plugin-svgr": "^4.3.0" @@ -1564,25 +1557,6 @@ "uuid": "^9.0.1" } }, - "node_modules/@datocms/cma-client-browser": { - "version": "3.4.5", - "resolved": "https://registry.npmjs.org/@datocms/cma-client-browser/-/cma-client-browser-3.4.5.tgz", - "integrity": "sha512-z0Z3bXgpVshLKxtfcRoZzYzhKj0cI3+KNytXB1XG5sJ3nBwfAhGuBY3QLSHtOTPh44wCNox0Kw9TXaOfggJXXA==", - "license": "MIT", - "dependencies": { - "@datocms/cma-client": "^3.4.5", - "@datocms/rest-client-utils": "^3.4.2" - } - }, - "node_modules/@datocms/rest-api-events": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/@datocms/rest-api-events/-/rest-api-events-3.4.3.tgz", - "integrity": "sha512-F1zz0Pj1JqVIDjgl/zzNJ8zyFZ/Yjyhi4INeP0XsnrHfBy/pHff075iGTvNBXjMHzbP2DU8rR9WeNfXPCUTsLg==", - "license": "MIT", - "dependencies": { - "pusher-js": "^7.0.6" - } - }, "node_modules/@datocms/rest-client-utils": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/@datocms/rest-client-utils/-/rest-client-utils-3.4.2.tgz", @@ -2844,13 +2818,6 @@ "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", "license": "MIT" }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/d3-transition": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", @@ -2877,17 +2844,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/express-serve-static-core": { - "version": "4.17.28", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz", - "integrity": "sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*" - } - }, "node_modules/@types/gensync": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.4.tgz", @@ -2899,6 +2855,7 @@ "version": "22.13.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz", "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.20.0" @@ -2916,18 +2873,6 @@ "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", "license": "MIT" }, - "node_modules/@types/qs": { - "version": "6.9.18", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", - "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "license": "MIT" - }, "node_modules/@types/react": { "version": "18.3.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", @@ -3013,32 +2958,6 @@ "d3-zoom": "^3.0.0" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3199,41 +3118,6 @@ "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", "license": "MIT" }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/compute-scroll-into-view": { "version": "1.0.20", "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", @@ -3552,16 +3436,6 @@ } } }, - "node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -3756,29 +3630,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/globals": { - "version": "15.14.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz", - "integrity": "sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -3837,45 +3688,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4038,24 +3850,6 @@ "node": ">=0.10.0" } }, - "node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4884,23 +4678,6 @@ "react-is": "^16.13.1" } }, - "node_modules/pusher-js": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-7.6.0.tgz", - "integrity": "sha512-5CJ7YN5ZdC24E0ETraCU5VYFv0IY5ziXhrS0gS5+9Qrro1E4M1lcZhtr9H1H+6jNSLj1LKKAgcLeE1EH9GxMlw==", - "license": "MIT", - "dependencies": { - "@types/express-serve-static-core": "4.17.28", - "@types/node": "^14.14.31", - "tweetnacl": "^1.0.3" - } - }, - "node_modules/pusher-js/node_modules/@types/node": { - "version": "14.18.63", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", - "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", - "license": "MIT" - }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -5011,16 +4788,6 @@ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", "license": "MIT" }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -5089,47 +4856,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/rollup-plugin-visualizer": { - "version": "5.14.0", - "resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-5.14.0.tgz", - "integrity": "sha512-VlDXneTDaKsHIw8yzJAFWtrzguoJ/LnQ+lMpoVfYJ3jJF4Ihe5oYLAqLklIK/35lgUY+1yEzCkHyZ1j4A5w5fA==", - "dev": true, - "license": "MIT", - "dependencies": { - "open": "^8.4.0", - "picomatch": "^4.0.2", - "source-map": "^0.7.4", - "yargs": "^17.5.1" - }, - "bin": { - "rollup-plugin-visualizer": "dist/bin/cli.js" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "rolldown": "1.x", - "rollup": "2.x || 3.x || 4.x" - }, - "peerDependenciesMeta": { - "rolldown": { - "optional": true - }, - "rollup": { - "optional": true - } - } - }, - "node_modules/rollup-plugin-visualizer/node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 12" - } - }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -5188,41 +4914,6 @@ "node": ">=0.10.0" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -5248,12 +4939,6 @@ "dev": true, "license": "MIT" }, - "node_modules/ts-easing": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/ts-easing/-/ts-easing-0.2.0.tgz", - "integrity": "sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==", - "license": "Unlicense" - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5261,12 +4946,6 @@ "dev": true, "license": "0BSD" }, - "node_modules/tweetnacl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", - "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", - "license": "Unlicense" - }, "node_modules/typescript": { "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", @@ -5285,6 +4964,7 @@ "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -5436,34 +5116,6 @@ "vite": ">=2.6.0" } }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -5480,35 +5132,6 @@ "node": ">= 6" } }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/zustand": { "version": "4.5.6", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.6.tgz", diff --git a/import-export-schema/package.json b/import-export-schema/package.json index 31bb3f14..d73d6d67 100644 --- a/import-export-schema/package.json +++ b/import-export-schema/package.json @@ -32,8 +32,6 @@ }, "dependencies": { "@datocms/cma-client": "^3.4.5", - "@datocms/cma-client-browser": "^3.4.5", - "@datocms/rest-api-events": "^3.4.3", "@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", @@ -41,7 +39,6 @@ "@xyflow/react": "^12.3.6", "classnames": "^2.5.1", "d3-hierarchy": "^3.1.2", - "d3-timer": "^3.0.1", "datocms-plugin-sdk": "^2.0.13", "datocms-react-ui": "^2.0.13", "emoji-regex": "^10.4.0", @@ -49,21 +46,17 @@ "lodash-es": "^4.17.21", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-final-form": "^6.5.9", - "ts-easing": "^0.2.0" + "react-final-form": "^6.5.9" }, "devDependencies": { "@biomejs/biome": "^2.2.0", - "@types/d3-timer": "^3.0.2", "@types/node": "^22.13.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", - "globals": "^15.9.0", "postcss-preset-env": "^9.6.0", "typescript": "^5.5.3", "vite": "^5.4.1", - "vite-plugin-svgr": "^4.3.0", - "rollup-plugin-visualizer": "^5.12.0" + "vite-plugin-svgr": "^4.3.0" } } diff --git a/import-export-schema/src/components/ExportStartPanel.tsx b/import-export-schema/src/components/ExportStartPanel.tsx new file mode 100644 index 00000000..aaa2dfdd --- /dev/null +++ b/import-export-schema/src/components/ExportStartPanel.tsx @@ -0,0 +1,125 @@ +import type { SchemaTypes } from '@datocms/cma-client'; +import { Button, SelectField } from 'datocms-react-ui'; +import { useMemo } from 'react'; + +type MultiOption = { label: string; value: string }; +type SelectGroup = { + label?: string; + options: readonly OptionType[]; +}; + +type Props = { + selectId: string; + itemTypes?: SchemaTypes.ItemType[]; + selectedIds: string[]; + onSelectedIdsChange: (ids: string[]) => void; + onSelectAllModels: () => void; + onSelectAllBlocks: () => void; + onStart: () => void; + startDisabled: boolean; + onExportAll: () => void | Promise; + exportAllDisabled: boolean; + title?: string; + description?: string; + footerHint?: string; + selectLabel?: string; + startLabel?: string; + exportAllLabel?: string; +}; + +export function ExportStartPanel({ + selectId, + itemTypes, + selectedIds, + onSelectedIdsChange, + onSelectAllModels, + onSelectAllBlocks, + onStart, + startDisabled, + onExportAll, + exportAllDisabled, + title = 'Start a new export', + description = 'Select one or more models/blocks to start selecting what to export.', + footerHint, + selectLabel = 'Starting models/blocks', + startLabel = 'Start export', + exportAllLabel = 'Export entire current schema', +}: Props) { + const options = useMemo( + () => + (itemTypes ?? []).map((it) => ({ + value: it.id, + label: `${it.attributes.name}${ + it.attributes.modular_block ? ' (Block)' : '' + }`, + })), + [itemTypes], + ); + + const value = useMemo( + () => options.filter((opt) => selectedIds.includes(opt.value)), + [options, selectedIds], + ); + + return ( +
    +
    {title}
    +
    +

    {description}

    +
    +
    + > + id={selectId} + name="export-initial-model" + label={selectLabel} + selectInputProps={{ + isMulti: true, + isClearable: true, + isDisabled: !itemTypes, + options, + placeholder: 'Choose models/blocks…', + }} + value={value} + onChange={(multi) => + onSelectedIdsChange(Array.isArray(multi) ? multi.map((o) => o.value) : []) + } + /> +
    +
    + + +
    +
    + +
    +
    + +
    +
    +
    + {footerHint ? ( +
    {footerHint}
    + ) : null} +
    + ); +} diff --git a/import-export-schema/src/components/ProgressOverlay.tsx b/import-export-schema/src/components/ProgressOverlay.tsx new file mode 100644 index 00000000..e227ad4f --- /dev/null +++ b/import-export-schema/src/components/ProgressOverlay.tsx @@ -0,0 +1,125 @@ +import type { PropsWithChildren } from 'react'; +import { Button } from 'datocms-react-ui'; +import ProgressStallNotice from './ProgressStallNotice'; + +type CancelProps = { + label: string; + intent?: 'negative' | 'muted'; + disabled?: boolean; + onCancel: () => void; + size?: 's' | 'm'; +}; + +type ProgressData = { + label?: string; + done?: number; + total?: number; + percentOverride?: number; +}; + +type Props = { + title: string; + subtitle?: string; + progress: ProgressData; + stallCurrent?: number | undefined; + ariaLabel: string; + cancel?: CancelProps; + overlayZIndex?: number; +}; + +function clampPercent(value: number | undefined): number { + if (typeof value !== 'number' || Number.isNaN(value)) return 0.1; + return Math.min(1, Math.max(0, value)); +} + +function resolvePercent(progress: ProgressData): number { + if (typeof progress.percentOverride === 'number') { + return clampPercent(progress.percentOverride); + } + if ( + typeof progress.done === 'number' && + typeof progress.total === 'number' && + progress.total > 0 + ) { + return clampPercent(progress.done / progress.total); + } + return 0.1; +} + +export function ProgressOverlay({ + title, + subtitle, + progress, + stallCurrent, + ariaLabel, + cancel, + overlayZIndex = 9999, +}: PropsWithChildren) { + const percent = resolvePercent(progress); + const totalText = + typeof progress.total === 'number' && progress.total > 0 + ? `${progress.done ?? 0} / ${progress.total}` + : ''; + + return ( +
    +
    +
    {title}
    + {subtitle ? ( +
    {subtitle}
    + ) : null} + +
    0 + ? progress.total + : undefined + } + aria-valuenow={ + typeof progress.total === 'number' && progress.total > 0 + ? progress.done + : undefined + } + > +
    +
    +
    +
    {progress.label ?? ''}
    +
    {totalText}
    +
    + + {cancel ? ( +
    + +
    + ) : null} +
    +
    + ); +} diff --git a/import-export-schema/src/components/TaskProgressOverlay.tsx b/import-export-schema/src/components/TaskProgressOverlay.tsx new file mode 100644 index 00000000..9e3f4c09 --- /dev/null +++ b/import-export-schema/src/components/TaskProgressOverlay.tsx @@ -0,0 +1,63 @@ +import { ProgressOverlay } from '@/components/ProgressOverlay'; +import type { + LongTaskProgress, + LongTaskState, + UseLongTaskResult, +} from '@/shared/tasks/useLongTask'; + +type CancelOptions = { + label: string; + onCancel: () => void | Promise; + intent?: 'negative' | 'muted'; + disabled?: boolean; +}; + +type TaskProgressOverlayProps = { + task: UseLongTaskResult; + title: string; + subtitle: string | ((state: LongTaskState) => string); + ariaLabel: string; + overlayZIndex?: number; + progressLabel?: (progress: LongTaskProgress, state: LongTaskState) => string; + percentOverride?: number; + cancel?: (state: LongTaskState) => CancelOptions | undefined; +}; + +export function TaskProgressOverlay({ + task, + title, + subtitle, + ariaLabel, + overlayZIndex, + progressLabel, + percentOverride, + cancel, +}: TaskProgressOverlayProps) { + if (task.state.status !== 'running') { + return null; + } + + const state = task.state; + const progress = state.progress; + const resolvedSubtitle = + typeof subtitle === 'function' ? subtitle(state) : subtitle; + const label = progressLabel ? progressLabel(progress, state) : progress.label; + const cancelProps = cancel?.(state); + + return ( + + ); +} diff --git a/import-export-schema/src/entrypoints/ExportHome/index.tsx b/import-export-schema/src/entrypoints/ExportHome/index.tsx index 73d553e5..54ff5c94 100644 --- a/import-export-schema/src/entrypoints/ExportHome/index.tsx +++ b/import-export-schema/src/entrypoints/ExportHome/index.tsx @@ -1,13 +1,14 @@ -import type { SchemaTypes } from '@datocms/cma-client'; import { ReactFlowProvider } from '@xyflow/react'; import type { RenderPageCtx } from 'datocms-plugin-sdk'; -import { Button, Canvas, SelectField } from 'datocms-react-ui'; -import { useEffect, useId, useMemo, useRef, useState } from 'react'; -import type { GroupBase } from 'react-select'; -import ProgressStallNotice from '@/components/ProgressStallNotice'; -import { createCmaClient } from '@/utils/createCmaClient'; +import { Canvas } from 'datocms-react-ui'; +import { useId, useState } from 'react'; +import { ExportStartPanel } from '@/components/ExportStartPanel'; +import { TaskProgressOverlay } from '@/components/TaskProgressOverlay'; +import { useExportSelection } from '@/shared/hooks/useExportSelection'; +import { useProjectSchema } from '@/shared/hooks/useProjectSchema'; +import { useExportAllHandler } from '@/shared/hooks/useExportAllHandler'; +import { useLongTask } from '@/shared/tasks/useLongTask'; import { downloadJSON } from '@/utils/downloadJson'; -import { ProjectSchema } from '@/utils/ProjectSchema'; import buildExportDoc from '../ExportPage/buildExportDoc'; import ExportInner from '../ExportPage/Inner'; @@ -17,71 +18,42 @@ type Props = { export default function ExportHome({ ctx }: Props) { const exportInitialSelectId = useId(); - const client = useMemo( - () => createCmaClient(ctx), - [ctx.currentUserAccessToken, ctx.environment], - ); - - const projectSchema = useMemo(() => new ProjectSchema(client), [client]); + const projectSchema = useProjectSchema(ctx); // adminDomain and post-export overview removed; we download and toast only - const [allItemTypes, setAllItemTypes] = useState< - SchemaTypes.ItemType[] | undefined - >(undefined); - useEffect(() => { - async function load() { - const types = await projectSchema.getAllItemTypes(); - setAllItemTypes(types); - } - load(); - }, [projectSchema]); - - const [exportInitialItemTypeIds, setExportInitialItemTypeIds] = useState< - string[] - >([]); - const [exportInitialItemTypes, setExportInitialItemTypes] = useState< - SchemaTypes.ItemType[] - >([]); - useEffect(() => { - async function resolveInitial() { - if (!exportInitialItemTypeIds.length) { - setExportInitialItemTypes([]); - return; - } - const list: SchemaTypes.ItemType[] = []; - for (const id of exportInitialItemTypeIds) { - list.push(await projectSchema.getItemTypeById(id)); - } - setExportInitialItemTypes(list); - } - resolveInitial(); - // using join to keep deps simple - }, [exportInitialItemTypeIds.join('-'), projectSchema]); + const { + allItemTypes, + selectedIds: exportInitialItemTypeIds, + selectedItemTypes: exportInitialItemTypes, + setSelectedIds: setExportInitialItemTypeIds, + selectAllModels: handleSelectAllModels, + selectAllBlocks: handleSelectAllBlocks, + } = useExportSelection({ schema: projectSchema }); const [exportStarted, setExportStarted] = useState(false); - const [exportAllBusy, setExportAllBusy] = useState(false); - const [exportAllProgress, setExportAllProgress] = useState< - { done: number; total: number; label: string } | undefined - >(undefined); - const [exportAllCancelled, setExportAllCancelled] = useState(false); - const exportAllCancelRef = useRef(false); + const exportAllTask = useLongTask(); + const exportPreparingTask = useLongTask(); + const exportSelectionTask = useLongTask(); - const [exportPreparingBusy, setExportPreparingBusy] = useState(false); - const [exportPreparingProgress, setExportPreparingProgress] = useState< - { done: number; total: number; label: string } | undefined - >(undefined); // Smoothed percent for preparing overlay to avoid jitter and changing max const [exportPreparingPercent, setExportPreparingPercent] = useState(0.1); - const [exportSelectionBusy, setExportSelectionBusy] = useState(false); - const [exportSelectionProgress, setExportSelectionProgress] = useState< - { done: number; total: number; label: string } | undefined - >(undefined); - const [exportSelectionCancelled, setExportSelectionCancelled] = - useState(false); - const exportSelectionCancelRef = useRef(false); + const runExportAll = useExportAllHandler({ + ctx, + schema: projectSchema, + task: exportAllTask.controller, + }); + + const handleStartExport = () => { + exportPreparingTask.controller.start({ + label: 'Preparing export…', + }); + setExportPreparingPercent(0.1); + setExportStarted(true); + }; + return ( @@ -90,217 +62,35 @@ export default function ExportHome({ ctx }: Props) {
    {!exportStarted ? ( -
    -
    - Start a new export -
    -
    -

    - Select one or more models/blocks to start selecting what - to export. -

    -
    -
    - - > - id={exportInitialSelectId} - name="export-initial-model" - label="Starting models/blocks" - selectInputProps={{ - isMulti: true, - isClearable: true, - isDisabled: !allItemTypes, - options: - allItemTypes?.map((it) => ({ - value: it.id, - label: `${it.attributes.name}${it.attributes.modular_block ? ' (Block)' : ''}`, - })) ?? [], - placeholder: 'Choose models/blocks…', - }} - value={ - allItemTypes - ? allItemTypes - .map((it) => ({ - value: it.id, - label: `${it.attributes.name}${it.attributes.modular_block ? ' (Block)' : ''}`, - })) - .filter((opt) => - exportInitialItemTypeIds.includes( - opt.value, - ), - ) - : [] - } - onChange={(options) => - setExportInitialItemTypeIds( - Array.isArray(options) - ? options.map((o) => o.value) - : [], - ) - } - /> -
    -
    - - -
    -
    - -
    -
    - -
    -
    -
    -
    + ) : ( setExportPreparingBusy(false)} + onGraphPrepared={() => { + setExportPreparingPercent(1); + exportPreparingTask.controller.complete({ + label: 'Graph prepared', + }); + }} onPrepareProgress={(p) => { // ensure overlay shows determinate progress - setExportPreparingBusy(true); - setExportPreparingProgress(p); + if (exportPreparingTask.state.status !== 'running') { + exportPreparingTask.controller.start(p); + } else { + exportPreparingTask.controller.setProgress(p); + } const hasFixedTotal = (p.total ?? 0) > 0; const raw = hasFixedTotal ? p.done / p.total : 0; if (!hasFixedTotal) { @@ -319,16 +109,12 @@ export default function ExportHome({ ctx }: Props) { onClose={() => { // Return to selection screen with current picks preserved setExportStarted(false); + exportPreparingTask.controller.reset(); }} onExport={async (itemTypeIds, pluginIds) => { try { - setExportSelectionBusy(true); - setExportSelectionProgress(undefined); - setExportSelectionCancelled(false); - exportSelectionCancelRef.current = false; - const total = pluginIds.length + itemTypeIds.length * 2; - setExportSelectionProgress({ + exportSelectionTask.controller.start({ done: 0, total, label: 'Preparing export…', @@ -343,13 +129,18 @@ export default function ExportHome({ ctx }: Props) { { onProgress: (label: string) => { done += 1; - setExportSelectionProgress({ done, total, label }); + exportSelectionTask.controller.setProgress({ + done, + total, + label, + }); }, - shouldCancel: () => exportSelectionCancelRef.current, + shouldCancel: () => + exportSelectionTask.controller.isCancelRequested(), }, ); - if (exportSelectionCancelRef.current) { + if (exportSelectionTask.controller.isCancelRequested()) { throw new Error('Export cancelled'); } @@ -357,6 +148,11 @@ export default function ExportHome({ ctx }: Props) { fileName: 'export.json', prettify: true, }); + exportSelectionTask.controller.complete({ + done: total, + total, + label: 'Export completed', + }); ctx.notice('Export completed successfully.'); } catch (e) { console.error('Selection export failed', e); @@ -364,17 +160,18 @@ export default function ExportHome({ ctx }: Props) { e instanceof Error && e.message === 'Export cancelled' ) { + exportSelectionTask.controller.complete({ + label: 'Export cancelled', + }); ctx.notice('Export canceled'); } else { + exportSelectionTask.controller.fail(e); ctx.alert( 'Could not complete the export. Please try again.', ); } } finally { - setExportSelectionBusy(false); - setExportSelectionProgress(undefined); - setExportSelectionCancelled(false); - exportSelectionCancelRef.current = false; + exportSelectionTask.controller.reset(); } }} /> @@ -385,230 +182,44 @@ export default function ExportHome({ ctx }: Props) { {/* Blocking overlay while exporting all */} - {exportAllBusy && ( -
    -
    -
    Exporting entire schema
    -
    - Sit tight, we’re gathering models, blocks, and plugins… -
    - -
    -
    -
    -
    -
    - {exportAllProgress - ? exportAllProgress.label - : 'Loading project schema…'} -
    -
    - {exportAllProgress - ? `${exportAllProgress.done} / ${exportAllProgress.total}` - : ''} -
    -
    - -
    - -
    -
    -
    - )} - - {/* Overlay while preparing the Export selection view */} - {exportPreparingBusy && ( -
    -
    -
    Preparing export
    -
    - Sit tight, we’re setting up your models, blocks, and plugins… -
    - -
    0 - ? exportPreparingProgress.total - : undefined - } - aria-valuenow={ - exportPreparingProgress && - (exportPreparingProgress.total ?? 0) > 0 - ? exportPreparingProgress.done - : undefined - } - > -
    -
    -
    -
    - {exportPreparingProgress - ? exportPreparingProgress.label - : 'Preparing export…'} -
    -
    - {exportPreparingProgress && - (exportPreparingProgress.total ?? 0) > 0 - ? `${exportPreparingProgress.done} / ${exportPreparingProgress.total}` - : ''} -
    -
    - -
    -
    - )} - - {/* Overlay during selection export */} - {exportSelectionBusy && ( -
    -
    -
    Exporting selection
    -
    - Sit tight, we’re gathering models, blocks, and plugins… -
    - -
    -
    -
    -
    -
    - {exportSelectionProgress - ? exportSelectionProgress.label - : 'Preparing export…'} -
    -
    - {exportSelectionProgress - ? `${exportSelectionProgress.done} / ${exportSelectionProgress.total}` - : ''} -
    -
    - -
    - -
    -
    -
    - )} + + progress.label ?? 'Loading project schema…' + } + cancel={() => ({ + label: 'Cancel export', + intent: exportAllTask.state.cancelRequested ? 'muted' : 'negative', + disabled: exportAllTask.state.cancelRequested, + onCancel: () => exportAllTask.controller.requestCancel(), + })} + /> + + progress.label ?? 'Preparing export…'} + percentOverride={exportPreparingPercent} + /> + + progress.label ?? 'Preparing export…'} + cancel={() => ({ + label: 'Cancel export', + intent: exportSelectionTask.state.cancelRequested ? 'muted' : 'negative', + disabled: exportSelectionTask.state.cancelRequested, + onCancel: () => exportSelectionTask.controller.requestCancel(), + })} + /> ); } diff --git a/import-export-schema/src/entrypoints/ExportPage/Inner.tsx b/import-export-schema/src/entrypoints/ExportPage/Inner.tsx index 17c09cfc..f6348205 100644 --- a/import-export-schema/src/entrypoints/ExportPage/Inner.tsx +++ b/import-export-schema/src/entrypoints/ExportPage/Inner.tsx @@ -15,12 +15,12 @@ import { } from '@/utils/datocms/schema'; // import { collectDependencies } from '@/utils/graph/dependencies'; import { type AppNode, edgeTypes, type Graph } from '@/utils/graph/types'; -import { buildGraphFromSchema } from './buildGraphFromSchema'; import { EntitiesToExportContext } from './EntitiesToExportContext'; import { ExportItemTypeNodeRenderer } from './ExportItemTypeNodeRenderer'; import { ExportPluginNodeRenderer } from './ExportPluginNodeRenderer'; import LargeSelectionView from './LargeSelectionView'; import { useAnimatedNodes } from './useAnimatedNodes'; +import { useExportGraph } from './useExportGraph'; const nodeTypes: NodeTypes = { itemType: ExportItemTypeNodeRenderer, @@ -55,10 +55,6 @@ export default function Inner({ }: Props) { const ctx = useCtx(); - const [graph, setGraph] = useState(); - const [error, setError] = useState(); - const [refreshKey, setRefreshKey] = useState(0); - const [selectedItemTypeIds, setSelectedItemTypeIds] = useState( initialItemTypes.map((it) => it.id), ); @@ -70,56 +66,16 @@ export default function Inner({ pluginIds: Set; }>({ itemTypeIds: new Set(), pluginIds: new Set() }); - // Overlay is controlled by parent; we signal prepared after each build - - useEffect(() => { - async function run() { - try { - setError(undefined); - if ( - typeof window !== 'undefined' && - window.localStorage?.getItem('schemaDebug') === '1' - ) { - console.log('[Inner] buildGraphFromSchema start', { - selectedItemTypeIds: selectedItemTypeIds.length, - }); - } - const graph = await buildGraphFromSchema({ - initialItemTypes, - selectedItemTypeIds, - schema, - onProgress: onPrepareProgress, - installedPluginIds, - }); - - setGraph(graph); - if ( - typeof window !== 'undefined' && - window.localStorage?.getItem('schemaDebug') === '1' - ) { - console.log('[Inner] buildGraphFromSchema done', { - nodes: graph.nodes.length, - edges: graph.edges.length, - }); - } - onGraphPrepared?.(); - } catch (e) { - console.error('Error building export graph:', e); - setError(e as Error); - onGraphPrepared?.(); - } - } - - run(); - }, [ - initialItemTypes - .map((it) => it.id) - .sort() - .join('-'), - selectedItemTypeIds.sort().join('-'), + const { graph, error, refresh } = useExportGraph({ + initialItemTypes, + selectedItemTypeIds, schema, - refreshKey, - ]); + onPrepareProgress, + onGraphPrepared, + installedPluginIds, + }); + + // Overlay is controlled by parent; we signal prepared after each build // Keep selection in sync if the parent changes the initial set of item types useEffect(() => { @@ -142,9 +98,6 @@ export default function Inner({ const animatedNodes = useAnimatedNodes( showGraph && graph ? graph.nodes : [], - { - animationDuration: 300, - }, ); const onNodeClick: NodeMouseHandler = useCallback( @@ -443,10 +396,7 @@ export default function Inner({ -
    -
    -
    + stallCurrent={exportProgress.done} + cancel={{ + label: 'Cancel export', + intent: exportTask.state.cancelRequested ? 'muted' : 'negative', + disabled: exportTask.state.cancelRequested, + onCancel: () => exportTask.controller.requestCancel(), + }} + /> )} ); diff --git a/import-export-schema/src/entrypoints/ExportPage/useAnimatedNodes.tsx b/import-export-schema/src/entrypoints/ExportPage/useAnimatedNodes.tsx index 3a2f7426..4cf37160 100644 --- a/import-export-schema/src/entrypoints/ExportPage/useAnimatedNodes.tsx +++ b/import-export-schema/src/entrypoints/ExportPage/useAnimatedNodes.tsx @@ -1,51 +1,5 @@ -import { useReactFlow } from '@xyflow/react'; -import { timer } from 'd3-timer'; -import { useEffect, useState } from 'react'; -import { easing } from 'ts-easing'; import type { AppNode } from '@/utils/graph/types'; -export function useAnimatedNodes( - initialNodes: AppNode[], - { animationDuration = 300 } = {}, -) { - const [nodes, setNodes] = useState(initialNodes); - const { getNode } = useReactFlow(); - - useEffect(() => { - const wantedPositionChanges = initialNodes.map((initialNode) => { - const currentNode = getNode(initialNode.id); - - return { - id: initialNode.id, - from: - (currentNode == null ? undefined : currentNode.position) ?? - initialNode.position, - to: initialNode.position, - node: initialNode, - }; - }); - - const t = timer((elapsed) => { - const percentElapsed = easing.inOutCubic(elapsed / animationDuration); - - const movedNodes = wantedPositionChanges.map(({ node, from, to }) => ({ - ...node, - position: { - x: from.x + (to.x - from.x) * percentElapsed, - y: from.y + (to.y - from.y) * percentElapsed, - }, - })); - - setNodes(movedNodes); - - if (elapsed > animationDuration) { - setNodes(initialNodes); - t.stop(); - } - }); - - return () => t.stop(); - }, [initialNodes, getNode, animationDuration]); - - return nodes; +export function useAnimatedNodes(initialNodes: AppNode[]) { + return initialNodes; } diff --git a/import-export-schema/src/entrypoints/ExportPage/useExportGraph.ts b/import-export-schema/src/entrypoints/ExportPage/useExportGraph.ts new file mode 100644 index 00000000..b2203795 --- /dev/null +++ b/import-export-schema/src/entrypoints/ExportPage/useExportGraph.ts @@ -0,0 +1,92 @@ +import { useEffect, useState } from 'react'; +import type { SchemaTypes } from '@datocms/cma-client'; +import type { ProjectSchema } from '@/utils/ProjectSchema'; +import type { Graph, SchemaProgressUpdate } from '@/utils/graph/types'; +import { buildGraphFromSchema } from './buildGraphFromSchema'; + +type Options = { + initialItemTypes: SchemaTypes.ItemType[]; + selectedItemTypeIds: string[]; + schema: ProjectSchema; + onPrepareProgress?: (update: SchemaProgressUpdate) => void; + onGraphPrepared?: () => void; + installedPluginIds?: Set; +}; + +export function useExportGraph({ + initialItemTypes, + selectedItemTypeIds, + schema, + onPrepareProgress, + onGraphPrepared, + installedPluginIds, +}: Options) { + const [graph, setGraph] = useState(); + const [error, setError] = useState(); + const [refreshKey, setRefreshKey] = useState(0); + + useEffect(() => { + let cancelled = false; + async function run() { + try { + setError(undefined); + if ( + typeof window !== 'undefined' && + window.localStorage?.getItem('schemaDebug') === '1' + ) { + console.log('[ExportGraph] buildGraphFromSchema start', { + selectedItemTypeIds: selectedItemTypeIds.length, + }); + } + const nextGraph = await buildGraphFromSchema({ + initialItemTypes, + selectedItemTypeIds, + schema, + onProgress: onPrepareProgress, + installedPluginIds, + }); + if (cancelled) return; + setGraph(nextGraph); + if ( + typeof window !== 'undefined' && + window.localStorage?.getItem('schemaDebug') === '1' + ) { + console.log('[ExportGraph] buildGraphFromSchema done', { + nodes: nextGraph.nodes.length, + edges: nextGraph.edges.length, + }); + } + onGraphPrepared?.(); + } catch (err) { + if (cancelled) return; + console.error('Error building export graph:', err); + setError(err as Error); + onGraphPrepared?.(); + } + } + run(); + return () => { + cancelled = true; + }; + }, [ + initialItemTypes + .map((it) => it.id) + .sort() + .join('-'), + selectedItemTypeIds + .slice() + .sort() + .join('-'), + schema, + refreshKey, + onPrepareProgress, + onGraphPrepared, + installedPluginIds, + ]); + + return { + graph, + error, + refresh: () => setRefreshKey((key) => key + 1), + }; +} diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx index 6c0e7442..bdda8464 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx @@ -3,11 +3,14 @@ import { useReactFlow } from '@xyflow/react'; import { SelectField, TextField } from 'datocms-react-ui'; import { useId } from 'react'; import { Field } from 'react-final-form'; -import type { GroupBase } from 'react-select'; import { useResolutionStatusForItemType } from '../ResolutionsForm'; import Collapsible from './Collapsible'; type Option = { label: string; value: string }; +type SelectGroup = { + label?: string; + options: readonly OptionType[]; +}; type Props = { exportItemType: SchemaTypes.ItemType; @@ -62,7 +65,7 @@ export function ItemTypeConflict({ exportItemType, projectItemType }: Props) {

    {({ input, meta: { error } }) => ( - > + > {...input} id={selectId} label="To resolve this conflict:" diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx index 179e5e77..3d6351bd 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx @@ -3,11 +3,14 @@ import { useReactFlow } from '@xyflow/react'; import { SelectField } from 'datocms-react-ui'; import { useId } from 'react'; import { Field } from 'react-final-form'; -import type { GroupBase } from 'react-select'; import { useResolutionStatusForPlugin } from '../ResolutionsForm'; import Collapsible from './Collapsible'; type Option = { label: string; value: string }; +type SelectGroup = { + label?: string; + options: readonly OptionType[]; +}; const options: Option[] = [ { @@ -44,7 +47,7 @@ export function PluginConflict({ exportPlugin, projectPlugin }: Props) {

    {({ input, meta: { error } }) => ( - > + > {...input} id={selectId} label="To resolve this conflict:" diff --git a/import-export-schema/src/entrypoints/ImportPage/index.tsx b/import-export-schema/src/entrypoints/ImportPage/index.tsx index 95028dc2..7bc5a2df 100644 --- a/import-export-schema/src/entrypoints/ImportPage/index.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/index.tsx @@ -1,27 +1,26 @@ -import type { SchemaTypes } from '@datocms/cma-client'; // Removed unused icons import { ReactFlowProvider } from '@xyflow/react'; import type { RenderPageCtx } from 'datocms-plugin-sdk'; -import { Button, Canvas, SelectField, Spinner } from 'datocms-react-ui'; -import { useEffect, useId, useMemo, useRef, useState } from 'react'; -import type { GroupBase } from 'react-select'; -import ProgressStallNotice from '@/components/ProgressStallNotice'; -import { createCmaClient } from '@/utils/createCmaClient'; +import { Canvas, Spinner } from 'datocms-react-ui'; +import { useEffect, useId, useState } from 'react'; +import { ExportStartPanel } from '@/components/ExportStartPanel'; +import { TaskProgressOverlay } from '@/components/TaskProgressOverlay'; +import { useExportSelection } from '@/shared/hooks/useExportSelection'; +import { useProjectSchema } from '@/shared/hooks/useProjectSchema'; +import { useExportAllHandler } from '@/shared/hooks/useExportAllHandler'; +import { useConflictsBuilder } from '@/shared/hooks/useConflictsBuilder'; +import { useLongTask } from '@/shared/tasks/useLongTask'; import { downloadJSON } from '@/utils/downloadJson'; -import { ProjectSchema } from '@/utils/ProjectSchema'; import type { ExportDoc } from '@/utils/types'; import buildExportDoc from '../ExportPage/buildExportDoc'; import { ExportSchema } from '../ExportPage/ExportSchema'; import ExportInner from '../ExportPage/Inner'; // PostExportSummary removed: exports now download directly with a toast import { buildImportDoc } from './buildImportDoc'; -import buildConflicts, { - type Conflicts, -} from './ConflictsManager/buildConflicts'; import { ConflictsContext } from './ConflictsManager/ConflictsContext'; import FileDropZone from './FileDropZone'; import { Inner } from './Inner'; -import importSchema, { type ImportProgress } from './importSchema'; +import importSchema from './importSchema'; // PostImportSummary removed: after import we just show a toast and reset import ResolutionsForm, { type Resolutions } from './ResolutionsForm'; @@ -73,12 +72,6 @@ export function ImportPage({ // Local tab to switch between importing a file and exporting from selection const [mode, setMode] = useState<'import' | 'export'>(initialMode); - const [importProgress, setImportProgress] = useState< - ImportProgress | undefined - >(undefined); - const [importCancelled, setImportCancelled] = useState(false); - const importCancelRef = useRef(false); - // Removed postImportSummary: no post-import overview screen async function handleDrop(filename: string, doc: ExportDoc) { @@ -91,97 +84,49 @@ export function ImportPage({ } } - const client = useMemo( - () => createCmaClient(ctx), - [ctx.currentUserAccessToken, ctx.environment], - ); + const projectSchema = useProjectSchema(ctx); + const client = projectSchema.client; - const projectSchema = useMemo(() => new ProjectSchema(client), [client]); + const importTask = useLongTask(); + const exportAllTask = useLongTask(); + const exportPreparingTask = useLongTask(); + const exportSelectionTask = useLongTask(); + const conflictsTask = useLongTask(); // Removed adminDomain lookup; no post-import summary links needed - // State used only in Export tab: choose initial model/block for graph - const [exportInitialItemTypeIds, setExportInitialItemTypeIds] = useState< - string[] - >([]); - const [exportInitialItemTypes, setExportInitialItemTypes] = useState< - SchemaTypes.ItemType[] - >([]); const [exportStarted, setExportStarted] = useState(false); - // Removed postExportDoc: no post-export overview for exports - const [exportAllBusy, setExportAllBusy] = useState(false); - const [exportAllProgress, setExportAllProgress] = useState< - { done: number; total: number; label: string } | undefined - >(undefined); - const [exportAllCancelled, setExportAllCancelled] = useState(false); - const exportAllCancelRef = useRef(false); - // Show overlay while Export selection view prepares its graph - const [exportPreparingBusy, setExportPreparingBusy] = useState(false); - const [exportPreparingProgress, setExportPreparingProgress] = useState< - { done: number; total: number; label: string } | undefined - >(undefined); - // Selection export overlay state (when exporting from Start export flow) - const [exportSelectionBusy, setExportSelectionBusy] = useState(false); - const [exportSelectionProgress, setExportSelectionProgress] = useState< - { done: number; total: number; label: string } | undefined - >(undefined); - const [exportSelectionCancelled, setExportSelectionCancelled] = - useState(false); - const exportSelectionCancelRef = useRef(false); - const [allItemTypes, setAllItemTypes] = useState< - SchemaTypes.ItemType[] | undefined - >(undefined); + const { + allItemTypes, + selectedIds: exportInitialItemTypeIds, + selectedItemTypes: exportInitialItemTypes, + setSelectedIds: setExportInitialItemTypeIds, + selectAllModels: handleSelectAllModels, + selectAllBlocks: handleSelectAllBlocks, + } = useExportSelection({ schema: projectSchema, enabled: mode === 'export' }); + + const { + conflicts, + setConflicts, + } = useConflictsBuilder({ + exportSchema: exportSchema?.[1], + projectSchema, + task: conflictsTask.controller, + }); + + const runExportAll = useExportAllHandler({ + ctx, + schema: projectSchema, + task: exportAllTask.controller, + }); + + const handleStartExportSelection = () => { + exportPreparingTask.controller.start({ + label: 'Preparing export…', + }); + setExportStarted(true); + }; - useEffect(() => { - async function load() { - if (mode !== 'export') return; - const types = await projectSchema.getAllItemTypes(); - setAllItemTypes(types); - } - load(); - }, [mode, projectSchema]); - - useEffect(() => { - async function resolveInitial() { - if (!exportInitialItemTypeIds.length) { - setExportInitialItemTypes([]); - return; - } - const list: SchemaTypes.ItemType[] = []; - for (const id of exportInitialItemTypeIds) { - list.push(await projectSchema.getItemTypeById(id)); - } - setExportInitialItemTypes(list); - } - resolveInitial(); - }, [exportInitialItemTypeIds.join('-'), projectSchema]); - - const [conflicts, setConflicts] = useState(); - const [conflictsBusy, setConflictsBusy] = useState(false); - const [conflictsProgress, setConflictsProgress] = useState< - { done: number; total: number; label: string } | undefined - >(undefined); - - - useEffect(() => { - async function run() { - if (!exportSchema) { - return; - } - try { - setConflictsBusy(true); - setConflictsProgress({ done: 0, total: 1, label: 'Preparing import…' }); - const c = await buildConflicts(exportSchema[1], projectSchema, (p) => - setConflictsProgress(p), - ); - setConflicts(c); - } finally { - setConflictsBusy(false); - } - } - - run(); - }, [exportSchema, projectSchema]); // Listen for bottom Cancel action from ConflictsManager useEffect(() => { @@ -227,9 +172,11 @@ export function ImportPage({ } try { - setImportCancelled(false); - importCancelRef.current = false; - setImportProgress({ finished: 0, total: 1 }); + importTask.controller.start({ + done: 0, + total: 1, + label: 'Preparing import…', + }); const importDoc = await buildImportDoc( exportSchema[1], @@ -241,26 +188,43 @@ export function ImportPage({ importDoc, client, (p) => { - if (!importCancelRef.current) setImportProgress(p); + if (!importTask.controller.isCancelRequested()) { + importTask.controller.setProgress({ + done: p.finished, + total: p.total, + label: p.label, + }); + } }, { - shouldCancel: () => importCancelRef.current, + shouldCancel: () => importTask.controller.isCancelRequested(), }, ); + if (importTask.controller.isCancelRequested()) { + throw new Error('Import cancelled'); + } + // Success: notify and reset to initial idle state + importTask.controller.complete({ + done: importTask.state.progress.total, + total: importTask.state.progress.total, + label: 'Import completed', + }); ctx.notice('Import completed successfully.'); - setImportProgress(undefined); setExportSchema(undefined); setConflicts(undefined); } catch (e) { console.error(e); if (e instanceof Error && e.message === 'Import cancelled') { + importTask.controller.complete({ label: 'Import cancelled' }); ctx.notice('Import canceled'); } else { + importTask.controller.fail(e); ctx.alert('Import could not be completed successfully.'); } - setImportProgress(undefined); + } finally { + importTask.controller.reset(); } } @@ -357,229 +321,49 @@ export function ImportPage({ ) : (
    {!exportStarted ? ( -
    -
    - Start a new export -
    -
    -

    - Select one or more models/blocks to start selecting what - to export. -

    -
    -
    - - > - id={exportInitialSelectId} - name="export-initial-model" - label="Starting models/blocks" - selectInputProps={{ - isMulti: true, - isClearable: true, - isDisabled: !allItemTypes, - options: - allItemTypes?.map((it) => ({ - value: it.id, - label: `${it.attributes.name}${it.attributes.modular_block ? ' (Block)' : ''}`, - })) ?? [], - placeholder: 'Choose models/blocks…', - }} - value={ - allItemTypes - ? allItemTypes - .map((it) => ({ - value: it.id, - label: `${it.attributes.name}${it.attributes.modular_block ? ' (Block)' : ''}`, - })) - .filter((opt) => - exportInitialItemTypeIds.includes( - opt.value, - ), - ) - : [] - } - onChange={(options) => - setExportInitialItemTypeIds( - Array.isArray(options) - ? options.map((o) => o.value) - : [], - ) - } - /> -
    -
    - - -
    -
    - -
    -
    - -
    -
    -
    -
    + ) : ( setExportPreparingBusy(false)} + onGraphPrepared={() => { + exportPreparingTask.controller.complete({ + label: 'Graph prepared', + }); + }} onPrepareProgress={(p) => { // ensure overlay shows determinate progress - setExportPreparingBusy(true); - setExportPreparingProgress(p); + if (exportPreparingTask.state.status !== 'running') { + exportPreparingTask.controller.start(p); + } else { + exportPreparingTask.controller.setProgress(p); + } }} onClose={() => { // Return to selection screen with current picks preserved setExportStarted(false); + exportPreparingTask.controller.reset(); }} onExport={async (itemTypeIds, pluginIds) => { try { - setExportSelectionBusy(true); - setExportSelectionProgress(undefined); - setExportSelectionCancelled(false); - exportSelectionCancelRef.current = false; - const total = pluginIds.length + itemTypeIds.length * 2; - setExportSelectionProgress({ + exportSelectionTask.controller.start({ done: 0, total, label: 'Preparing export…', @@ -594,18 +378,18 @@ export function ImportPage({ { onProgress: (label: string) => { done += 1; - setExportSelectionProgress({ + exportSelectionTask.controller.setProgress({ done, total, label, }); }, shouldCancel: () => - exportSelectionCancelRef.current, + exportSelectionTask.controller.isCancelRequested(), }, ); - if (exportSelectionCancelRef.current) { + if (exportSelectionTask.controller.isCancelRequested()) { throw new Error('Export cancelled'); } @@ -613,6 +397,11 @@ export function ImportPage({ fileName: 'export.json', prettify: true, }); + exportSelectionTask.controller.complete({ + done: total, + total, + label: 'Export completed', + }); ctx.notice('Export completed successfully.'); } catch (e) { console.error('Selection export failed', e); @@ -620,17 +409,18 @@ export function ImportPage({ e instanceof Error && e.message === 'Export cancelled' ) { + exportSelectionTask.controller.complete({ + label: 'Export cancelled', + }); ctx.notice('Export canceled'); } else { + exportSelectionTask.controller.fail(e); ctx.alert( 'Could not complete the export. Please try again.', ); } } finally { - setExportSelectionBusy(false); - setExportSelectionProgress(undefined); - setExportSelectionCancelled(false); - exportSelectionCancelRef.current = false; + exportSelectionTask.controller.reset(); } }} /> @@ -642,376 +432,97 @@ export function ImportPage({
    - {importProgress && ( -
    -
    -
    Import in progress
    -
    - {importCancelled - ? 'Cancelling import…' - : 'Sit tight, we’re applying models, fields, and plugins…'} -
    - -
    -
    -
    -
    -
    - {importCancelled - ? 'Stopping at next safe point…' - : importProgress.label || ''} -
    -
    - {importProgress.finished} / {importProgress.total} -
    -
    - -
    - -
    -
    -
    - )} - {/* Blocking overlay while exporting all */} - {exportAllBusy && ( -
    -
    -
    Exporting entire schema
    -
    - Sit tight, we’re gathering models, blocks, and plugins… -
    - -
    -
    -
    -
    -
    - {exportAllProgress - ? exportAllProgress.label - : 'Loading project schema…'} -
    -
    - {exportAllProgress - ? `${exportAllProgress.done} / ${exportAllProgress.total}` - : ''} -
    -
    - -
    - -
    -
    -
    - )} - - {/* Overlay while preparing import conflicts after selecting a file */} - {conflictsBusy && ( -
    -
    -
    Preparing import
    -
    - Sit tight, we’re scanning your export against the project… -
    - -
    -
    -
    -
    -
    - {conflictsProgress - ? conflictsProgress.label - : 'Preparing import…'} -
    -
    - {conflictsProgress - ? `${conflictsProgress.done} / ${conflictsProgress.total}` - : ''} -
    -
    - -
    -
    - )} - - {/* Overlay while preparing the Export selection view */} - {exportPreparingBusy && ( -
    -
    -
    Preparing export
    -
    - Sit tight, we’re setting up your models, blocks, and plugins… -
    - -
    -
    -
    -
    -
    - {exportPreparingProgress - ? exportPreparingProgress.label - : 'Preparing export…'} -
    -
    - {exportPreparingProgress - ? `${exportPreparingProgress.done} / ${exportPreparingProgress.total}` - : ''} -
    -
    - -
    -
    - )} - - {/* Blocking overlay while exporting selection via Start export */} - {exportSelectionBusy && ( -
    -
    -
    Exporting selection
    -
    - Sit tight, we’re gathering models, blocks, and plugins… -
    - -
    -
    -
    -
    -
    - {exportSelectionProgress - ? exportSelectionProgress.label - : 'Preparing export…'} -
    -
    - {exportSelectionProgress - ? `${exportSelectionProgress.done} / ${exportSelectionProgress.total}` - : ''} -
    -
    - -
    - -
    -
    -
    - )} + + state.cancelRequested + ? 'Cancelling import…' + : 'Sit tight, we’re applying models, fields, and plugins…' + } + ariaLabel="Import in progress" + progressLabel={(progress, state) => + state.cancelRequested + ? 'Stopping at next safe point…' + : progress.label ?? '' + } + cancel={() => ({ + label: 'Cancel import', + intent: importTask.state.cancelRequested ? 'muted' : 'negative', + disabled: importTask.state.cancelRequested, + onCancel: async () => { + if (!exportSchema) return; + const result = await ctx.openConfirm({ + title: 'Cancel import in progress?', + content: + 'Stopping now can leave partial changes in your project. Some models or blocks may be created without relationships, some fields or fieldsets may already exist, and plugin installations or editor settings may be incomplete. You can run the import again to finish or manually clean up. Are you sure you want to cancel?', + choices: [ + { + label: 'Yes, cancel the import', + value: 'yes', + intent: 'negative', + }, + ], + cancel: { + label: 'Nevermind', + value: false, + intent: 'positive', + }, + }); + + if (result === 'yes') { + importTask.controller.requestCancel(); + } + }, + })} + /> + + + progress.label ?? 'Loading project schema…' + } + cancel={() => ({ + label: 'Cancel export', + intent: exportAllTask.state.cancelRequested ? 'muted' : 'negative', + disabled: exportAllTask.state.cancelRequested, + onCancel: () => exportAllTask.controller.requestCancel(), + })} + /> + + progress.label ?? 'Preparing import…'} + overlayZIndex={9998} + /> + + progress.label ?? 'Preparing export…'} + /> + + progress.label ?? 'Preparing export…'} + cancel={() => ({ + label: 'Cancel export', + intent: exportSelectionTask.state.cancelRequested ? 'muted' : 'negative', + disabled: exportSelectionTask.state.cancelRequested, + onCancel: () => exportSelectionTask.controller.requestCancel(), + })} + /> ); } diff --git a/import-export-schema/src/index.css b/import-export-schema/src/index.css index 429307c3..0c5087eb 100644 --- a/import-export-schema/src/index.css +++ b/import-export-schema/src/index.css @@ -2,6 +2,14 @@ svg { display: block; } +:root { + --overlay-gradient: linear-gradient( + 180deg, + rgba(250, 252, 255, 0.96), + rgba(245, 247, 255, 0.96) + ); +} + html, body, #root { @@ -1014,6 +1022,18 @@ button.chip:focus-visible { max-width: 92vw; } +.progress-overlay { + position: fixed; + inset: 0; + background: var(--overlay-gradient); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 16px; +} + .export-overlay__title { font-weight: 800; font-size: 22px; diff --git a/import-export-schema/src/shared/hooks/useCmaClient.ts b/import-export-schema/src/shared/hooks/useCmaClient.ts new file mode 100644 index 00000000..6a535f32 --- /dev/null +++ b/import-export-schema/src/shared/hooks/useCmaClient.ts @@ -0,0 +1,29 @@ +import { useMemo } from 'react'; +import type { Client, ClientConfigOptions } from '@datocms/cma-client'; +import type { + RenderConfigScreenCtx, + RenderPageCtx, +} from 'datocms-plugin-sdk'; +import { createCmaClient } from '@/utils/createCmaClient'; + +type AuthCtx = + | Pick + | Pick; + +type UseCmaClientOptions = { + overrides?: Partial; +}; + +/** + * Returns a memoized CMA client that only changes when auth info changes. + * Consumers should keep `overrides` stable to avoid needless re-instantiation. + */ +export function useCmaClient( + ctx: AuthCtx, + { overrides }: UseCmaClientOptions = {}, +): Client { + return useMemo( + () => createCmaClient(ctx, overrides), + [ctx.currentUserAccessToken, ctx.environment, overrides], + ); +} diff --git a/import-export-schema/src/shared/hooks/useConflictsBuilder.ts b/import-export-schema/src/shared/hooks/useConflictsBuilder.ts new file mode 100644 index 00000000..deef4c66 --- /dev/null +++ b/import-export-schema/src/shared/hooks/useConflictsBuilder.ts @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useState } from 'react'; +import type { ProjectSchema } from '@/utils/ProjectSchema'; +import type { ExportSchema } from '@/entrypoints/ExportPage/ExportSchema'; +import buildConflicts, { + type Conflicts, +} from '@/entrypoints/ImportPage/ConflictsManager/buildConflicts'; +import type { LongTaskController } from '@/shared/tasks/useLongTask'; + +export function useConflictsBuilder({ + exportSchema, + projectSchema, + task, +}: { + exportSchema: ExportSchema | undefined; + projectSchema: ProjectSchema; + task: LongTaskController; +}) { + const [conflicts, setConflicts] = useState(); + const [refreshKey, setRefreshKey] = useState(0); + + useEffect(() => { + let cancelled = false; + async function run() { + if (!exportSchema) { + setConflicts(undefined); + return; + } + try { + task.start({ done: 0, total: 1, label: 'Preparing import…' }); + const result = await buildConflicts(exportSchema, projectSchema, (p) => { + if (!cancelled) { + task.setProgress(p); + } + }); + if (cancelled) return; + setConflicts(result); + } finally { + if (!cancelled) { + task.complete({ label: 'Conflicts ready' }); + task.reset(); + } + } + } + run(); + return () => { + cancelled = true; + }; + }, [exportSchema, projectSchema, task, refreshKey]); + + const refresh = useCallback(() => setRefreshKey((key) => key + 1), []); + + return { conflicts, setConflicts, refresh }; +} diff --git a/import-export-schema/src/shared/hooks/useExportAllHandler.ts b/import-export-schema/src/shared/hooks/useExportAllHandler.ts new file mode 100644 index 00000000..c617e37a --- /dev/null +++ b/import-export-schema/src/shared/hooks/useExportAllHandler.ts @@ -0,0 +1,82 @@ +import { useCallback } from 'react'; +import type { RenderPageCtx } from 'datocms-plugin-sdk'; +import { downloadJSON } from '@/utils/downloadJson'; +import type { ProjectSchema } from '@/utils/ProjectSchema'; +import buildExportDoc from '@/entrypoints/ExportPage/buildExportDoc'; +import type { LongTaskController } from '@/shared/tasks/useLongTask'; + +type Options = { + ctx: RenderPageCtx; + schema: ProjectSchema; + task: LongTaskController; +}; + +export function useExportAllHandler({ ctx, schema, task }: Options) { + return useCallback(async () => { + try { + const confirmation = await ctx.openConfirm({ + title: 'Export entire current schema?', + content: + 'This will export all models, block models, and plugins in the current environment as a single JSON file.', + choices: [ + { + label: 'Export everything', + value: 'export', + intent: 'positive', + }, + ], + cancel: { label: 'Cancel', value: false }, + }); + if (confirmation !== 'export') { + return; + } + + task.start({ label: 'Preparing export…' }); + const allTypes = await schema.getAllItemTypes(); + const allPlugins = await schema.getAllPlugins(); + if (!allTypes.length) { + task.reset(); + ctx.alert('No item types found in this environment.'); + return; + } + const preferredRoot = + allTypes.find((t) => !t.attributes.modular_block) || allTypes[0]; + const total = allPlugins.length + allTypes.length * 2; + task.setProgress({ done: 0, total, label: 'Preparing export…' }); + let done = 0; + const exportDoc = await buildExportDoc( + schema, + preferredRoot.id, + allTypes.map((t) => t.id), + allPlugins.map((p) => p.id), + { + onProgress: (label: string) => { + done += 1; + task.setProgress({ done, total, label }); + }, + shouldCancel: () => task.isCancelRequested(), + }, + ); + if (task.isCancelRequested()) { + throw new Error('Export cancelled'); + } + downloadJSON(exportDoc, { + fileName: 'export.json', + prettify: true, + }); + task.complete({ done: total, total, label: 'Export completed' }); + ctx.notice('Export completed successfully.'); + } catch (error) { + console.error('Export-all failed', error); + if (error instanceof Error && error.message === 'Export cancelled') { + task.complete({ label: 'Export cancelled' }); + ctx.notice('Export canceled'); + } else { + task.fail(error); + ctx.alert('Could not export the current schema. Please try again.'); + } + } finally { + task.reset(); + } + }, [ctx, schema, task]); +} diff --git a/import-export-schema/src/shared/hooks/useExportSelection.ts b/import-export-schema/src/shared/hooks/useExportSelection.ts new file mode 100644 index 00000000..85d7cb9d --- /dev/null +++ b/import-export-schema/src/shared/hooks/useExportSelection.ts @@ -0,0 +1,113 @@ +import type { SchemaTypes } from '@datocms/cma-client'; +import { useCallback, useEffect, useState } from 'react'; +import type { ProjectSchema } from '@/utils/ProjectSchema'; + +type UseExportSelectionOptions = { + schema: ProjectSchema; + enabled?: boolean; +}; + +type UseExportSelectionResult = { + allItemTypes?: SchemaTypes.ItemType[]; + selectedIds: string[]; + selectedItemTypes: SchemaTypes.ItemType[]; + setSelectedIds: (ids: string[]) => void; + selectAllModels: () => void; + selectAllBlocks: () => void; +}; + +export function useExportSelection({ + schema, + enabled = true, +}: UseExportSelectionOptions): UseExportSelectionResult { + const [allItemTypes, setAllItemTypes] = useState(); + const [selectedIds, setSelectedIds] = useState([]); + const [selectedItemTypes, setSelectedItemTypes] = useState< + SchemaTypes.ItemType[] + >([]); + + useEffect(() => { + if (!enabled) { + return; + } + + let cancelled = false; + async function load() { + const types = await schema.getAllItemTypes(); + if (!cancelled) { + setAllItemTypes(types); + } + } + + void load(); + + return () => { + cancelled = true; + }; + }, [schema, enabled]); + + useEffect(() => { + if (!enabled) { + return; + } + + if (selectedIds.length === 0) { + setSelectedItemTypes([]); + return; + } + + let cancelled = false; + async function resolve() { + const list: SchemaTypes.ItemType[] = []; + for (const id of selectedIds) { + const itemType = await schema.getItemTypeById(id); + if (cancelled) { + return; + } + list.push(itemType); + } + if (!cancelled) { + setSelectedItemTypes(list); + } + } + + void resolve(); + + return () => { + cancelled = true; + }; + }, [schema, enabled, selectedIds.join('-')]); + + const selectAllModels = useCallback(() => { + if (!allItemTypes) { + return; + } + + setSelectedIds( + allItemTypes + .filter((it) => !it.attributes.modular_block) + .map((it) => it.id), + ); + }, [allItemTypes]); + + const selectAllBlocks = useCallback(() => { + if (!allItemTypes) { + return; + } + + setSelectedIds( + allItemTypes + .filter((it) => it.attributes.modular_block) + .map((it) => it.id), + ); + }, [allItemTypes]); + + return { + allItemTypes, + selectedIds, + selectedItemTypes, + setSelectedIds, + selectAllModels, + selectAllBlocks, + }; +} diff --git a/import-export-schema/src/shared/hooks/useProjectSchema.ts b/import-export-schema/src/shared/hooks/useProjectSchema.ts new file mode 100644 index 00000000..1d769baa --- /dev/null +++ b/import-export-schema/src/shared/hooks/useProjectSchema.ts @@ -0,0 +1,36 @@ +import { useMemo } from 'react'; +import type { Client, ClientConfigOptions } from '@datocms/cma-client'; +import type { + RenderConfigScreenCtx, + RenderPageCtx, +} from 'datocms-plugin-sdk'; +import { ProjectSchema } from '@/utils/ProjectSchema'; +import { useCmaClient } from './useCmaClient'; + +type AuthCtx = + | Pick + | Pick; + +type UseProjectSchemaOptions = { + clientOverrides?: Partial; + existingClient?: Client; +}; + +/** + * Provides a memoized ProjectSchema instance keyed by CMA client identity. + * If `existingClient` is passed, the hook will wrap that client instead of + * creating a new one. The resulting schema caches API calls internally, so + * sharing the instance across the component tree avoids redundant requests. + */ +export function useProjectSchema( + ctx: AuthCtx, + options: UseProjectSchemaOptions = {}, +): ProjectSchema { + const resolvedClient = useCmaClient(ctx, { + overrides: options.clientOverrides, + }); + + const targetClient = options.existingClient ?? resolvedClient; + + return useMemo(() => new ProjectSchema(targetClient), [targetClient]); +} diff --git a/import-export-schema/src/shared/tasks/useLongTask.ts b/import-export-schema/src/shared/tasks/useLongTask.ts new file mode 100644 index 00000000..4aa81940 --- /dev/null +++ b/import-export-schema/src/shared/tasks/useLongTask.ts @@ -0,0 +1,120 @@ +import { useMemo, useRef, useState } from 'react'; + +export type LongTaskStatus = + | 'idle' + | 'running' + | 'cancelling' + | 'completed' + | 'failed'; + +export type LongTaskProgress = { + label?: string; + done?: number; + total?: number; +}; + +export type LongTaskState = { + status: LongTaskStatus; + cancelRequested: boolean; + progress: LongTaskProgress; + error?: Error; +}; + +const initialState: LongTaskState = { + status: 'idle', + cancelRequested: false, + progress: {}, + error: undefined, +}; + +export type LongTaskController = { + start(progress?: LongTaskProgress): void; + setProgress(update: LongTaskProgress): void; + complete(progress?: LongTaskProgress): void; + fail(error: unknown): void; + requestCancel(): void; + reset(): void; + isCancelRequested(): boolean; +}; + +function toError(error: unknown): Error { + if (error instanceof Error) return error; + return new Error(typeof error === 'string' ? error : 'Unknown error'); +} + +function mergeProgress( + prev: LongTaskProgress, + update: LongTaskProgress, +): LongTaskProgress { + return { + ...prev, + ...update, + }; +} + +/** + * Hook for managing long-running async tasks (imports, exports, etc.). + * Provides declarative state for progress overlays and cancel handling. + */ +export type UseLongTaskResult = { + state: LongTaskState; + controller: LongTaskController; +}; + +export function useLongTask(): UseLongTaskResult { + const [state, setState] = useState(initialState); + const cancelRequestedRef = useRef(false); + + const controller = useMemo(() => { + return { + start(progress) { + cancelRequestedRef.current = false; + setState({ + status: 'running', + cancelRequested: false, + progress: progress ?? {}, + error: undefined, + }); + }, + setProgress(update) { + setState((prev) => ({ + ...prev, + progress: mergeProgress(prev.progress, update), + })); + }, + complete(progress) { + setState((prev) => ({ + status: 'completed', + cancelRequested: prev.cancelRequested, + progress: mergeProgress(prev.progress, progress ?? {}), + error: undefined, + })); + }, + fail(error) { + setState((prev) => ({ + status: 'failed', + cancelRequested: prev.cancelRequested, + progress: prev.progress, + error: toError(error), + })); + }, + requestCancel() { + cancelRequestedRef.current = true; + setState((prev) => ({ + ...prev, + status: prev.status === 'running' ? 'cancelling' : prev.status, + cancelRequested: true, + })); + }, + reset() { + cancelRequestedRef.current = false; + setState(initialState); + }, + isCancelRequested() { + return cancelRequestedRef.current; + }, + }; + }, []); + + return { state, controller }; +} diff --git a/import-export-schema/src/utils/graph/buildGraph.ts b/import-export-schema/src/utils/graph/buildGraph.ts index 8991fb63..ce5189f6 100644 --- a/import-export-schema/src/utils/graph/buildGraph.ts +++ b/import-export-schema/src/utils/graph/buildGraph.ts @@ -4,7 +4,7 @@ import { buildEdgesForItemType } from '@/utils/graph/edges'; import { buildItemTypeNode, buildPluginNode } from '@/utils/graph/nodes'; import { rebuildGraphWithPositionsFromHierarchy } from '@/utils/graph/rebuildGraphWithPositionsFromHierarchy'; import { deterministicGraphSort } from '@/utils/graph/sort'; -import type { Graph } from '@/utils/graph/types'; +import type { Graph, SchemaProgressUpdate } from '@/utils/graph/types'; import type { ISchemaSource } from '@/utils/schema/ISchemaSource'; type BuildGraphOptions = { @@ -12,12 +12,7 @@ type BuildGraphOptions = { initialItemTypes: SchemaTypes.ItemType[]; selectedItemTypeIds?: string[]; // export use-case to include edges itemTypeIdsToSkip?: string[]; // import use-case to avoid edges - onProgress?: (update: { - done: number; - total: number; - label: string; - phase?: 'scan' | 'build'; - }) => void; + onProgress?: (update: SchemaProgressUpdate) => void; }; export async function buildGraph({ diff --git a/import-export-schema/src/utils/graph/index.ts b/import-export-schema/src/utils/graph/index.ts new file mode 100644 index 00000000..322d1079 --- /dev/null +++ b/import-export-schema/src/utils/graph/index.ts @@ -0,0 +1,9 @@ +export * from './analysis'; +export * from './buildGraph'; +export * from './buildHierarchyNodes'; +export * from './dependencies'; +export * from './edges'; +export * from './nodes'; +export * from './rebuildGraphWithPositionsFromHierarchy'; +export * from './sort'; +export * from './types'; diff --git a/import-export-schema/src/utils/graph/types.ts b/import-export-schema/src/utils/graph/types.ts index d4d1394f..8f8d21d4 100644 --- a/import-export-schema/src/utils/graph/types.ts +++ b/import-export-schema/src/utils/graph/types.ts @@ -18,3 +18,10 @@ export type Graph = { nodes: Array; edges: Array; }; + +export type SchemaProgressUpdate = { + done: number; + total: number; + label: string; + phase?: 'scan' | 'build'; +}; diff --git a/import-export-schema/tsconfig.app.tsbuildinfo b/import-export-schema/tsconfig.app.tsbuildinfo index a6ccafa2..8796e417 100644 --- a/import-export-schema/tsconfig.app.tsbuildinfo +++ b/import-export-schema/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/field.tsx","./src/components/fieldedgerenderer.tsx","./src/components/graphcanvas.tsx","./src/components/itemtypenoderenderer.tsx","./src/components/pluginnoderenderer.tsx","./src/components/progressstallnotice.tsx","./src/components/bezier.ts","./src/entrypoints/config/index.tsx","./src/entrypoints/exporthome/index.tsx","./src/entrypoints/exportpage/entitiestoexportcontext.ts","./src/entrypoints/exportpage/exportitemtypenoderenderer.tsx","./src/entrypoints/exportpage/exportpluginnoderenderer.tsx","./src/entrypoints/exportpage/exportschema.ts","./src/entrypoints/exportpage/inner.tsx","./src/entrypoints/exportpage/largeselectionview.tsx","./src/entrypoints/exportpage/buildexportdoc.ts","./src/entrypoints/exportpage/buildgraphfromschema.ts","./src/entrypoints/exportpage/index.tsx","./src/entrypoints/exportpage/useanimatednodes.tsx","./src/entrypoints/importpage/filedropzone.tsx","./src/entrypoints/importpage/importitemtypenoderenderer.tsx","./src/entrypoints/importpage/importpluginnoderenderer.tsx","./src/entrypoints/importpage/inner.tsx","./src/entrypoints/importpage/largeselectionview.tsx","./src/entrypoints/importpage/resolutionsform.tsx","./src/entrypoints/importpage/selectedentitycontext.tsx","./src/entrypoints/importpage/buildgraphfromexportdoc.ts","./src/entrypoints/importpage/buildimportdoc.ts","./src/entrypoints/importpage/importschema.ts","./src/entrypoints/importpage/index.tsx","./src/entrypoints/importpage/conflictsmanager/collapsible.tsx","./src/entrypoints/importpage/conflictsmanager/conflictscontext.ts","./src/entrypoints/importpage/conflictsmanager/itemtypeconflict.tsx","./src/entrypoints/importpage/conflictsmanager/pluginconflict.tsx","./src/entrypoints/importpage/conflictsmanager/buildconflicts.ts","./src/entrypoints/importpage/conflictsmanager/index.tsx","./src/types/lodash-es.d.ts","./src/utils/projectschema.ts","./src/utils/createcmaclient.ts","./src/utils/downloadjson.ts","./src/utils/emojiagnosticsorter.ts","./src/utils/icons.tsx","./src/utils/isdefined.ts","./src/utils/render.tsx","./src/utils/types.ts","./src/utils/datocms/appearance.ts","./src/utils/datocms/fieldtypeinfo.ts","./src/utils/datocms/schema.ts","./src/utils/datocms/validators.ts","./src/utils/graph/analysis.ts","./src/utils/graph/buildgraph.ts","./src/utils/graph/buildhierarchynodes.ts","./src/utils/graph/dependencies.ts","./src/utils/graph/edges.ts","./src/utils/graph/nodes.ts","./src/utils/graph/rebuildgraphwithpositionsfromhierarchy.ts","./src/utils/graph/sort.ts","./src/utils/graph/types.ts","./src/utils/schema/exportschemasource.ts","./src/utils/schema/ischemasource.ts","./src/utils/schema/projectschemasource.ts"],"version":"5.7.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/exportstartpanel.tsx","./src/components/field.tsx","./src/components/fieldedgerenderer.tsx","./src/components/graphcanvas.tsx","./src/components/itemtypenoderenderer.tsx","./src/components/pluginnoderenderer.tsx","./src/components/progressoverlay.tsx","./src/components/progressstallnotice.tsx","./src/components/taskprogressoverlay.tsx","./src/components/bezier.ts","./src/entrypoints/config/index.tsx","./src/entrypoints/exporthome/index.tsx","./src/entrypoints/exportpage/entitiestoexportcontext.ts","./src/entrypoints/exportpage/exportitemtypenoderenderer.tsx","./src/entrypoints/exportpage/exportpluginnoderenderer.tsx","./src/entrypoints/exportpage/exportschema.ts","./src/entrypoints/exportpage/inner.tsx","./src/entrypoints/exportpage/largeselectionview.tsx","./src/entrypoints/exportpage/buildexportdoc.ts","./src/entrypoints/exportpage/buildgraphfromschema.ts","./src/entrypoints/exportpage/index.tsx","./src/entrypoints/exportpage/useanimatednodes.tsx","./src/entrypoints/exportpage/useexportgraph.ts","./src/entrypoints/importpage/filedropzone.tsx","./src/entrypoints/importpage/importitemtypenoderenderer.tsx","./src/entrypoints/importpage/importpluginnoderenderer.tsx","./src/entrypoints/importpage/inner.tsx","./src/entrypoints/importpage/largeselectionview.tsx","./src/entrypoints/importpage/resolutionsform.tsx","./src/entrypoints/importpage/selectedentitycontext.tsx","./src/entrypoints/importpage/buildgraphfromexportdoc.ts","./src/entrypoints/importpage/buildimportdoc.ts","./src/entrypoints/importpage/importschema.ts","./src/entrypoints/importpage/index.tsx","./src/entrypoints/importpage/conflictsmanager/collapsible.tsx","./src/entrypoints/importpage/conflictsmanager/conflictscontext.ts","./src/entrypoints/importpage/conflictsmanager/itemtypeconflict.tsx","./src/entrypoints/importpage/conflictsmanager/pluginconflict.tsx","./src/entrypoints/importpage/conflictsmanager/buildconflicts.ts","./src/entrypoints/importpage/conflictsmanager/index.tsx","./src/shared/hooks/usecmaclient.ts","./src/shared/hooks/useconflictsbuilder.ts","./src/shared/hooks/useexportallhandler.ts","./src/shared/hooks/useexportselection.ts","./src/shared/hooks/useprojectschema.ts","./src/shared/tasks/uselongtask.ts","./src/types/lodash-es.d.ts","./src/utils/projectschema.ts","./src/utils/createcmaclient.ts","./src/utils/downloadjson.ts","./src/utils/emojiagnosticsorter.ts","./src/utils/icons.tsx","./src/utils/isdefined.ts","./src/utils/render.tsx","./src/utils/types.ts","./src/utils/datocms/appearance.ts","./src/utils/datocms/fieldtypeinfo.ts","./src/utils/datocms/schema.ts","./src/utils/datocms/validators.ts","./src/utils/graph/analysis.ts","./src/utils/graph/buildgraph.ts","./src/utils/graph/buildhierarchynodes.ts","./src/utils/graph/dependencies.ts","./src/utils/graph/edges.ts","./src/utils/graph/index.ts","./src/utils/graph/nodes.ts","./src/utils/graph/rebuildgraphwithpositionsfromhierarchy.ts","./src/utils/graph/sort.ts","./src/utils/graph/types.ts","./src/utils/schema/exportschemasource.ts","./src/utils/schema/ischemasource.ts","./src/utils/schema/projectschemasource.ts"],"version":"5.7.3"} \ No newline at end of file From f18ca1db1debf7f0068f8883303bf4b62fa5e7a0 Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Thu, 18 Sep 2025 12:02:16 +0200 Subject: [PATCH 07/36] fix dependencies --- import-export-schema/AGENTS.md | 86 +---- .../src/components/ExportStartPanel.tsx | 9 +- import-export-schema/src/components/Field.tsx | 4 + .../src/components/FieldEdgeRenderer.tsx | 6 +- .../src/components/GraphCanvas.tsx | 4 + .../src/components/ItemTypeNodeRenderer.tsx | 9 + .../src/components/PluginNodeRenderer.tsx | 4 + .../src/components/ProgressOverlay.tsx | 7 +- .../src/components/ProgressStallNotice.tsx | 4 + .../src/components/TaskOverlayStack.tsx | 23 ++ .../src/components/TaskProgressOverlay.tsx | 6 +- import-export-schema/src/components/bezier.ts | 17 +- .../src/entrypoints/Config/index.tsx | 2 + .../src/entrypoints/ExportHome/index.tsx | 171 ++++----- .../DependencyActionsPanel.module.css | 5 + .../ExportPage/DependencyActionsPanel.tsx | 59 +++ .../ExportPage/EntitiesToExportContext.ts | 3 + .../ExportPage/ExportItemTypeNodeRenderer.tsx | 3 + .../ExportPage/ExportPluginNodeRenderer.tsx | 3 + .../entrypoints/ExportPage/ExportSchema.ts | 3 + .../ExportPage/ExportToolbar.module.css | 9 + .../entrypoints/ExportPage/ExportToolbar.tsx | 44 +++ .../src/entrypoints/ExportPage/Inner.tsx | 292 +++++--------- .../ExportPage/LargeSelectionView.tsx | 6 + .../entrypoints/ExportPage/buildExportDoc.ts | 6 + .../ExportPage/buildGraphFromSchema.ts | 4 + .../src/entrypoints/ExportPage/index.tsx | 221 ++++------- .../ExportPage/useAnimatedNodes.tsx | 3 + .../entrypoints/ExportPage/useExportGraph.ts | 58 +-- .../ConflictsManager/Collapsible.tsx | 3 + .../ConflictsManager/ConflictsContext.ts | 3 + .../ConflictsManager/ItemTypeConflict.tsx | 4 + .../ConflictsManager/PluginConflict.tsx | 1 + .../ConflictsManager/buildConflicts.ts | 4 + .../ImportPage/ConflictsManager/index.tsx | 5 +- .../entrypoints/ImportPage/FileDropZone.tsx | 3 + .../ImportPage/ImportItemTypeNodeRenderer.tsx | 3 + .../src/entrypoints/ImportPage/Inner.tsx | 12 +- .../ImportPage/LargeSelectionView.tsx | 5 + .../ImportPage/ResolutionsForm.tsx | 12 + .../ImportPage/SelectedEntityContext.tsx | 3 + .../ImportPage/buildGraphFromExportDoc.ts | 1 + .../entrypoints/ImportPage/buildImportDoc.ts | 4 + .../entrypoints/ImportPage/importSchema.ts | 86 +++-- .../src/entrypoints/ImportPage/index.tsx | 362 ++++++++---------- .../src/shared/constants/graph.ts | 1 + .../src/shared/hooks/useCmaClient.ts | 7 +- .../src/shared/hooks/useConflictsBuilder.ts | 21 +- .../src/shared/hooks/useExportAllHandler.ts | 10 +- .../src/shared/hooks/useExportSelection.ts | 43 +-- .../src/shared/hooks/useProjectSchema.ts | 7 +- .../src/shared/hooks/useSchemaExportTask.ts | 97 +++++ .../src/shared/tasks/useLongTask.ts | 7 +- .../src/utils/ProjectSchema.ts | 3 + .../src/utils/createCmaClient.ts | 1 + .../src/utils/datocms/fieldTypeInfo.ts | 2 + .../src/utils/datocms/schema.ts | 5 + .../src/utils/datocms/validators.ts | 2 + import-export-schema/src/utils/debug.ts | 31 ++ .../src/utils/emojiAgnosticSorter.ts | 4 + .../src/utils/graph/analysis.ts | 2 + .../src/utils/graph/buildGraph.ts | 35 +- .../src/utils/graph/buildHierarchyNodes.ts | 61 ++- .../src/utils/graph/dependencies.ts | 70 +++- import-export-schema/src/utils/graph/edges.ts | 1 + import-export-schema/src/utils/graph/index.ts | 1 + import-export-schema/src/utils/graph/nodes.ts | 1 + .../rebuildGraphWithPositionsFromHierarchy.ts | 3 + import-export-schema/src/utils/graph/sort.ts | 1 + import-export-schema/src/utils/graph/types.ts | 1 + import-export-schema/src/utils/icons.tsx | 1 + import-export-schema/src/utils/isDefined.ts | 1 + import-export-schema/src/utils/render.tsx | 1 + .../src/utils/schema/ExportSchemaSource.ts | 1 + .../src/utils/schema/ISchemaSource.ts | 1 + .../src/utils/schema/ProjectSchemaSource.ts | 1 + import-export-schema/src/utils/types.ts | 1 + .../tmp-dist/src/utils/graph/dependencies.js | 49 +++ .../tmp-dist/src/utils/graph/types.js | 7 + .../tmp-dist/tmp-test-deps.js | 55 +++ import-export-schema/tmp-test-deps.ts | 59 +++ import-export-schema/tsconfig.app.tsbuildinfo | 2 +- 82 files changed, 1312 insertions(+), 866 deletions(-) create mode 100644 import-export-schema/src/components/TaskOverlayStack.tsx create mode 100644 import-export-schema/src/entrypoints/ExportPage/DependencyActionsPanel.module.css create mode 100644 import-export-schema/src/entrypoints/ExportPage/DependencyActionsPanel.tsx create mode 100644 import-export-schema/src/entrypoints/ExportPage/ExportToolbar.module.css create mode 100644 import-export-schema/src/entrypoints/ExportPage/ExportToolbar.tsx create mode 100644 import-export-schema/src/shared/constants/graph.ts create mode 100644 import-export-schema/src/shared/hooks/useSchemaExportTask.ts create mode 100644 import-export-schema/src/utils/debug.ts create mode 100644 import-export-schema/tmp-dist/src/utils/graph/dependencies.js create mode 100644 import-export-schema/tmp-dist/src/utils/graph/types.js create mode 100644 import-export-schema/tmp-dist/tmp-test-deps.js create mode 100644 import-export-schema/tmp-test-deps.ts diff --git a/import-export-schema/AGENTS.md b/import-export-schema/AGENTS.md index 6418babf..19f36ae9 100644 --- a/import-export-schema/AGENTS.md +++ b/import-export-schema/AGENTS.md @@ -1,75 +1,29 @@ # Repository Guidelines ## Project Structure & Module Organization -- `src/entrypoints/`: Plugin pages (`Config`, `ExportPage`, `ImportPage`) with local helpers and `index.tsx`. -- `src/components/`: Reusable React components shared across entrypoints. -- `src/utils/`: Helpers (schema builders, rendering, types, download utilities). -- `src/icons/`: SVG assets. -- `public/`, `index.html`: Vite app shell; production build in `dist/` (plugin entry is `dist/index.html`). -- `docs/`: Cover/preview assets included in the package. +- `src/entrypoints/` hosts the Config, Export, and Import plugin pages plus local helpers; `index.tsx` wires each page to DatoCMS. +- `src/components/` gathers shared React pieces such as overlays, selectors, and graph controls. +- `src/utils/` contains schema builders, progress types, download helpers, and graph utilities; extend existing modules before adding new single-use files. +- `public/` and `index.html` power the Vite shell; the production entry lives at `dist/index.html`. Keep `dist/` build-only. +- `docs/` stores marketplace assets and baseline QA notes (`docs/refactor-baseline.md`). ## Build, Test, and Development Commands -- `npm run build` — check for errors -Notes: Use Node 18+ and npm (repo uses `package-lock.json`). +- `npm run build` ## Coding Style & Naming Conventions -- Language: TypeScript + React 18; Vite. -- Formatting: Biome; 2-space indent, single quotes, organized imports. -- Naming: PascalCase for components/files (e.g., `ExportPluginNodeRenderer.tsx`); camelCase for functions/vars; PascalCase for types. -- Styles: Prefer CSS modules when present (e.g., `styles.module.css`). -- UI: Follow DatoCMS-like design using `datocms-react-ui` and `ctx.theme` vars. - -I want to make a refactor to make this whole code way smaller, as DRY as possible, and as legible and simple as possible +- Language stack: TypeScript + React 18 with Vite. +- Follow Biome defaults (2-space indent, single quotes, sorted imports). Run `npm run format` prior to commits. +- Use PascalCase for components (`ExportStartPanel.tsx`), camelCase for functions/variables, and PascalCase for types/interfaces. +- Prefer CSS modules; reference class names via `styles.` and reuse design tokens from `datocms-react-ui`. ## Security & Configuration Tips -- Never hardcode or log tokens; rely on `@datocms/cma-client`. -- Avoid mutating existing schema objects; make additive, safe changes. -- Do not commit secrets or personal access tokens. Review diffs for sensitive data. - -# Refactor Roadmap (Sept 2025) - -## Stage Overview -1. Baseline & Safeguards - - Capture current UX (screenshots, flows, manual QA list). - - Ensure `npm run build` passes; note regressions. -2. Shared Infrastructure Layer - - Hooks for CMA/schema access and async state. - - Shared progress/task utilities and contexts. -3. UI Composition Cleanup - - Shared layout, blank-slate, and selector components. - - Replace inline overlay markup with reusable components. -4. Export Workflow Refactor - - Consolidate ExportHome/ExportPage logic via hooks. - - Break `Inner` into smaller focused components/utilities. -5. Import Workflow Refactor - - Mirror export improvements; simplify conflicts UI. -6. Graph Utilities Consolidation - - Centralize analysis helpers; document graph contracts. -7. Styling Rationalization - - Move inline styles to CSS modules or tokens. - - Normalize color/spacing variables. -8. Types & Utilities Cleanup - - Strengthen progress/event types; remove duplication. -9. Validation & Documentation - - Run build + manual QA per stage; update README/notes. - -## Active Checklist -- [x] Stage 0: Baseline docs + QA scenarios recorded -- [x] Stage 1: Shared infrastructure primitives extracted -- [x] Stage 2: Common UI components introduced -- [x] Stage 3: Export workflow streamlined -- [x] Stage 4: Import workflow streamlined -- [x] Stage 5: Graph utilities consolidated -- [x] Stage 6: Styling centralized -- [x] Stage 7: Type/util cleanup complete -- [x] Stage 8: Validation + docs refreshed - -## Worklog -- 2025-09-17: Created refactor roadmap, documented baseline QA in `docs/refactor-baseline.md`, introduced shared hooks (`useCmaClient`, `useProjectSchema`) plus long-task controller (`useLongTask`), and updated export/import entrypoints to rely on the shared schema hook. -- 2025-09-17: Replaced bespoke busy/progress state in ExportHome, ExportPage, and ImportPage with `useLongTask`, unified cancel handling, and refreshed overlays to read from shared controllers (build verified). -- 2025-09-17: Added reusable `ProgressOverlay`, `ExportStartPanel`, and `useExportAllHandler` to DRY export/import entrypoints; refactored `ExportPage/Inner` to use `useExportGraph` for graph prep. -- 2025-09-17: Streamlined import/export panels via shared hooks (`useExportAllHandler`, `useConflictsBuilder`) and components, leaving ImportPage/ExportHome with leaner startup flows. -- 2025-09-17: Centralized graph progress wiring via `useExportGraph`, shared `SchemaProgressUpdate` type, and index exports for graph utilities. -- 2025-09-17: Introduced `ProgressOverlay` styling tokens (`--overlay-gradient`, `.progress-overlay`) and DRY `ExportStartPanel`, eliminating repeated inline overlay/selector styles. -- 2025-09-17: Refreshed README + docs with new shared infrastructure and updated baseline observations; build validated via `npm run build`. -- 2025-09-17: Added reusable progress types (`SchemaProgressUpdate`, `LongTaskProgress`) and shared hooks (`useConflictsBuilder`, `useExportGraph`) to remove duplicated state management and tighten typing across workflows. +- Never log or hardcode tokens; rely on `@datocms/cma-client` injected credentials. +- Avoid mutating existing schema objects in place; prefer additive or cloned changes to prevent data loss. +- Inspect diffs for accidental secrets or large asset files before pushing. + +## Active Engineering Tasks (September 17, 2025) +- [x] Deduplicate export task handling by introducing a shared helper/hook that wraps `buildExportDoc` and `useLongTask`. +- [x] Centralize long-task overlay composition so entrypoints declare overlays declaratively instead of repeating JSX. +- [x] Break down `src/entrypoints/ExportPage/Inner.tsx` into smaller modules and move dependency-closure logic to a shared utility. +- [x] Improve `useExportSelection` to reuse cached item types instead of per-id fetch loops. +- [x] Tackle remaining polish items (debug logging helper, shared graph threshold config, styling cleanup) to keep the codebase DRY. diff --git a/import-export-schema/src/components/ExportStartPanel.tsx b/import-export-schema/src/components/ExportStartPanel.tsx index aaa2dfdd..0ae3d06f 100644 --- a/import-export-schema/src/components/ExportStartPanel.tsx +++ b/import-export-schema/src/components/ExportStartPanel.tsx @@ -27,6 +27,10 @@ type Props = { exportAllLabel?: string; }; +/** + * Blank-slate panel that lets editors pick the starting set of models/blocks and kick + * off either a targeted or full-schema export. + */ export function ExportStartPanel({ selectId, itemTypes, @@ -56,6 +60,7 @@ export function ExportStartPanel({ [itemTypes], ); + // React-Select expects objects; keep them memoized so the control stays controlled. const value = useMemo( () => options.filter((opt) => selectedIds.includes(opt.value)), [options, selectedIds], @@ -81,7 +86,9 @@ export function ExportStartPanel({ }} value={value} onChange={(multi) => - onSelectedIdsChange(Array.isArray(multi) ? multi.map((o) => o.value) : []) + onSelectedIdsChange( + Array.isArray(multi) ? multi.map((o) => o.value) : [], + ) } />
    diff --git a/import-export-schema/src/components/Field.tsx b/import-export-schema/src/components/Field.tsx index 3b9dcbdb..326b139b 100644 --- a/import-export-schema/src/components/Field.tsx +++ b/import-export-schema/src/components/Field.tsx @@ -5,11 +5,15 @@ import { fieldTypeGroups, } from '@/utils/datocms/schema'; +/** + * Displays a field summary with consistent iconography and type information. + */ export function Field({ field }: { field: SchemaTypes.Field }) { const group = fieldTypeGroups.find((g) => g.types.includes(field.attributes.field_type), ); + // Fallback to the generic JSON icon/color when the field type has no group match. const { IconComponent, bgColor, fgColor } = fieldGroupColors[group ? group.name : 'json']; diff --git a/import-export-schema/src/components/FieldEdgeRenderer.tsx b/import-export-schema/src/components/FieldEdgeRenderer.tsx index e0b6d616..b853f1aa 100644 --- a/import-export-schema/src/components/FieldEdgeRenderer.tsx +++ b/import-export-schema/src/components/FieldEdgeRenderer.tsx @@ -10,6 +10,9 @@ import { getBezierPath, getSelfPath } from './bezier'; export type FieldEdge = Edge<{ fields: SchemaTypes.Field[] }, 'field'>; +/** + * Custom React Flow edge that renders a tooltip listing the fields linking two nodes. + */ export function FieldEdgeRenderer({ id, source, @@ -28,7 +31,8 @@ export function FieldEdgeRenderer({ const [edgePath, labelX, labelY] = source === target - ? getSelfPath({ + ? // Self-references loop back to the node so the label has space to render. + getSelfPath({ sourceX, sourceY, sourcePosition, diff --git a/import-export-schema/src/components/GraphCanvas.tsx b/import-export-schema/src/components/GraphCanvas.tsx index 03afa878..88e69f5b 100644 --- a/import-export-schema/src/components/GraphCanvas.tsx +++ b/import-export-schema/src/components/GraphCanvas.tsx @@ -11,6 +11,9 @@ type Props = { fitView?: boolean; }; +/** + * Shared React Flow canvas configuration to keep export/import graphs consistent. + */ export function GraphCanvas({ graph, nodeTypes, @@ -26,6 +29,7 @@ export function GraphCanvas({ nodes={graph.nodes} edges={graph.edges} onNodeClick={onNodeClick} + // Keep the canvas read-only; selections are handled by higher-level components. nodesDraggable={false} nodesConnectable={false} zoomOnDoubleClick={false} diff --git a/import-export-schema/src/components/ItemTypeNodeRenderer.tsx b/import-export-schema/src/components/ItemTypeNodeRenderer.tsx index 9016577b..6880c8d3 100644 --- a/import-export-schema/src/components/ItemTypeNodeRenderer.tsx +++ b/import-export-schema/src/components/ItemTypeNodeRenderer.tsx @@ -23,6 +23,10 @@ export type ItemTypeNode = Node< 'itemType' >; +/** + * Renders a fieldset summary inside the item-type tooltip, keeping field ordering in sync + * with their schema positions. + */ function Fieldset({ fieldset, allFields, @@ -47,8 +51,13 @@ function Fieldset({ ); } +// Show extra metadata once the canvas is sufficiently zoomed in. const zoomSelector = (s: ReactFlowState) => s.transform[2] >= 0.8; +/** + * Node renderer used by React Flow to display a DatoCMS model/block with a hoverable + * field list and API key details that show when zoomed in. + */ export function ItemTypeNodeRenderer({ data: { itemType, fields, fieldsets }, className, diff --git a/import-export-schema/src/components/PluginNodeRenderer.tsx b/import-export-schema/src/components/PluginNodeRenderer.tsx index fe327e72..66165960 100644 --- a/import-export-schema/src/components/PluginNodeRenderer.tsx +++ b/import-export-schema/src/components/PluginNodeRenderer.tsx @@ -17,8 +17,12 @@ export type PluginNode = Node< 'plugin' >; +// Only reveal meta information when zoomed in far enough. const zoomSelector = (s: ReactFlowState) => s.transform[2] >= 0.8; +/** + * React Flow node renderer used to visualize installed plugins within dependency graphs. + */ export function PluginNodeRenderer({ data: { plugin }, className, diff --git a/import-export-schema/src/components/ProgressOverlay.tsx b/import-export-schema/src/components/ProgressOverlay.tsx index e227ad4f..6c5f3027 100644 --- a/import-export-schema/src/components/ProgressOverlay.tsx +++ b/import-export-schema/src/components/ProgressOverlay.tsx @@ -1,5 +1,5 @@ -import type { PropsWithChildren } from 'react'; import { Button } from 'datocms-react-ui'; +import type { PropsWithChildren } from 'react'; import ProgressStallNotice from './ProgressStallNotice'; type CancelProps = { @@ -27,6 +27,7 @@ type Props = { overlayZIndex?: number; }; +// Ensure the progress bar always renders with a minimal width when progress is unknown. function clampPercent(value: number | undefined): number { if (typeof value !== 'number' || Number.isNaN(value)) return 0.1; return Math.min(1, Math.max(0, value)); @@ -46,6 +47,10 @@ function resolvePercent(progress: ProgressData): number { return 0.1; } +/** + * Fullscreen overlay that shows a determinate progress bar, optional stall warning, + * and cancel affordance while long-running tasks execute. + */ export function ProgressOverlay({ title, subtitle, diff --git a/import-export-schema/src/components/ProgressStallNotice.tsx b/import-export-schema/src/components/ProgressStallNotice.tsx index 754a4c42..bf2b274c 100644 --- a/import-export-schema/src/components/ProgressStallNotice.tsx +++ b/import-export-schema/src/components/ProgressStallNotice.tsx @@ -9,6 +9,10 @@ type Props = { message?: string; }; +/** + * Surface a gentle warning when long-running tasks appear stuck, while avoiding + * false positives during the initial idle period. + */ export default function ProgressStallNotice({ current, thresholdMs = 8000, diff --git a/import-export-schema/src/components/TaskOverlayStack.tsx b/import-export-schema/src/components/TaskOverlayStack.tsx new file mode 100644 index 00000000..e9f919c5 --- /dev/null +++ b/import-export-schema/src/components/TaskOverlayStack.tsx @@ -0,0 +1,23 @@ +import { + TaskProgressOverlay, + type TaskProgressOverlayProps, +} from '@/components/TaskProgressOverlay'; + +type OverlayConfig = TaskProgressOverlayProps & { id?: string | number }; + +type Props = { + items: OverlayConfig[]; +}; + +/** + * Render a list of long-task overlays while keeping individual config definitions concise. + */ +export function TaskOverlayStack({ items }: Props) { + return ( + <> + {items.map(({ id, ...config }, index) => ( + + ))} + + ); +} diff --git a/import-export-schema/src/components/TaskProgressOverlay.tsx b/import-export-schema/src/components/TaskProgressOverlay.tsx index 9e3f4c09..e7da4c09 100644 --- a/import-export-schema/src/components/TaskProgressOverlay.tsx +++ b/import-export-schema/src/components/TaskProgressOverlay.tsx @@ -12,7 +12,7 @@ type CancelOptions = { disabled?: boolean; }; -type TaskProgressOverlayProps = { +export type TaskProgressOverlayProps = { task: UseLongTaskResult; title: string; subtitle: string | ((state: LongTaskState) => string); @@ -23,6 +23,10 @@ type TaskProgressOverlayProps = { cancel?: (state: LongTaskState) => CancelOptions | undefined; }; +/** + * Convenience wrapper over `ProgressOverlay` that wires up a `useLongTask` instance and + * allows callers to customize messaging via small callbacks. + */ export function TaskProgressOverlay({ task, title, diff --git a/import-export-schema/src/components/bezier.ts b/import-export-schema/src/components/bezier.ts index cfac9ba7..8fa9a589 100644 --- a/import-export-schema/src/components/bezier.ts +++ b/import-export-schema/src/components/bezier.ts @@ -1,5 +1,9 @@ import { type GetBezierPathParams, Position } from '@xyflow/react'; +/** + * Compute a point along an SVG arc using the endpoint parameterization. + * Ported from the SVG spec so self-loop edges can reserve space for labels. + */ function getPointOnSimpleArc( arcFraction: number, rx: number, @@ -66,6 +70,9 @@ function getPointOnSimpleArc( return [x, y]; } +/** + * Build a looped arc path for edges that start and end on the same node. + */ export function getSelfPath({ sourceX, targetX, @@ -94,6 +101,7 @@ export function getSelfPath({ ]; } +/** Classic cubic Bézier interpolation helper. */ function bezierInterpolation( t: number, p0: number, // start point @@ -115,6 +123,7 @@ function bezierInterpolation( ); } +// Inspired by React Flow: determine control point offsets based on curvature. function calculateControlOffset(distance: number, curvature: number) { if (distance >= 0) { return 0.5 * distance; @@ -131,6 +140,7 @@ type GetControlWithCurvatureParams = { c: number; }; +/** Deduce control points based on the port position and desired curvature. */ function getControlWithCurvature({ pos, x1, @@ -151,6 +161,7 @@ function getControlWithCurvature({ } } +/** Numerically approximate the arc length for a cubic Bézier segment. */ function calculateBezierLength( sourceX: number, sourceY: number, @@ -193,7 +204,7 @@ function calculateBezierLength( } /** - * Find the t value for which the arc length of the cubic Bézier curve equals the target length + * Find the `t` value where the curve length reaches `targetLength` using bisection. */ function getTForLength( sourceX: number, @@ -239,6 +250,9 @@ function getTForLength( return t; } +/** + * Generate a cubic Bézier path between two nodes and return coordinates suitable for React Flow. + */ export function getBezierPath({ sourceX, sourceY, @@ -266,6 +280,7 @@ export function getBezierPath({ c: curvature, }); + // Place the edge label ~40px along the line so multi-field tooltips stay close to the source. const labelPercent = getTForLength( targetX, targetY, diff --git a/import-export-schema/src/entrypoints/Config/index.tsx b/import-export-schema/src/entrypoints/Config/index.tsx index 6ac8176e..b9b4e3f0 100644 --- a/import-export-schema/src/entrypoints/Config/index.tsx +++ b/import-export-schema/src/entrypoints/Config/index.tsx @@ -6,6 +6,7 @@ type Props = { ctx: RenderConfigScreenCtx; }; +/** Lightweight anchor that uses the plugin navigation API instead of full page loads. */ function Link({ href, children }: { href: string; children: ReactNode }) { const ctx = useCtx(); @@ -22,6 +23,7 @@ function Link({ href, children }: { href: string; children: ReactNode }) { ); } +/** Configuration screen shown in Settings → Plugins. */ export function Config({ ctx }: Props) { const schemaUrl = `${ctx.isEnvironmentPrimary ? '' : `/environments/${ctx.environment}`}/schema`; const importUrl = `${ctx.isEnvironmentPrimary ? '' : `/environments/${ctx.environment}`}/configuration/p/${ctx.plugin.id}/pages/import`; diff --git a/import-export-schema/src/entrypoints/ExportHome/index.tsx b/import-export-schema/src/entrypoints/ExportHome/index.tsx index 54ff5c94..23815dc7 100644 --- a/import-export-schema/src/entrypoints/ExportHome/index.tsx +++ b/import-export-schema/src/entrypoints/ExportHome/index.tsx @@ -3,19 +3,22 @@ import type { RenderPageCtx } from 'datocms-plugin-sdk'; import { Canvas } from 'datocms-react-ui'; import { useId, useState } from 'react'; import { ExportStartPanel } from '@/components/ExportStartPanel'; -import { TaskProgressOverlay } from '@/components/TaskProgressOverlay'; +import { TaskOverlayStack } from '@/components/TaskOverlayStack'; +import { useExportAllHandler } from '@/shared/hooks/useExportAllHandler'; import { useExportSelection } from '@/shared/hooks/useExportSelection'; import { useProjectSchema } from '@/shared/hooks/useProjectSchema'; -import { useExportAllHandler } from '@/shared/hooks/useExportAllHandler'; +import { useSchemaExportTask } from '@/shared/hooks/useSchemaExportTask'; import { useLongTask } from '@/shared/tasks/useLongTask'; -import { downloadJSON } from '@/utils/downloadJson'; -import buildExportDoc from '../ExportPage/buildExportDoc'; import ExportInner from '../ExportPage/Inner'; type Props = { ctx: RenderPageCtx; }; +/** + * Landing page for the export workflow. Guides the user from the initial selection + * state into the detailed graph view while coordinating the long-running tasks. + */ export default function ExportHome({ ctx }: Props) { const exportInitialSelectId = useId(); const projectSchema = useProjectSchema(ctx); @@ -35,7 +38,11 @@ export default function ExportHome({ ctx }: Props) { const exportAllTask = useLongTask(); const exportPreparingTask = useLongTask(); - const exportSelectionTask = useLongTask(); + const { task: exportSelectionTask, runExport: runSelectionExport } = + useSchemaExportTask({ + schema: projectSchema, + ctx, + }); // Smoothed percent for preparing overlay to avoid jitter and changing max const [exportPreparingPercent, setExportPreparingPercent] = useState(0.1); @@ -54,7 +61,6 @@ export default function ExportHome({ ctx }: Props) { setExportStarted(true); }; - return ( @@ -111,69 +117,13 @@ export default function ExportHome({ ctx }: Props) { setExportStarted(false); exportPreparingTask.controller.reset(); }} - onExport={async (itemTypeIds, pluginIds) => { - try { - const total = pluginIds.length + itemTypeIds.length * 2; - exportSelectionTask.controller.start({ - done: 0, - total, - label: 'Preparing export…', - }); - let done = 0; - - const exportDoc = await buildExportDoc( - projectSchema, - exportInitialItemTypeIds[0], - itemTypeIds, - pluginIds, - { - onProgress: (label: string) => { - done += 1; - exportSelectionTask.controller.setProgress({ - done, - total, - label, - }); - }, - shouldCancel: () => - exportSelectionTask.controller.isCancelRequested(), - }, - ); - - if (exportSelectionTask.controller.isCancelRequested()) { - throw new Error('Export cancelled'); - } - - downloadJSON(exportDoc, { - fileName: 'export.json', - prettify: true, - }); - exportSelectionTask.controller.complete({ - done: total, - total, - label: 'Export completed', - }); - ctx.notice('Export completed successfully.'); - } catch (e) { - console.error('Selection export failed', e); - if ( - e instanceof Error && - e.message === 'Export cancelled' - ) { - exportSelectionTask.controller.complete({ - label: 'Export cancelled', - }); - ctx.notice('Export canceled'); - } else { - exportSelectionTask.controller.fail(e); - ctx.alert( - 'Could not complete the export. Please try again.', - ); - } - } finally { - exportSelectionTask.controller.reset(); - } - }} + onExport={(itemTypeIds, pluginIds) => + runSelectionExport({ + rootItemTypeId: exportInitialItemTypeIds[0], + itemTypeIds, + pluginIds, + }) + } /> )}
    @@ -182,43 +132,52 @@ export default function ExportHome({ ctx }: Props) { {/* Blocking overlay while exporting all */} - - progress.label ?? 'Loading project schema…' - } - cancel={() => ({ - label: 'Cancel export', - intent: exportAllTask.state.cancelRequested ? 'muted' : 'negative', - disabled: exportAllTask.state.cancelRequested, - onCancel: () => exportAllTask.controller.requestCancel(), - })} - /> - - progress.label ?? 'Preparing export…'} - percentOverride={exportPreparingPercent} - /> - - progress.label ?? 'Preparing export…'} - cancel={() => ({ - label: 'Cancel export', - intent: exportSelectionTask.state.cancelRequested ? 'muted' : 'negative', - disabled: exportSelectionTask.state.cancelRequested, - onCancel: () => exportSelectionTask.controller.requestCancel(), - })} + + progress.label ?? 'Loading project schema…', + cancel: () => ({ + label: 'Cancel export', + intent: exportAllTask.state.cancelRequested + ? 'muted' + : 'negative', + disabled: exportAllTask.state.cancelRequested, + onCancel: () => exportAllTask.controller.requestCancel(), + }), + }, + { + id: 'export-preparing', + task: exportPreparingTask, + title: 'Preparing export', + subtitle: + 'Sit tight, we’re setting up your models, blocks, and plugins…', + ariaLabel: 'Preparing export', + progressLabel: (progress) => progress.label ?? 'Preparing export…', + percentOverride: exportPreparingPercent, + }, + { + id: 'export-selection', + task: exportSelectionTask, + title: 'Exporting selection', + subtitle: 'Sit tight, we’re gathering models, blocks, and plugins…', + ariaLabel: 'Export in progress', + progressLabel: (progress) => progress.label ?? 'Preparing export…', + cancel: () => ({ + label: 'Cancel export', + intent: exportSelectionTask.state.cancelRequested + ? 'muted' + : 'negative', + disabled: exportSelectionTask.state.cancelRequested, + onCancel: () => exportSelectionTask.controller.requestCancel(), + }), + }, + ]} /> ); diff --git a/import-export-schema/src/entrypoints/ExportPage/DependencyActionsPanel.module.css b/import-export-schema/src/entrypoints/ExportPage/DependencyActionsPanel.module.css new file mode 100644 index 00000000..23868e9a --- /dev/null +++ b/import-export-schema/src/entrypoints/ExportPage/DependencyActionsPanel.module.css @@ -0,0 +1,5 @@ +.actions { + display: flex; + gap: 8px; + align-items: center; +} diff --git a/import-export-schema/src/entrypoints/ExportPage/DependencyActionsPanel.tsx b/import-export-schema/src/entrypoints/ExportPage/DependencyActionsPanel.tsx new file mode 100644 index 00000000..437d5c2a --- /dev/null +++ b/import-export-schema/src/entrypoints/ExportPage/DependencyActionsPanel.tsx @@ -0,0 +1,59 @@ +import { faFileExport } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Panel } from '@xyflow/react'; +import { Button, Spinner } from 'datocms-react-ui'; +import styles from './DependencyActionsPanel.module.css'; + +type Props = { + selectingDependencies: boolean; + areAllDependenciesSelected: boolean; + selectedItemCount: number; + onSelectAllDependencies: () => void; + onUnselectAllDependencies: () => void; + onExport: () => void; +}; + +/** + * Sticky controls rendered over the graph to handle dependency selection + export CTA. + */ +export function DependencyActionsPanel({ + selectingDependencies, + areAllDependenciesSelected, + selectedItemCount, + onSelectAllDependencies, + onUnselectAllDependencies, + onExport, +}: Props) { + return ( + +
    + + {selectingDependencies && } + + +
    +
    + ); +} diff --git a/import-export-schema/src/entrypoints/ExportPage/EntitiesToExportContext.ts b/import-export-schema/src/entrypoints/ExportPage/EntitiesToExportContext.ts index f94ba24b..69ac5ec5 100644 --- a/import-export-schema/src/entrypoints/ExportPage/EntitiesToExportContext.ts +++ b/import-export-schema/src/entrypoints/ExportPage/EntitiesToExportContext.ts @@ -1,5 +1,8 @@ import { createContext } from 'react'; +/** + * Provides the currently selected export entities so node renderers can mark excluded items. + */ export const EntitiesToExportContext = createContext< undefined | { itemTypeIds: string[]; pluginIds: string[] } >(undefined); diff --git a/import-export-schema/src/entrypoints/ExportPage/ExportItemTypeNodeRenderer.tsx b/import-export-schema/src/entrypoints/ExportPage/ExportItemTypeNodeRenderer.tsx index c63fae55..ef4e49ae 100644 --- a/import-export-schema/src/entrypoints/ExportPage/ExportItemTypeNodeRenderer.tsx +++ b/import-export-schema/src/entrypoints/ExportPage/ExportItemTypeNodeRenderer.tsx @@ -6,6 +6,9 @@ import { } from '@/components/ItemTypeNodeRenderer'; import { EntitiesToExportContext } from '@/entrypoints/ExportPage/EntitiesToExportContext'; +/** + * Highlights item-type nodes that fall outside the export selection. + */ export function ExportItemTypeNodeRenderer(props: NodeProps) { const { itemType } = props.data; const entitiesToExport = useContext(EntitiesToExportContext); diff --git a/import-export-schema/src/entrypoints/ExportPage/ExportPluginNodeRenderer.tsx b/import-export-schema/src/entrypoints/ExportPage/ExportPluginNodeRenderer.tsx index f498b271..c645560d 100644 --- a/import-export-schema/src/entrypoints/ExportPage/ExportPluginNodeRenderer.tsx +++ b/import-export-schema/src/entrypoints/ExportPage/ExportPluginNodeRenderer.tsx @@ -7,6 +7,9 @@ import { } from '@/components/PluginNodeRenderer'; import { EntitiesToExportContext } from '@/entrypoints/ExportPage/EntitiesToExportContext'; +/** + * Wraps the generic plugin renderer to flag nodes that are outside the current export selection. + */ export function ExportPluginNodeRenderer(props: NodeProps) { const { plugin } = props.data; diff --git a/import-export-schema/src/entrypoints/ExportPage/ExportSchema.ts b/import-export-schema/src/entrypoints/ExportPage/ExportSchema.ts index 04ae67d5..2738091a 100644 --- a/import-export-schema/src/entrypoints/ExportPage/ExportSchema.ts +++ b/import-export-schema/src/entrypoints/ExportPage/ExportSchema.ts @@ -4,6 +4,9 @@ import { findLinkedItemTypeIds } from '@/utils/datocms/schema'; import { isDefined } from '@/utils/isDefined'; import type { ExportDoc } from '@/utils/types'; +/** + * Normalizes an export document into easy-to-query maps, helping both import and graph builders. + */ export class ExportSchema { public rootItemType: SchemaTypes.ItemType; public rootItemTypes: SchemaTypes.ItemType[]; diff --git a/import-export-schema/src/entrypoints/ExportPage/ExportToolbar.module.css b/import-export-schema/src/entrypoints/ExportPage/ExportToolbar.module.css new file mode 100644 index 00000000..7902e697 --- /dev/null +++ b/import-export-schema/src/entrypoints/ExportPage/ExportToolbar.module.css @@ -0,0 +1,9 @@ +.toolbar { + padding: 8px var(--spacing-l); + display: flex; + align-items: center; +} + +.spacer { + flex: 1; +} diff --git a/import-export-schema/src/entrypoints/ExportPage/ExportToolbar.tsx b/import-export-schema/src/entrypoints/ExportPage/ExportToolbar.tsx new file mode 100644 index 00000000..f364b734 --- /dev/null +++ b/import-export-schema/src/entrypoints/ExportPage/ExportToolbar.tsx @@ -0,0 +1,44 @@ +import type { SchemaTypes } from '@datocms/cma-client'; +import { faXmark } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import type { RenderPageCtx } from 'datocms-plugin-sdk'; +import { Button } from 'datocms-react-ui'; +import styles from './ExportToolbar.module.css'; + +type Props = { + ctx: RenderPageCtx; + initialItemTypes: SchemaTypes.ItemType[]; + onClose?: () => void; +}; + +/** + * Header bar for the export flow, displaying the active title and close action. + */ +export function ExportToolbar({ ctx, initialItemTypes, onClose }: Props) { + const title = + initialItemTypes.length === 1 + ? `Export ${initialItemTypes[0].attributes.name}` + : 'Export selection'; + + return ( +
    +
    {title}
    +
    + +
    + ); +} diff --git a/import-export-schema/src/entrypoints/ExportPage/Inner.tsx b/import-export-schema/src/entrypoints/ExportPage/Inner.tsx index f6348205..df01e271 100644 --- a/import-export-schema/src/entrypoints/ExportPage/Inner.tsx +++ b/import-export-schema/src/entrypoints/ExportPage/Inner.tsx @@ -1,27 +1,26 @@ import type { SchemaTypes } from '@datocms/cma-client'; -import { faFileExport, faXmark } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { type NodeMouseHandler, type NodeTypes, Panel } from '@xyflow/react'; +import type { NodeMouseHandler, NodeTypes } from '@xyflow/react'; import type { ProjectSchema } from '@/utils/ProjectSchema'; import '@xyflow/react/dist/style.css'; import type { RenderPageCtx } from 'datocms-plugin-sdk'; import { Button, Spinner, useCtx } from 'datocms-react-ui'; import { without } from 'lodash-es'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { GraphCanvas } from '@/components/GraphCanvas'; -import { - findLinkedItemTypeIds, - findLinkedPluginIds, -} from '@/utils/datocms/schema'; -// import { collectDependencies } from '@/utils/graph/dependencies'; +import { GRAPH_NODE_THRESHOLD } from '@/shared/constants/graph'; +import { debugLog } from '@/utils/debug'; +import { expandSelectionWithDependencies } from '@/utils/graph/dependencies'; import { type AppNode, edgeTypes, type Graph } from '@/utils/graph/types'; +import { DependencyActionsPanel } from './DependencyActionsPanel'; import { EntitiesToExportContext } from './EntitiesToExportContext'; import { ExportItemTypeNodeRenderer } from './ExportItemTypeNodeRenderer'; import { ExportPluginNodeRenderer } from './ExportPluginNodeRenderer'; +import { ExportToolbar } from './ExportToolbar'; import LargeSelectionView from './LargeSelectionView'; import { useAnimatedNodes } from './useAnimatedNodes'; import { useExportGraph } from './useExportGraph'; +// Map React Flow node types to their respective renderer components. const nodeTypes: NodeTypes = { itemType: ExportItemTypeNodeRenderer, plugin: ExportPluginNodeRenderer, @@ -43,6 +42,10 @@ type Props = { onSelectingDependenciesChange?: (busy: boolean) => void; }; +/** + * Presents the export graph, wiring selection state, dependency resolution, and + * export call-outs for both graph and list views. + */ export default function Inner({ initialItemTypes, schema, @@ -55,12 +58,13 @@ export default function Inner({ }: Props) { const ctx = useCtx(); + // Track the current selection while ensuring initial models stay checked. const [selectedItemTypeIds, setSelectedItemTypeIds] = useState( initialItemTypes.map((it) => it.id), ); - const [selectedPluginIds, setSelectedPluginIds] = useState([]); const [selectingDependencies, setSelectingDependencies] = useState(false); + // Remember which dependencies were auto-selected so we can undo the action later. const [autoSelectedDependencies, setAutoSelectedDependencies] = useState<{ itemTypeIds: Set; pluginIds: Set; @@ -75,6 +79,19 @@ export default function Inner({ installedPluginIds, }); + const resolvedInstalledPluginIds = useMemo(() => { + if (installedPluginIds && installedPluginIds.size > 0) { + return installedPluginIds; + } + if (!graph) return undefined; + const discovered = new Set( + graph.nodes + .filter((node) => node.type === 'plugin') + .map((node) => (node.type === 'plugin' ? node.data.plugin.id : '')), + ); + return discovered.size > 0 ? discovered : undefined; + }, [installedPluginIds, graph]); + // Overlay is controlled by parent; we signal prepared after each build // Keep selection in sync if the parent changes the initial set of item types @@ -92,13 +109,10 @@ export default function Inner({ .join('-'), ]); - const GRAPH_NODE_THRESHOLD = 60; - + // React Flow becomes cluttered past this many nodes, so we fall back to a list. const showGraph = !!graph && graph.nodes.length <= GRAPH_NODE_THRESHOLD; - const animatedNodes = useAnimatedNodes( - showGraph && graph ? graph.nodes : [], - ); + const animatedNodes = useAnimatedNodes(showGraph && graph ? graph.nodes : []); const onNodeClick: NodeMouseHandler = useCallback( (_, node) => { @@ -136,27 +150,13 @@ export default function Inner({ try { // Ensure any preparation overlay is hidden during dependency selection onGraphPrepared?.(); - // Determine installed plugin IDs, warn user once if unknown - // (avoids false positives when detecting plugin dependencies) - // eslint-disable-next-line @typescript-eslint/no-explicit-any + const warnedKey = 'exportPluginIdsWarned'; - const installedFromGraph = graph - ? new Set( - graph.nodes - .filter((n) => n.type === 'plugin') - .map((n) => (n.type === 'plugin' ? n.data.plugin.id : '')), - ) - : undefined; - const installed = - installedPluginIds && installedPluginIds.size > 0 - ? installedPluginIds - : installedFromGraph && installedFromGraph.size > 0 - ? installedFromGraph - : undefined; - if (!installed && typeof window !== 'undefined') { + if (!resolvedInstalledPluginIds && typeof window !== 'undefined') { try { - const already = window.sessionStorage.getItem(warnedKey) === '1'; - if (!already) { + const alreadyWarned = + window.sessionStorage.getItem(warnedKey) === '1'; + if (!alreadyWarned) { void ctx.notice( 'Plugin dependency detection may be incomplete (installed plugin list unavailable).', ); @@ -164,69 +164,37 @@ export default function Inner({ } } catch {} } - if ( - typeof window !== 'undefined' && - window.localStorage?.getItem('schemaDebug') === '1' - ) { - console.log('[SelectAllDependencies] start', { - selectedItemTypeIds: selectedItemTypeIds.length, - selectedPluginIds: selectedPluginIds.length, - installedPluginIds: installed - ? Array.from(installed).slice(0, 5) - : 'unknown', - }); - } - const beforeItemTypeIds = new Set(selectedItemTypeIds); - const beforePluginIds = new Set(selectedPluginIds); - const nextItemTypeIds = new Set(selectedItemTypeIds); - const nextPluginIds = new Set(selectedPluginIds); - - const queue = [...selectedItemTypeIds]; - while (queue.length > 0) { - const popped = queue.pop(); - if (!popped) break; - const id = popped; - const node = graph?.nodes.find((n) => n.id === `itemType--${id}`); - const fields = node?.type === 'itemType' ? node.data.fields : []; - - for (const field of fields) { - for (const linkedId of findLinkedItemTypeIds(field)) { - if (!nextItemTypeIds.has(linkedId)) { - nextItemTypeIds.add(linkedId); - queue.push(linkedId); - } - } + debugLog('SelectAllDependencies start', { + selectedItemTypeCount: selectedItemTypeIds.length, + selectedPluginCount: selectedPluginIds.length, + installedPluginIds: resolvedInstalledPluginIds + ? Array.from(resolvedInstalledPluginIds).slice(0, 5) + : 'unknown', + }); - for (const pluginId of findLinkedPluginIds(field, installed)) { - nextPluginIds.add(pluginId); - } - } - } + const expansion = expandSelectionWithDependencies({ + graph, + seedItemTypeIds: selectedItemTypeIds, + seedPluginIds: selectedPluginIds, + installedPluginIds: resolvedInstalledPluginIds, + }); - const addedItemTypeIds = Array.from(nextItemTypeIds).filter( - (id) => !beforeItemTypeIds.has(id), - ); - const addedPluginIds = Array.from(nextPluginIds).filter( - (id) => !beforePluginIds.has(id), - ); + const { addedItemTypeIds, addedPluginIds } = expansion; - setSelectedItemTypeIds(Array.from(nextItemTypeIds)); - setSelectedPluginIds(Array.from(nextPluginIds)); + setSelectedItemTypeIds(Array.from(expansion.itemTypeIds)); + setSelectedPluginIds(Array.from(expansion.pluginIds)); setAutoSelectedDependencies({ itemTypeIds: new Set(addedItemTypeIds), pluginIds: new Set(addedPluginIds), }); - if ( - typeof window !== 'undefined' && - window.localStorage?.getItem('schemaDebug') === '1' - ) { - console.log('[SelectAllDependencies] done', { - itemTypeIds: nextItemTypeIds.size, - pluginIds: nextPluginIds.size, - samplePluginIds: Array.from(nextPluginIds).slice(0, 5), - }); - } + + debugLog('SelectAllDependencies done', { + itemTypeCount: expansion.itemTypeIds.size, + pluginCount: expansion.pluginIds.size, + samplePluginIds: Array.from(expansion.pluginIds).slice(0, 5), + }); + void ctx.notice( `Selected dependencies: +${addedItemTypeIds.length} models, +${addedPluginIds.length} plugins`, ); @@ -235,12 +203,13 @@ export default function Inner({ // Do not lift overlay suppression here; let onGraphPrepared re-enable it } }, [ + ctx, graph, + onGraphPrepared, + onSelectingDependenciesChange, + resolvedInstalledPluginIds, selectedItemTypeIds, selectedPluginIds, - installedPluginIds, - onSelectingDependenciesChange, - ctx, ]); const handleUnselectAllDependencies = useCallback(() => { @@ -276,87 +245,36 @@ export default function Inner({ ); }, [autoSelectedDependencies, ctx, selectedItemTypeIds, selectedPluginIds]); - // Determine if all deps are selected to toggle label - const areAllDependenciesSelected = (() => { + // Determine if all dependencies are already selected to flip the CTA label. + const areAllDependenciesSelected = useMemo(() => { try { - const installedFromGraph = graph - ? new Set( - graph.nodes - .filter((n) => n.type === 'plugin') - .map((n) => (n.type === 'plugin' ? n.data.plugin.id : '')), - ) - : undefined; - const installed = - installedPluginIds && installedPluginIds.size > 0 - ? installedPluginIds - : installedFromGraph && installedFromGraph.size > 0 - ? installedFromGraph - : undefined; - - const nextItemTypeIds = new Set(selectedItemTypeIds); - const nextPluginIds = new Set(selectedPluginIds); - const queue = [...selectedItemTypeIds]; - while (queue.length > 0) { - const popped = queue.pop(); - if (!popped) break; - const id = popped; - const node = graph?.nodes.find((n) => n.id === `itemType--${id}`); - const fields = node?.type === 'itemType' ? node.data.fields : []; - for (const field of fields) { - for (const linkedId of findLinkedItemTypeIds(field)) { - if (!nextItemTypeIds.has(linkedId)) { - nextItemTypeIds.add(linkedId); - queue.push(linkedId); - } - } - for (const pluginId of findLinkedPluginIds(field, installed)) { - nextPluginIds.add(pluginId); - } - } - } - const toAddItemTypes = Array.from(nextItemTypeIds).filter( - (id) => !selectedItemTypeIds.includes(id), - ); - const toAddPlugins = Array.from(nextPluginIds).filter( - (id) => !selectedPluginIds.includes(id), + const expansion = expandSelectionWithDependencies({ + graph, + seedItemTypeIds: selectedItemTypeIds, + seedPluginIds: selectedPluginIds, + installedPluginIds: resolvedInstalledPluginIds, + }); + return ( + expansion.addedItemTypeIds.length === 0 && + expansion.addedPluginIds.length === 0 ); - return toAddItemTypes.length === 0 && toAddPlugins.length === 0; } catch { return false; } - })(); + }, [ + graph, + resolvedInstalledPluginIds, + selectedItemTypeIds, + selectedPluginIds, + ]); return (
    -
    -
    - {initialItemTypes.length === 1 - ? `Export ${initialItemTypes[0].attributes.name}` - : 'Export selection'} -
    -
    - -
    +
    {!graph && !error ? ( @@ -419,40 +337,16 @@ export default function Inner({ style={{ position: 'absolute' }} fitView /> - -
    - - {selectingDependencies && } - - -
    -
    + + onExport(selectedItemTypeIds, selectedPluginIds) + } + /> ) : ( >(new Set()); const initialItemTypeIdSet = useMemo( @@ -97,6 +102,7 @@ export default function LargeSelectionView({ setExpandedWhy(next); } + // Track selected nodes by React Flow id to surface "why included" reasons quickly. const selectedSourceSet = useMemo( () => new Set(selectedItemTypeIds.map((id) => `itemType--${id}`)), [selectedItemTypeIds], diff --git a/import-export-schema/src/entrypoints/ExportPage/buildExportDoc.ts b/import-export-schema/src/entrypoints/ExportPage/buildExportDoc.ts index fa8dda8c..67fa73aa 100644 --- a/import-export-schema/src/entrypoints/ExportPage/buildExportDoc.ts +++ b/import-export-schema/src/entrypoints/ExportPage/buildExportDoc.ts @@ -12,6 +12,10 @@ type BuildExportDocOptions = { shouldCancel?: () => boolean; }; +/** + * Assemble an export document tailored to the selected item types and plugins, trimming + * validators and appearances so the payload is self-contained. + */ export default async function buildExportDoc( schema: ProjectSchema, initialItemTypeId: string, @@ -70,6 +74,7 @@ export default async function buildExportDoc( validator, ) as string[]; + // Drop links to models outside the export selection so the document stays valid. set( exportableField.attributes.validators, validator, @@ -77,6 +82,7 @@ export default async function buildExportDoc( ); } + // Remove appearance references to non-exported plugins/media. exportableField.attributes.appearance = await ensureExportableAppearance( field, pluginIdsToExport, diff --git a/import-export-schema/src/entrypoints/ExportPage/buildGraphFromSchema.ts b/import-export-schema/src/entrypoints/ExportPage/buildGraphFromSchema.ts index eae2359d..d420eaf0 100644 --- a/import-export-schema/src/entrypoints/ExportPage/buildGraphFromSchema.ts +++ b/import-export-schema/src/entrypoints/ExportPage/buildGraphFromSchema.ts @@ -14,6 +14,10 @@ type Options = { // Note: queue type was unused; removed for strict build +/** + * Lightweight wrapper that adapts the current project schema into the shared + * `buildGraph` helper so the export view can render a dependency graph. + */ export async function buildGraphFromSchema({ initialItemTypes, selectedItemTypeIds, diff --git a/import-export-schema/src/entrypoints/ExportPage/index.tsx b/import-export-schema/src/entrypoints/ExportPage/index.tsx index 559c3399..1ecd204d 100644 --- a/import-export-schema/src/entrypoints/ExportPage/index.tsx +++ b/import-export-schema/src/entrypoints/ExportPage/index.tsx @@ -5,9 +5,9 @@ import { Canvas, Spinner } from 'datocms-react-ui'; import { useEffect, useState } from 'react'; import { ProgressOverlay } from '@/components/ProgressOverlay'; import { useProjectSchema } from '@/shared/hooks/useProjectSchema'; +import { useSchemaExportTask } from '@/shared/hooks/useSchemaExportTask'; import { useLongTask } from '@/shared/tasks/useLongTask'; -import { downloadJSON } from '@/utils/downloadJson'; -import buildExportDoc from './buildExportDoc'; +import { debugLog } from '@/utils/debug'; import Inner from './Inner'; type Props = { @@ -15,6 +15,10 @@ type Props = { initialItemTypeId: string; }; +/** + * Export entry loaded from the DatoCMS sidebar when a single model kicks off the flow. + * Fetches schema resources, shows progress overlays, and renders the main graph view. + */ export default function ExportPage({ ctx, initialItemTypeId }: Props) { const schema = useProjectSchema(ctx); @@ -23,16 +27,20 @@ export default function ExportPage({ ctx, initialItemTypeId }: Props) { >(); const [suppressPreparingOverlay, setSuppressPreparingOverlay] = useState(false); - const [preparingPhase, setPreparingPhase] = useState<'scan' | 'build'>('scan'); + const [preparingPhase, setPreparingPhase] = useState<'scan' | 'build'>( + 'scan', + ); const preparingTask = useLongTask(); - const exportTask = useLongTask(); + const { task: exportTask, runExport } = useSchemaExportTask({ + schema, + ctx, + }); const preparingProgress = preparingTask.state.progress; const exportProgress = exportTask.state.progress; const preparingHasTotals = - typeof preparingProgress.total === 'number' && - preparingProgress.total > 0; + typeof preparingProgress.total === 'number' && preparingProgress.total > 0; // Smoothed visual progress percentage for the preparing overlay. // We map the initial scanning phase to 0–25%, then determinate build to 25–100%. const [preparingPercent, setPreparingPercent] = useState(0.1); @@ -85,16 +93,7 @@ export default function ExportPage({ ctx, initialItemTypeId }: Props) { const itemType = await schema.getItemTypeById(initialItemTypeId); setInitialItemType(itemType); if (lastPreparedForId !== initialItemTypeId) { - try { - if ( - typeof window !== 'undefined' && - window.localStorage?.getItem('schemaDebug') === '1' - ) { - console.log( - `[ExportPage] preparingBusy -> true (init); initialItemTypeId=${initialItemTypeId}`, - ); - } - } catch {} + debugLog('ExportPage preparing start', { initialItemTypeId }); preparingTask.controller.start({ label: 'Preparing export…', }); @@ -107,56 +106,6 @@ export default function ExportPage({ ctx, initialItemTypeId }: Props) { run(); }, [schema, initialItemTypeId, lastPreparedForId, preparingTask]); - async function handleExport(itemTypeIds: string[], pluginIds: string[]) { - try { - // Initialize progress bar - const total = pluginIds.length + itemTypeIds.length * 2; - exportTask.controller.start({ - done: 0, - total, - label: 'Preparing export…', - }); - let done = 0; - - const exportDoc = await buildExportDoc( - schema, - initialItemTypeId, - itemTypeIds, - pluginIds, - { - onProgress: (label: string) => { - done += 1; - exportTask.controller.setProgress({ done, total, label }); - }, - shouldCancel: () => exportTask.controller.isCancelRequested(), - }, - ); - - if (exportTask.controller.isCancelRequested()) { - throw new Error('Export cancelled'); - } - - downloadJSON(exportDoc, { fileName: 'export.json', prettify: true }); - exportTask.controller.complete({ - done: total, - total, - label: 'Export completed', - }); - ctx.notice('Export completed successfully.'); - } catch (e) { - console.error('Export failed', e); - if (e instanceof Error && e.message === 'Export cancelled') { - exportTask.controller.complete({ label: 'Export cancelled' }); - ctx.notice('Export canceled'); - } else { - exportTask.controller.fail(e); - ctx.alert('Could not complete the export. Please try again.'); - } - } finally { - exportTask.controller.reset(); - } - } - if (!initialItemType) { return (
    @@ -171,81 +120,79 @@ export default function ExportPage({ ctx, initialItemTypeId }: Props) { { - if (preparingTask.state.status !== 'running') { - preparingTask.controller.start(p); - } else { - preparingTask.controller.setProgress(p); + key={initialItemTypeId} + initialItemTypes={[initialItemType]} + schema={schema} + onExport={(itemTypeIds, pluginIds) => + runExport({ + rootItemTypeId: initialItemTypeId, + itemTypeIds, + pluginIds, + }) + } + onPrepareProgress={(p) => { + if (preparingTask.state.status !== 'running') { + preparingTask.controller.start(p); + } else { + preparingTask.controller.setProgress(p); + } + setPreparingPhase(p.phase ?? 'scan'); + const phase = p.phase ?? 'scan'; + const hasTotals = (p.total ?? 0) > 0; + if (phase === 'scan') { + if (hasTotals) { + const raw = Math.max(0, Math.min(1, p.done / p.total)); + // Map scan to [0.05, 0.85]; keep monotonic + const mapped = 0.05 + raw * 0.8; + setPreparingPercent((prev) => + Math.max(prev, Math.min(0.88, mapped)), + ); } - setPreparingPhase(p.phase ?? 'scan'); - const phase = p.phase ?? 'scan'; - const hasTotals = (p.total ?? 0) > 0; - if (phase === 'scan') { - if (hasTotals) { - const raw = Math.max(0, Math.min(1, p.done / p.total)); - // Map scan to [0.05, 0.85]; keep monotonic - const mapped = 0.05 + raw * 0.8; - setPreparingPercent((prev) => - Math.max(prev, Math.min(0.88, mapped)), - ); - } - // else: heartbeat drives percent - } else { - if (hasTotals) { - const raw = Math.max(0, Math.min(1, p.done / p.total)); - // Map build to [0.85, 1.00]; keep monotonic - const mapped = 0.85 + raw * 0.15; - setPreparingPercent((prev) => - Math.max(prev, Math.min(1, mapped)), - ); - } + // else: heartbeat drives percent + } else { + if (hasTotals) { + const raw = Math.max(0, Math.min(1, p.done / p.total)); + // Map build to [0.85, 1.00]; keep monotonic + const mapped = 0.85 + raw * 0.15; + setPreparingPercent((prev) => + Math.max(prev, Math.min(1, mapped)), + ); } - }} - onGraphPrepared={() => { - try { - if ( - typeof window !== 'undefined' && - window.localStorage?.getItem('schemaDebug') === '1' - ) { - console.log( - '[ExportPage] onGraphPrepared -> preparingBusy false', - ); - } - } catch {} - setPreparingPercent(1); - preparingTask.controller.complete({ - label: 'Graph prepared', - }); - setSuppressPreparingOverlay(false); - setPreparingPhase('build'); - }} - installedPluginIds={installedPluginIds} - onSelectingDependenciesChange={(busy) => { - // Hide overlay during dependency expansion; release when graph is prepared - if (busy) { - setSuppressPreparingOverlay(true); - } - }} - /> - - {preparingTask.state.status === 'running' && !suppressPreparingOverlay && ( - { + debugLog('ExportPage graph prepared'); + setPreparingPercent(1); + preparingTask.controller.complete({ + label: 'Graph prepared', + }); + setSuppressPreparingOverlay(false); + setPreparingPhase('build'); + }} + installedPluginIds={installedPluginIds} + onSelectingDependenciesChange={(busy) => { + // Hide overlay during dependency expansion; release when graph is prepared + if (busy) { + setSuppressPreparingOverlay(true); + } }} - stallCurrent={preparingProgress.done} /> - )} + + {preparingTask.state.status === 'running' && + !suppressPreparingOverlay && ( + + )} {exportTask.state.status === 'running' && ( ; }; +/** + * Builds the export dependency graph whenever the selection or schema changes, + * surfacing progress callbacks and exposing a manual `refresh` helper. + */ export function useExportGraph({ initialItemTypes, selectedItemTypeIds, @@ -24,44 +29,45 @@ export function useExportGraph({ const [graph, setGraph] = useState(); const [error, setError] = useState(); const [refreshKey, setRefreshKey] = useState(0); + const prepareProgressRef = useRef(onPrepareProgress); + const graphPreparedRef = useRef(onGraphPrepared); + + useEffect(() => { + prepareProgressRef.current = onPrepareProgress; + }, [onPrepareProgress]); + + useEffect(() => { + graphPreparedRef.current = onGraphPrepared; + }, [onGraphPrepared]); useEffect(() => { + // Avoid setting state after unmount or when inputs change mid-build. let cancelled = false; async function run() { try { setError(undefined); - if ( - typeof window !== 'undefined' && - window.localStorage?.getItem('schemaDebug') === '1' - ) { - console.log('[ExportGraph] buildGraphFromSchema start', { - selectedItemTypeIds: selectedItemTypeIds.length, - }); - } + debugLog('ExportGraph build start', { + selectedItemTypeCount: selectedItemTypeIds.length, + }); const nextGraph = await buildGraphFromSchema({ initialItemTypes, selectedItemTypeIds, schema, - onProgress: onPrepareProgress, + onProgress: prepareProgressRef.current, installedPluginIds, }); if (cancelled) return; setGraph(nextGraph); - if ( - typeof window !== 'undefined' && - window.localStorage?.getItem('schemaDebug') === '1' - ) { - console.log('[ExportGraph] buildGraphFromSchema done', { - nodes: nextGraph.nodes.length, - edges: nextGraph.edges.length, - }); - } - onGraphPrepared?.(); + debugLog('ExportGraph build complete', { + nodeCount: nextGraph.nodes.length, + edgeCount: nextGraph.edges.length, + }); + graphPreparedRef.current?.(); } catch (err) { if (cancelled) return; console.error('Error building export graph:', err); setError(err as Error); - onGraphPrepared?.(); + graphPreparedRef.current?.(); } } run(); @@ -73,20 +79,16 @@ export function useExportGraph({ .map((it) => it.id) .sort() .join('-'), - selectedItemTypeIds - .slice() - .sort() - .join('-'), + selectedItemTypeIds.slice().sort().join('-'), schema, refreshKey, - onPrepareProgress, - onGraphPrepared, installedPluginIds, ]); return { graph, error, + // Trigger a rebuild (for example after an intermittent API failure). refresh: () => setRefreshKey((key) => key + 1), }; } diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/Collapsible.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/Collapsible.tsx index bd3560d0..5398e0cf 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/Collapsible.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/Collapsible.tsx @@ -15,6 +15,9 @@ type Props = { children: ReactNode; }; +/** + * Accordion-style wrapper that also syncs with the graph selection context. + */ export default function Collapsible({ entity, invalid, diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ConflictsContext.ts b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ConflictsContext.ts index f52eafe4..0e2554a1 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ConflictsContext.ts +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ConflictsContext.ts @@ -1,6 +1,9 @@ import { createContext } from 'react'; import type { Conflicts } from './buildConflicts'; +/** + * Stores the conflict mappings so detailed components can read and annotate results. + */ export const ConflictsContext = createContext({ plugins: {}, itemTypes: {}, diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx index bdda8464..a8c28c87 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx @@ -17,6 +17,9 @@ type Props = { projectItemType: SchemaTypes.ItemType; }; +/** + * Renders the resolution UI for a conflicting model/block, including rename inputs. + */ export function ItemTypeConflict({ exportItemType, projectItemType }: Props) { const selectId = useId(); const nameId = useId(); @@ -32,6 +35,7 @@ export function ItemTypeConflict({ exportItemType, projectItemType }: Props) { ? 'block' : 'model'; + // Base strategy options; reuse is only valid for matching model/block types. const options: Option[] = [ { label: `Import ${exportType} using a different name`, value: 'rename' }, ]; diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx index 3d6351bd..5a3acc4a 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx @@ -25,6 +25,7 @@ type Props = { projectPlugin: SchemaTypes.Plugin; }; +/** Presents resolution choices for plugin conflicts (reuse vs. skip). */ export function PluginConflict({ exportPlugin, projectPlugin }: Props) { const selectId = useId(); const fieldPrefix = `plugin-${exportPlugin.id}`; diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/buildConflicts.ts b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/buildConflicts.ts index 24289eaa..6cc82b94 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/buildConflicts.ts +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/buildConflicts.ts @@ -8,6 +8,10 @@ export type Conflicts = { itemTypes: Record; }; +/** + * Compare the export snapshot against the project and identify models/plugins that collide + * by name, API key, or URL. + */ export default async function buildConflicts( exportSchema: ExportSchema, projectSchema: ProjectSchema, diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx index f5cd6309..52df44fc 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx @@ -18,7 +18,9 @@ type Props = { ctx?: RenderPageCtx; }; - +/** + * Organizes detected conflicts by type, wiring them into the resolutions form. + */ export default function ConflictsManager({ exportSchema, schema: _schema, @@ -67,6 +69,7 @@ export default function ConflictsManager({ ); }, [conflicts, exportSchema]); + // Deterministic sorting keeps plugin conflicts stable between renders. const sortedPlugins = useMemo(() => { if (!conflicts) { return [] as Array<{ diff --git a/import-export-schema/src/entrypoints/ImportPage/FileDropZone.tsx b/import-export-schema/src/entrypoints/ImportPage/FileDropZone.tsx index 59d0d94a..20204bdd 100644 --- a/import-export-schema/src/entrypoints/ImportPage/FileDropZone.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/FileDropZone.tsx @@ -11,6 +11,9 @@ type Props = { children: (button: ReactNode) => ReactNode; }; +/** + * Handles drag-and-drop and manual file selection, validating JSON before delegating upwards. + */ export default function FileDropZone({ onJsonDrop, children }: Props) { const ctx = useCtx(); const fileInputRef = useRef(null); diff --git a/import-export-schema/src/entrypoints/ImportPage/ImportItemTypeNodeRenderer.tsx b/import-export-schema/src/entrypoints/ImportPage/ImportItemTypeNodeRenderer.tsx index bfc16823..373c3441 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ImportItemTypeNodeRenderer.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ImportItemTypeNodeRenderer.tsx @@ -9,6 +9,9 @@ import { ConflictsContext } from '@/entrypoints/ImportPage/ConflictsManager/Conf import { useResolutionStatusForItemType } from '@/entrypoints/ImportPage/ResolutionsForm'; import { SelectedEntityContext } from '@/entrypoints/ImportPage/SelectedEntityContext'; +/** + * Renders import graph item-type nodes, overlaying conflict and resolution state styling. + */ export function ImportItemTypeNodeRenderer(props: NodeProps) { const { itemType } = props.data; diff --git a/import-export-schema/src/entrypoints/ImportPage/Inner.tsx b/import-export-schema/src/entrypoints/ImportPage/Inner.tsx index 6543662e..6906f4cc 100644 --- a/import-export-schema/src/entrypoints/ImportPage/Inner.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/Inner.tsx @@ -7,6 +7,7 @@ import { useReactFlow, } from '@xyflow/react'; import { useCallback, useEffect, useState } from 'react'; +import { GRAPH_NODE_THRESHOLD } from '@/shared/constants/graph'; import { type AppNode, edgeTypes, type Graph } from '@/utils/graph/types'; import type { ProjectSchema } from '@/utils/ProjectSchema'; import type { ExportSchema } from '../ExportPage/ExportSchema'; @@ -18,6 +19,7 @@ import LargeSelectionView from './LargeSelectionView'; import { useSkippedItemsAndPluginIds } from './ResolutionsForm'; import { SelectedEntityContext } from './SelectedEntityContext'; +// Map React Flow node types to the dedicated renderers for import graphs. const nodeTypes: NodeTypes = { itemType: ImportItemTypeNodeRenderer, plugin: ImportPluginNodeRenderer, @@ -29,17 +31,23 @@ type Props = { ctx: import('datocms-plugin-sdk').RenderPageCtx; }; +/** + * Displays the import graph, helps the user inspect potential conflicts, and keeps + * the selection in sync with the conflict resolution form. + */ export function Inner({ exportSchema, schema, ctx: _ctx }: Props) { const { fitBounds, fitView } = useReactFlow(); const { skippedItemTypeIds, skippedPluginIds } = useSkippedItemsAndPluginIds(); + // Zoom the viewport to the full graph once React Flow has mounted. useEffect(() => { setTimeout(() => fitView(), 100); }, []); const [graph, setGraph] = useState(); + // Rebuild the graph when the export document or skip lists change. useEffect(() => { async function run() { setGraph(await buildGraphFromExportDoc(exportSchema, skippedItemTypeIds)); @@ -64,6 +72,7 @@ export function Inner({ exportSchema, schema, ctx: _ctx }: Props) { ); }, []); + // Allow external panels to highlight a specific entity while animating the view. function handleSelectEntity( newEntity: undefined | SchemaTypes.ItemType | SchemaTypes.Plugin, zoomIn?: boolean, @@ -90,11 +99,10 @@ export function Inner({ exportSchema, schema, ctx: _ctx }: Props) { } } - const GRAPH_NODE_THRESHOLD = 60; - const totalPotentialNodes = exportSchema.itemTypes.length + exportSchema.plugins.length; + // Prefer the interactive graph for small/medium selections; fall back otherwise. const showGraph = !!graph && graph.nodes.length <= GRAPH_NODE_THRESHOLD && diff --git a/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.tsx b/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.tsx index ba4c6397..8656dd00 100644 --- a/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.tsx @@ -16,9 +16,14 @@ type Props = { onSelect: (entity: SchemaTypes.ItemType | SchemaTypes.Plugin) => void; }; +/** + * Read-only overview used when the import graph is too dense to render. Mirrors the + * export-side list but drives the detail sidebar for conflicts. + */ export default function LargeSelectionView({ graph, onSelect }: Props) { const searchInputId = useId(); const [query, setQuery] = useState(''); + // Keep the row selection in sync with the conflict/resolution panels. const selected = useContext(SelectedEntityContext).entity; const { itemTypeNodes, pluginNodes } = useMemo( diff --git a/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx b/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx index af846b04..356eab5e 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx @@ -39,6 +39,7 @@ type Props = { onSubmit: (values: Resolutions) => void; }; +// Mirrors the platform validation rules plus common reserved identifiers. function isValidApiKey(apiKey: string) { if (!apiKey.match(/^[a-z](([a-z0-9]|_(?![_0-9]))*[a-z0-9])$/)) { return false; @@ -64,6 +65,9 @@ function isValidApiKey(apiKey: string) { return true; } +/** + * Hosts the conflict resolution form and exposes helpers for components to read state. + */ export default function ResolutionsForm({ schema, children, onSubmit }: Props) { const conflicts = useContext(ConflictsContext); @@ -89,6 +93,7 @@ export default function ResolutionsForm({ schema, children, onSubmit }: Props) { `itemType-${id}`, { strategy: null, + // Suggest sensible rename defaults to speed up resolution. name: `${projectItemType.attributes.name} (Import)`, apiKey: `${projectItemType.attributes.api_key}_import`, }, @@ -209,6 +214,9 @@ export default function ResolutionsForm({ schema, children, onSubmit }: Props) { ); } +/** + * Convenience hook for grabbing validity + values for a specific item type row. + */ export function useResolutionStatusForItemType(itemTypeId: string) { const state = useFormState(); @@ -227,6 +235,7 @@ export function useResolutionStatusForItemType(itemTypeId: string) { }; } +/** Same as above but for plugin conflicts. */ export function useResolutionStatusForPlugin(pluginId: string) { const state = useFormState(); @@ -245,6 +254,9 @@ export function useResolutionStatusForPlugin(pluginId: string) { }; } +/** + * Derive which entities are being reused so the graph/list views can hide them. + */ export function useSkippedItemsAndPluginIds() { const conflicts = useContext(ConflictsContext); const formState = useFormState(); diff --git a/import-export-schema/src/entrypoints/ImportPage/SelectedEntityContext.tsx b/import-export-schema/src/entrypoints/ImportPage/SelectedEntityContext.tsx index 264c0403..a9103b53 100644 --- a/import-export-schema/src/entrypoints/ImportPage/SelectedEntityContext.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/SelectedEntityContext.tsx @@ -9,6 +9,9 @@ type Context = { ) => void; }; +/** + * Shares the currently highlighted entity between the graph and the detail sidebar. + */ export const SelectedEntityContext = createContext({ entity: undefined, set: () => {}, diff --git a/import-export-schema/src/entrypoints/ImportPage/buildGraphFromExportDoc.ts b/import-export-schema/src/entrypoints/ImportPage/buildGraphFromExportDoc.ts index 3d99854b..3c8b6140 100644 --- a/import-export-schema/src/entrypoints/ImportPage/buildGraphFromExportDoc.ts +++ b/import-export-schema/src/entrypoints/ImportPage/buildGraphFromExportDoc.ts @@ -7,6 +7,7 @@ export async function buildGraphFromExportDoc( exportSchema: ExportSchema, itemTypeIdsToSkip: string[], ): Promise { + // Convert the static export document into the graph format expected by React Flow. const source = new ExportSchemaSource(exportSchema); return buildGraph({ source, diff --git a/import-export-schema/src/entrypoints/ImportPage/buildImportDoc.ts b/import-export-schema/src/entrypoints/ImportPage/buildImportDoc.ts index 110d9cd0..10fab709 100644 --- a/import-export-schema/src/entrypoints/ImportPage/buildImportDoc.ts +++ b/import-export-schema/src/entrypoints/ImportPage/buildImportDoc.ts @@ -28,6 +28,9 @@ export type ImportDoc = { }; }; +/** + * Walk the export graph while honoring conflict resolutions, producing a document for import. + */ export async function buildImportDoc( exportSchema: ExportSchema, conflicts: Conflicts, @@ -44,6 +47,7 @@ export async function buildImportDoc( }, }; + // Breadth-first traversal keeps dependencies ordered for creation. const queue: QueueItem[][] = [exportSchema.rootItemTypes]; const processedNodes = new Set(); diff --git a/import-export-schema/src/entrypoints/ImportPage/importSchema.ts b/import-export-schema/src/entrypoints/ImportPage/importSchema.ts index 7499bfdd..af8e4d09 100644 --- a/import-export-schema/src/entrypoints/ImportPage/importSchema.ts +++ b/import-export-schema/src/entrypoints/ImportPage/importSchema.ts @@ -5,8 +5,10 @@ import { validatorsContainingBlocks, validatorsContainingLinks, } from '@/utils/datocms/schema'; +import { debugLog } from '@/utils/debug'; import type { ImportDoc } from './buildImportDoc'; +/** Convenience helper to surface clearer errors when an ID mapping is missing. */ function getOrThrow(map: Map, key: K, context: string): V { const value = map.get(key); if (value === undefined) { @@ -15,17 +17,6 @@ function getOrThrow(map: Map, key: K, context: string): V { return value; } -function isDebug() { - try { - return ( - typeof window !== 'undefined' && - window.localStorage?.getItem('schemaDebug') === '1' - ); - } catch { - return false; - } -} - export type ImportProgress = { total: number; finished: number; @@ -39,6 +30,9 @@ export type ImportResult = { pluginIdByExportId: Record; }; +/** + * Applies an import document to the target project while reporting granular progress. + */ export default async function importSchema( importDoc: ImportDoc, client: Client, @@ -82,6 +76,7 @@ export default async function importSchema( // debug helper is module-scoped to be available in helpers below + // Wrap API calls so each step updates the overlay and respects cancellation. function trackWithLabel( labelForArgs: (...args: TArgs) => string, promiseGeneratorFn: (...args: TArgs) => Promise, @@ -103,9 +98,10 @@ export default async function importSchema( }; } - // Concurrency-limited mapper that preserves input order and - // stops scheduling new work after cancellation while letting - // in-flight jobs finish. It throws at the end if cancelled. + /** + * Concurrency-limited mapper that preserves order and stops scheduling new work after + * cancellation while letting in-flight jobs finish. + */ async function pMap( items: readonly T[], limit: number, @@ -152,6 +148,7 @@ export default async function importSchema( const fieldsetIdMappings: Map = new Map(); const pluginIdMappings: Map = new Map(); + // Pre-assign project IDs so relationships can reference them during creation. for (const toCreate of importDoc.itemTypes.entitiesToCreate) { itemTypeIdMappings.set(toCreate.entity.id, generateId()); @@ -205,7 +202,7 @@ export default async function importSchema( }; try { - if (isDebug()) console.log('Creating plugin', data); + debugLog('Creating plugin', data); const { data: created } = await client.plugins.rawCreate({ data }); if (!isEqual(created.attributes.parameters, {})) { @@ -217,7 +214,7 @@ export default async function importSchema( // ignore invalid legacy parameters } } - if (isDebug()) console.log('Created plugin', created); + debugLog('Created plugin', created); } catch (e) { console.error('Failed to create plugin', data, e); } @@ -252,11 +249,11 @@ export default async function importSchema( data.attributes.name = t.rename.name; data.attributes.api_key = t.rename.apiKey; } - if (isDebug()) console.log('Creating item type', data); + debugLog('Creating item type', data); const { data: itemType } = await client.itemTypes.rawCreate({ data, }); - if (isDebug()) console.log('Created item type', itemType); + debugLog('Created item type', itemType); return itemType; } catch (e) { console.error('Failed to create item type', data, e); @@ -287,7 +284,7 @@ export default async function importSchema( }; try { - if (isDebug()) console.log('Creating fieldset', data); + debugLog('Creating fieldset', data); const itemTypeProjectId = getOrThrow( itemTypeIdMappings, itemTypeId, @@ -297,7 +294,7 @@ export default async function importSchema( itemTypeProjectId, { data }, ); - if (isDebug()) console.log('Created fieldset', created); + debugLog('Created fieldset', created); } catch (e) { console.error('Failed to create fieldset', data, e); } @@ -410,12 +407,17 @@ export default async function importSchema( }; try { - if (isDebug()) - console.log( - data.relationships, - pick(createdItemType.attributes, attributesToUpdate), - pick(createdItemType.relationships, relationshipsToUpdate), - ); + debugLog('Finalize diff snapshot', { + relationships: data.relationships, + currentAttributes: pick( + createdItemType.attributes, + attributesToUpdate, + ), + currentRelationships: pick( + createdItemType.relationships, + relationshipsToUpdate, + ), + }); if ( !isEqual( data.relationships, @@ -426,12 +428,12 @@ export default async function importSchema( pick(createdItemType.attributes, attributesToUpdate), ) ) { - if (isDebug()) console.log('Finalizing item type', data); + debugLog('Finalizing item type', data); const { data: updatedItemType } = await client.itemTypes.rawUpdate( id, { data }, ); - if (isDebug()) console.log('Finalized item type', updatedItemType); + debugLog('Finalized item type', updatedItemType); } } catch (e) { console.error('Failed to finalize item type', data, e); @@ -457,11 +459,13 @@ export default async function importSchema( } try { - if (isDebug()) - console.log( - 'Reordering fields/fieldsets for item type', - getOrThrow(itemTypeIdMappings, itemType.id, 'reorder start log'), - ); + debugLog('Reordering fields/fieldsets for item type', { + itemTypeId: getOrThrow( + itemTypeIdMappings, + itemType.id, + 'reorder start log', + ), + }); for (const entity of sortBy(allEntities, [ 'attributes', 'position', @@ -483,11 +487,13 @@ export default async function importSchema( ); } } - if (isDebug()) - console.log( - 'Reordered fields/fieldsets for item type', - getOrThrow(itemTypeIdMappings, itemType.id, 'reorder log'), - ); + debugLog('Reordered fields/fieldsets for item type', { + itemTypeId: getOrThrow( + itemTypeIdMappings, + itemType.id, + 'reorder log', + ), + }); } catch (e) { console.error('Failed to reorder fields/fieldsets', e); } @@ -612,7 +618,7 @@ async function importField( data.attributes.appearance = nextAppearance; try { - if (isDebug()) console.log('Creating field', data); + debugLog('Creating field', data); const itemTypeProjectId = getOrThrow( itemTypeIdMappings, field.relationships.item_type.data.id, @@ -624,7 +630,7 @@ async function importField( data, }, ); - if (isDebug()) console.log('Created field', createdField); + debugLog('Created field', createdField); } catch (e) { console.error('Failed to create field', data, e); } diff --git a/import-export-schema/src/entrypoints/ImportPage/index.tsx b/import-export-schema/src/entrypoints/ImportPage/index.tsx index 7bc5a2df..03edb6b0 100644 --- a/import-export-schema/src/entrypoints/ImportPage/index.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/index.tsx @@ -4,15 +4,14 @@ import type { RenderPageCtx } from 'datocms-plugin-sdk'; import { Canvas, Spinner } from 'datocms-react-ui'; import { useEffect, useId, useState } from 'react'; import { ExportStartPanel } from '@/components/ExportStartPanel'; -import { TaskProgressOverlay } from '@/components/TaskProgressOverlay'; +import { TaskOverlayStack } from '@/components/TaskOverlayStack'; +import { useConflictsBuilder } from '@/shared/hooks/useConflictsBuilder'; +import { useExportAllHandler } from '@/shared/hooks/useExportAllHandler'; import { useExportSelection } from '@/shared/hooks/useExportSelection'; import { useProjectSchema } from '@/shared/hooks/useProjectSchema'; -import { useExportAllHandler } from '@/shared/hooks/useExportAllHandler'; -import { useConflictsBuilder } from '@/shared/hooks/useConflictsBuilder'; +import { useSchemaExportTask } from '@/shared/hooks/useSchemaExportTask'; import { useLongTask } from '@/shared/tasks/useLongTask'; -import { downloadJSON } from '@/utils/downloadJson'; import type { ExportDoc } from '@/utils/types'; -import buildExportDoc from '../ExportPage/buildExportDoc'; import { ExportSchema } from '../ExportPage/ExportSchema'; import ExportInner from '../ExportPage/Inner'; // PostExportSummary removed: exports now download directly with a toast @@ -30,6 +29,10 @@ type Props = { hideModeToggle?: boolean; }; +/** + * Unified Import/Export entrypoint rendered inside the Schema sidebar page. Handles + * file drops, conflict resolution, and the alternate export tab. + */ export function ImportPage({ ctx, initialMode = 'import', @@ -42,6 +45,7 @@ export function ImportPage({ const [loadingRecipeByUrl, setLoadingRecipeByUrl] = useState(false); useEffect(() => { + // Optional shortcut: pre-load an export recipe from a shared URL. async function run() { if (!recipeUrl) { return; @@ -70,10 +74,12 @@ export function ImportPage({ >(); // Local tab to switch between importing a file and exporting from selection + // Toggle between the import dropzone and export selector screens. const [mode, setMode] = useState<'import' | 'export'>(initialMode); // Removed postImportSummary: no post-import overview screen + // Parse the dropped JSON and hydrate our `ExportSchema` helper. async function handleDrop(filename: string, doc: ExportDoc) { try { const schema = new ExportSchema(doc); @@ -90,7 +96,11 @@ export function ImportPage({ const importTask = useLongTask(); const exportAllTask = useLongTask(); const exportPreparingTask = useLongTask(); - const exportSelectionTask = useLongTask(); + const { task: exportSelectionTask, runExport: runSelectionExport } = + useSchemaExportTask({ + schema: projectSchema, + ctx, + }); const conflictsTask = useLongTask(); // Removed adminDomain lookup; no post-import summary links needed @@ -105,10 +115,7 @@ export function ImportPage({ selectAllBlocks: handleSelectAllBlocks, } = useExportSelection({ schema: projectSchema, enabled: mode === 'export' }); - const { - conflicts, - setConflicts, - } = useConflictsBuilder({ + const { conflicts, setConflicts } = useConflictsBuilder({ exportSchema: exportSchema?.[1], projectSchema, task: conflictsTask.controller, @@ -127,9 +134,9 @@ export function ImportPage({ setExportStarted(true); }; - // Listen for bottom Cancel action from ConflictsManager useEffect(() => { + // ConflictsManager dispatches a custom event when the user cancels from the sidebar CTA. const onRequestCancel = async () => { if (!exportSchema) return; const result = await ctx.openConfirm({ @@ -184,6 +191,7 @@ export function ImportPage({ resolutions, ); + // Execute the import while streaming progress updates into the overlay. await importSchema( importDoc, client, @@ -274,50 +282,50 @@ export function ImportPage({
    {mode === 'import' ? ( - {(button) => - exportSchema ? ( - conflicts ? ( - - + exportSchema ? ( + conflicts ? ( + + + - - - - ) : ( - - ) - ) : loadingRecipeByUrl ? ( - + ctx={ctx} + /> + + ) : ( -
    -
    -
    - Upload your schema export file -
    - -
    -

    - Drag and drop your exported JSON file here, or - click the button to select one from your computer. -

    - {button} -
    + + ) + ) : loadingRecipeByUrl ? ( + + ) : ( +
    +
    +
    + Upload your schema export file
    -
    - {hideModeToggle - ? '💡 Need to bulk export your schema? Go to the Export page under Schema.' - : '💡 Need to bulk export your schema? Switch to the Export tab above.'} + +
    +

    + Drag and drop your exported JSON file here, or click + the button to select one from your computer. +

    + {button}
    - ) - } - +
    + {hideModeToggle + ? '💡 Need to bulk export your schema? Go to the Export page under Schema.' + : '💡 Need to bulk export your schema? Switch to the Export tab above.'} +
    +
    + ) + } + ) : (
    {!exportStarted ? ( @@ -360,69 +368,13 @@ export function ImportPage({ setExportStarted(false); exportPreparingTask.controller.reset(); }} - onExport={async (itemTypeIds, pluginIds) => { - try { - const total = pluginIds.length + itemTypeIds.length * 2; - exportSelectionTask.controller.start({ - done: 0, - total, - label: 'Preparing export…', - }); - let done = 0; - - const exportDoc = await buildExportDoc( - projectSchema, - exportInitialItemTypeIds[0], - itemTypeIds, - pluginIds, - { - onProgress: (label: string) => { - done += 1; - exportSelectionTask.controller.setProgress({ - done, - total, - label, - }); - }, - shouldCancel: () => - exportSelectionTask.controller.isCancelRequested(), - }, - ); - - if (exportSelectionTask.controller.isCancelRequested()) { - throw new Error('Export cancelled'); - } - - downloadJSON(exportDoc, { - fileName: 'export.json', - prettify: true, - }); - exportSelectionTask.controller.complete({ - done: total, - total, - label: 'Export completed', - }); - ctx.notice('Export completed successfully.'); - } catch (e) { - console.error('Selection export failed', e); - if ( - e instanceof Error && - e.message === 'Export cancelled' - ) { - exportSelectionTask.controller.complete({ - label: 'Export cancelled', - }); - ctx.notice('Export canceled'); - } else { - exportSelectionTask.controller.fail(e); - ctx.alert( - 'Could not complete the export. Please try again.', - ); - } - } finally { - exportSelectionTask.controller.reset(); - } - }} + onExport={(itemTypeIds, pluginIds) => + runSelectionExport({ + rootItemTypeId: exportInitialItemTypeIds[0], + itemTypeIds, + pluginIds, + }) + } /> )} {/* Fallback note removed per UX request */} @@ -432,96 +384,104 @@ export function ImportPage({
    - - state.cancelRequested - ? 'Cancelling import…' - : 'Sit tight, we’re applying models, fields, and plugins…' - } - ariaLabel="Import in progress" - progressLabel={(progress, state) => - state.cancelRequested - ? 'Stopping at next safe point…' - : progress.label ?? '' - } - cancel={() => ({ - label: 'Cancel import', - intent: importTask.state.cancelRequested ? 'muted' : 'negative', - disabled: importTask.state.cancelRequested, - onCancel: async () => { - if (!exportSchema) return; - const result = await ctx.openConfirm({ - title: 'Cancel import in progress?', - content: - 'Stopping now can leave partial changes in your project. Some models or blocks may be created without relationships, some fields or fieldsets may already exist, and plugin installations or editor settings may be incomplete. You can run the import again to finish or manually clean up. Are you sure you want to cancel?', - choices: [ - { - label: 'Yes, cancel the import', - value: 'yes', - intent: 'negative', - }, - ], - cancel: { - label: 'Nevermind', - value: false, - intent: 'positive', + + state.cancelRequested + ? 'Cancelling import…' + : 'Sit tight, we’re applying models, fields, and plugins…', + ariaLabel: 'Import in progress', + progressLabel: (progress, state) => + state.cancelRequested + ? 'Stopping at next safe point…' + : (progress.label ?? ''), + cancel: () => ({ + label: 'Cancel import', + intent: importTask.state.cancelRequested ? 'muted' : 'negative', + disabled: importTask.state.cancelRequested, + onCancel: async () => { + if (!exportSchema) return; + const result = await ctx.openConfirm({ + title: 'Cancel import in progress?', + content: + 'Stopping now can leave partial changes in your project. Some models or blocks may be created without relationships, some fields or fieldsets may already exist, and plugin installations or editor settings may be incomplete. You can run the import again to finish or manually clean up. Are you sure you want to cancel?', + choices: [ + { + label: 'Yes, cancel the import', + value: 'yes', + intent: 'negative', + }, + ], + cancel: { + label: 'Nevermind', + value: false, + intent: 'positive', + }, + }); + + if (result === 'yes') { + importTask.controller.requestCancel(); + } }, - }); - - if (result === 'yes') { - importTask.controller.requestCancel(); - } + }), }, - })} - /> - - - progress.label ?? 'Loading project schema…' - } - cancel={() => ({ - label: 'Cancel export', - intent: exportAllTask.state.cancelRequested ? 'muted' : 'negative', - disabled: exportAllTask.state.cancelRequested, - onCancel: () => exportAllTask.controller.requestCancel(), - })} - /> - - progress.label ?? 'Preparing import…'} - overlayZIndex={9998} - /> - - progress.label ?? 'Preparing export…'} - /> - - progress.label ?? 'Preparing export…'} - cancel={() => ({ - label: 'Cancel export', - intent: exportSelectionTask.state.cancelRequested ? 'muted' : 'negative', - disabled: exportSelectionTask.state.cancelRequested, - onCancel: () => exportSelectionTask.controller.requestCancel(), - })} + { + id: 'export-all', + task: exportAllTask, + title: 'Exporting entire schema', + subtitle: 'Sit tight, we’re gathering models, blocks, and plugins…', + ariaLabel: 'Export in progress', + progressLabel: (progress) => + progress.label ?? 'Loading project schema…', + cancel: () => ({ + label: 'Cancel export', + intent: exportAllTask.state.cancelRequested + ? 'muted' + : 'negative', + disabled: exportAllTask.state.cancelRequested, + onCancel: () => exportAllTask.controller.requestCancel(), + }), + }, + { + id: 'conflicts', + task: conflictsTask, + title: 'Preparing import', + subtitle: + 'Sit tight, we’re scanning your export against the project…', + ariaLabel: 'Preparing import', + progressLabel: (progress) => progress.label ?? 'Preparing import…', + overlayZIndex: 9998, + }, + { + id: 'export-prep', + task: exportPreparingTask, + title: 'Preparing export', + subtitle: + 'Sit tight, we’re setting up your models, blocks, and plugins…', + ariaLabel: 'Preparing export', + progressLabel: (progress) => progress.label ?? 'Preparing export…', + }, + { + id: 'export-selection', + task: exportSelectionTask, + title: 'Exporting selection', + subtitle: 'Sit tight, we’re gathering models, blocks, and plugins…', + ariaLabel: 'Export in progress', + progressLabel: (progress) => progress.label ?? 'Preparing export…', + cancel: () => ({ + label: 'Cancel export', + intent: exportSelectionTask.state.cancelRequested + ? 'muted' + : 'negative', + disabled: exportSelectionTask.state.cancelRequested, + onCancel: () => exportSelectionTask.controller.requestCancel(), + }), + }, + ]} /> ); diff --git a/import-export-schema/src/shared/constants/graph.ts b/import-export-schema/src/shared/constants/graph.ts new file mode 100644 index 00000000..9599de33 --- /dev/null +++ b/import-export-schema/src/shared/constants/graph.ts @@ -0,0 +1 @@ +export const GRAPH_NODE_THRESHOLD = 60; diff --git a/import-export-schema/src/shared/hooks/useCmaClient.ts b/import-export-schema/src/shared/hooks/useCmaClient.ts index 6a535f32..99a79a3a 100644 --- a/import-export-schema/src/shared/hooks/useCmaClient.ts +++ b/import-export-schema/src/shared/hooks/useCmaClient.ts @@ -1,9 +1,6 @@ -import { useMemo } from 'react'; import type { Client, ClientConfigOptions } from '@datocms/cma-client'; -import type { - RenderConfigScreenCtx, - RenderPageCtx, -} from 'datocms-plugin-sdk'; +import type { RenderConfigScreenCtx, RenderPageCtx } from 'datocms-plugin-sdk'; +import { useMemo } from 'react'; import { createCmaClient } from '@/utils/createCmaClient'; type AuthCtx = diff --git a/import-export-schema/src/shared/hooks/useConflictsBuilder.ts b/import-export-schema/src/shared/hooks/useConflictsBuilder.ts index deef4c66..6b64eda3 100644 --- a/import-export-schema/src/shared/hooks/useConflictsBuilder.ts +++ b/import-export-schema/src/shared/hooks/useConflictsBuilder.ts @@ -1,11 +1,15 @@ import { useCallback, useEffect, useState } from 'react'; -import type { ProjectSchema } from '@/utils/ProjectSchema'; import type { ExportSchema } from '@/entrypoints/ExportPage/ExportSchema'; import buildConflicts, { type Conflicts, } from '@/entrypoints/ImportPage/ConflictsManager/buildConflicts'; import type { LongTaskController } from '@/shared/tasks/useLongTask'; +import type { ProjectSchema } from '@/utils/ProjectSchema'; +/** + * Builds the import conflict summary in the background while providing a + * reusable `refresh` helper and progress reporting via `LongTask`. + */ export function useConflictsBuilder({ exportSchema, projectSchema, @@ -18,6 +22,7 @@ export function useConflictsBuilder({ const [conflicts, setConflicts] = useState(); const [refreshKey, setRefreshKey] = useState(0); + // Rebuild conflicts whenever the export document, schema, or refresh key changes. useEffect(() => { let cancelled = false; async function run() { @@ -27,11 +32,15 @@ export function useConflictsBuilder({ } try { task.start({ done: 0, total: 1, label: 'Preparing import…' }); - const result = await buildConflicts(exportSchema, projectSchema, (p) => { - if (!cancelled) { - task.setProgress(p); - } - }); + const result = await buildConflicts( + exportSchema, + projectSchema, + (p) => { + if (!cancelled) { + task.setProgress(p); + } + }, + ); if (cancelled) return; setConflicts(result); } finally { diff --git a/import-export-schema/src/shared/hooks/useExportAllHandler.ts b/import-export-schema/src/shared/hooks/useExportAllHandler.ts index c617e37a..df98e754 100644 --- a/import-export-schema/src/shared/hooks/useExportAllHandler.ts +++ b/import-export-schema/src/shared/hooks/useExportAllHandler.ts @@ -1,9 +1,9 @@ -import { useCallback } from 'react'; import type { RenderPageCtx } from 'datocms-plugin-sdk'; -import { downloadJSON } from '@/utils/downloadJson'; -import type { ProjectSchema } from '@/utils/ProjectSchema'; +import { useCallback } from 'react'; import buildExportDoc from '@/entrypoints/ExportPage/buildExportDoc'; import type { LongTaskController } from '@/shared/tasks/useLongTask'; +import { downloadJSON } from '@/utils/downloadJson'; +import type { ProjectSchema } from '@/utils/ProjectSchema'; type Options = { ctx: RenderPageCtx; @@ -11,6 +11,9 @@ type Options = { task: LongTaskController; }; +/** + * Returns a memoized handler that exports the entire schema with confirmation + progress. + */ export function useExportAllHandler({ ctx, schema, task }: Options) { return useCallback(async () => { try { @@ -39,6 +42,7 @@ export function useExportAllHandler({ ctx, schema, task }: Options) { ctx.alert('No item types found in this environment.'); return; } + // Use the first regular model as root to match older exports; fall back to any. const preferredRoot = allTypes.find((t) => !t.attributes.modular_block) || allTypes[0]; const total = allPlugins.length + allTypes.length * 2; diff --git a/import-export-schema/src/shared/hooks/useExportSelection.ts b/import-export-schema/src/shared/hooks/useExportSelection.ts index 85d7cb9d..c87f903f 100644 --- a/import-export-schema/src/shared/hooks/useExportSelection.ts +++ b/import-export-schema/src/shared/hooks/useExportSelection.ts @@ -1,5 +1,6 @@ import type { SchemaTypes } from '@datocms/cma-client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { isDefined } from '@/utils/isDefined'; import type { ProjectSchema } from '@/utils/ProjectSchema'; type UseExportSelectionOptions = { @@ -16,6 +17,9 @@ type UseExportSelectionResult = { selectAllBlocks: () => void; }; +/** + * Fetches item types and keeps a derived selection list in sync with the schema client. + */ export function useExportSelection({ schema, enabled = true, @@ -46,37 +50,26 @@ export function useExportSelection({ }; }, [schema, enabled]); - useEffect(() => { - if (!enabled) { - return; + const itemTypesById = useMemo(() => { + if (!allItemTypes) { + return undefined; } + return new Map(allItemTypes.map((it) => [it.id, it])); + }, [allItemTypes]); - if (selectedIds.length === 0) { + useEffect(() => { + if (!enabled) { setSelectedItemTypes([]); return; } - - let cancelled = false; - async function resolve() { - const list: SchemaTypes.ItemType[] = []; - for (const id of selectedIds) { - const itemType = await schema.getItemTypeById(id); - if (cancelled) { - return; - } - list.push(itemType); - } - if (!cancelled) { - setSelectedItemTypes(list); - } + if (!itemTypesById) { + return; } - void resolve(); - - return () => { - cancelled = true; - }; - }, [schema, enabled, selectedIds.join('-')]); + setSelectedItemTypes( + selectedIds.map((id) => itemTypesById.get(id)).filter(isDefined), + ); + }, [enabled, itemTypesById, selectedIds.join('-')]); const selectAllModels = useCallback(() => { if (!allItemTypes) { diff --git a/import-export-schema/src/shared/hooks/useProjectSchema.ts b/import-export-schema/src/shared/hooks/useProjectSchema.ts index 1d769baa..1164e6da 100644 --- a/import-export-schema/src/shared/hooks/useProjectSchema.ts +++ b/import-export-schema/src/shared/hooks/useProjectSchema.ts @@ -1,9 +1,6 @@ -import { useMemo } from 'react'; import type { Client, ClientConfigOptions } from '@datocms/cma-client'; -import type { - RenderConfigScreenCtx, - RenderPageCtx, -} from 'datocms-plugin-sdk'; +import type { RenderConfigScreenCtx, RenderPageCtx } from 'datocms-plugin-sdk'; +import { useMemo } from 'react'; import { ProjectSchema } from '@/utils/ProjectSchema'; import { useCmaClient } from './useCmaClient'; diff --git a/import-export-schema/src/shared/hooks/useSchemaExportTask.ts b/import-export-schema/src/shared/hooks/useSchemaExportTask.ts new file mode 100644 index 00000000..25eef348 --- /dev/null +++ b/import-export-schema/src/shared/hooks/useSchemaExportTask.ts @@ -0,0 +1,97 @@ +import type { RenderPageCtx } from 'datocms-plugin-sdk'; +import { useCallback } from 'react'; +import buildExportDoc from '@/entrypoints/ExportPage/buildExportDoc'; +import { useLongTask } from '@/shared/tasks/useLongTask'; +import { downloadJSON } from '@/utils/downloadJson'; +import type { ProjectSchema } from '@/utils/ProjectSchema'; + +type RunExportArgs = { + rootItemTypeId: string; + itemTypeIds: string[]; + pluginIds: string[]; + fileName?: string; +}; + +type UseSchemaExportTaskOptions = { + schema: ProjectSchema; + ctx: RenderPageCtx; + defaultFileName?: string; +}; + +type SchemaExportTask = { + runExport: (args: RunExportArgs) => Promise; + task: ReturnType; +}; + +/** + * Shared helper that wraps export doc building with progress + cancellation handling. + */ +export function useSchemaExportTask({ + schema, + ctx, + defaultFileName = 'export.json', +}: UseSchemaExportTaskOptions): SchemaExportTask { + const task = useLongTask(); + + const runExport = useCallback( + async ({ + rootItemTypeId, + itemTypeIds, + pluginIds, + fileName, + }: RunExportArgs) => { + try { + const total = pluginIds.length + itemTypeIds.length * 2; + task.controller.start({ + done: 0, + total, + label: 'Preparing export…', + }); + let done = 0; + + const exportDoc = await buildExportDoc( + schema, + rootItemTypeId, + itemTypeIds, + pluginIds, + { + onProgress: (label: string) => { + done += 1; + task.controller.setProgress({ done, total, label }); + }, + shouldCancel: () => task.controller.isCancelRequested(), + }, + ); + + if (task.controller.isCancelRequested()) { + throw new Error('Export cancelled'); + } + + downloadJSON(exportDoc, { + fileName: fileName ?? defaultFileName, + prettify: true, + }); + task.controller.complete({ + done: total, + total, + label: 'Export completed', + }); + ctx.notice('Export completed successfully.'); + } catch (error) { + console.error('Schema export failed', error); + if (error instanceof Error && error.message === 'Export cancelled') { + task.controller.complete({ label: 'Export cancelled' }); + ctx.notice('Export canceled'); + } else { + task.controller.fail(error); + ctx.alert('Could not complete the export. Please try again.'); + } + } finally { + task.controller.reset(); + } + }, + [ctx, defaultFileName, schema, task.controller], + ); + + return { runExport, task }; +} diff --git a/import-export-schema/src/shared/tasks/useLongTask.ts b/import-export-schema/src/shared/tasks/useLongTask.ts index 4aa81940..f240554b 100644 --- a/import-export-schema/src/shared/tasks/useLongTask.ts +++ b/import-export-schema/src/shared/tasks/useLongTask.ts @@ -52,15 +52,14 @@ function mergeProgress( }; } -/** - * Hook for managing long-running async tasks (imports, exports, etc.). - * Provides declarative state for progress overlays and cancel handling. - */ export type UseLongTaskResult = { state: LongTaskState; controller: LongTaskController; }; +/** + * Manage long-running async tasks (imports, exports, etc.) with progress + cancel support. + */ export function useLongTask(): UseLongTaskResult { const [state, setState] = useState(initialState); const cancelRequestedRef = useRef(false); diff --git a/import-export-schema/src/utils/ProjectSchema.ts b/import-export-schema/src/utils/ProjectSchema.ts index 2d77fa54..25338b69 100644 --- a/import-export-schema/src/utils/ProjectSchema.ts +++ b/import-export-schema/src/utils/ProjectSchema.ts @@ -1,5 +1,8 @@ import type { Client, SchemaTypes } from '@datocms/cma-client'; +/** + * Thin caching layer around the CMA client that smooths out rate limits and provides lookups. + */ export class ProjectSchema { public client: Client; private itemTypesPromise: Promise | null = null; diff --git a/import-export-schema/src/utils/createCmaClient.ts b/import-export-schema/src/utils/createCmaClient.ts index 8ffaa456..2ecd94c7 100644 --- a/import-export-schema/src/utils/createCmaClient.ts +++ b/import-export-schema/src/utils/createCmaClient.ts @@ -9,6 +9,7 @@ type CtxWithAuth = | Pick | Pick; +/** Create a CMA client configured for the current plugin session. */ export function createCmaClient( ctx: CtxWithAuth, overrides?: Partial, diff --git a/import-export-schema/src/utils/datocms/fieldTypeInfo.ts b/import-export-schema/src/utils/datocms/fieldTypeInfo.ts index cd57df46..b9f6639e 100644 --- a/import-export-schema/src/utils/datocms/fieldTypeInfo.ts +++ b/import-export-schema/src/utils/datocms/fieldTypeInfo.ts @@ -1,5 +1,7 @@ import type { FieldAttributes } from '@datocms/cma-client/dist/types/generated/SchemaTypes'; +/** Utilities for resolving editor metadata used in field appearance exports/imports. */ + type FieldTypeInfo = Record< string, { diff --git a/import-export-schema/src/utils/datocms/schema.ts b/import-export-schema/src/utils/datocms/schema.ts index 0a78a8c8..1761fa35 100644 --- a/import-export-schema/src/utils/datocms/schema.ts +++ b/import-export-schema/src/utils/datocms/schema.ts @@ -1,6 +1,9 @@ import type { SchemaTypes } from '@datocms/cma-client'; import type { FieldAttributes } from '@datocms/cma-client/dist/types/generated/SchemaTypes'; import { get } from 'lodash-es'; +/** + * Shared lookups and helper utilities for interpreting DatoCMS field metadata. + */ import boolean from '@/icons/fieldgroup-boolean.svg?react'; import color from '@/icons/fieldgroup-color.svg?react'; import datetime from '@/icons/fieldgroup-datetime.svg?react'; @@ -195,6 +198,7 @@ export const validatorsContainingBlocks: Array<{ }, ]; +// Collect all item type IDs referenced by validators for the given field. export function findLinkedItemTypeIds(field: SchemaTypes.Field) { const fieldLinkedItemTypeIds = new Set(); @@ -220,6 +224,7 @@ export function findLinkedItemTypeIds(field: SchemaTypes.Field) { return fieldLinkedItemTypeIds; } +// Collect plugin IDs referenced via appearance editors/addons, filtering by installed list when available. export function findLinkedPluginIds( field: SchemaTypes.Field, installedPluginIds?: Set, diff --git a/import-export-schema/src/utils/datocms/validators.ts b/import-export-schema/src/utils/datocms/validators.ts index dae617ce..3f8194ab 100644 --- a/import-export-schema/src/utils/datocms/validators.ts +++ b/import-export-schema/src/utils/datocms/validators.ts @@ -5,6 +5,7 @@ import { validatorsContainingLinks, } from '@/utils/datocms/schema'; +/** Map helper functions for trimming validator references during export/import. */ export function collectLinkValidatorPaths( fieldType: SchemaTypes.Field['attributes']['field_type'], ): string[] { @@ -14,6 +15,7 @@ export function collectLinkValidatorPaths( ].map((i) => i.validator); } +// Clone a field's validators while keeping only allowed item type references. export function filterValidatorIds( field: SchemaTypes.Field, allowedItemTypeIds: string[], diff --git a/import-export-schema/src/utils/debug.ts b/import-export-schema/src/utils/debug.ts new file mode 100644 index 00000000..f1d2a634 --- /dev/null +++ b/import-export-schema/src/utils/debug.ts @@ -0,0 +1,31 @@ +const DEFAULT_FLAG = 'schemaDebug'; + +function readFlag(flag: string): boolean { + try { + if (typeof window === 'undefined') { + return false; + } + return window.localStorage?.getItem(flag) === '1'; + } catch { + return false; + } +} + +export function isDebugFlagEnabled(flag: string = DEFAULT_FLAG): boolean { + return readFlag(flag); +} + +export function debugLog( + message: string, + payload?: unknown, + flag: string = DEFAULT_FLAG, +) { + if (!readFlag(flag)) { + return; + } + if (payload === undefined) { + console.log(`[${flag}] ${message}`); + } else { + console.log(`[${flag}] ${message}`, payload); + } +} diff --git a/import-export-schema/src/utils/emojiAgnosticSorter.ts b/import-export-schema/src/utils/emojiAgnosticSorter.ts index b0945144..706e6f16 100644 --- a/import-export-schema/src/utils/emojiAgnosticSorter.ts +++ b/import-export-schema/src/utils/emojiAgnosticSorter.ts @@ -1,7 +1,11 @@ import emojiRegexText from 'emoji-regex'; +/** + * Helpers for ordering text labels that may start with representative emojis. + */ const emojiRegexp = emojiRegexText(); +// Capture an optional leading emoji and strip padding spaces. const formatRegexp = new RegExp( `^(${emojiRegexp.source})\\s*(.*)$`, emojiRegexp.flags.replace('g', ''), diff --git a/import-export-schema/src/utils/graph/analysis.ts b/import-export-schema/src/utils/graph/analysis.ts index 41c9cd7c..1bf43ee9 100644 --- a/import-export-schema/src/utils/graph/analysis.ts +++ b/import-export-schema/src/utils/graph/analysis.ts @@ -2,6 +2,8 @@ import type { ItemTypeNode } from '@/components/ItemTypeNodeRenderer'; import type { PluginNode } from '@/components/PluginNodeRenderer'; import type { AppEdge, Graph } from './types'; +/** Graph analytics helpers for metrics, traversals, and conflict tooling. */ + type Adjacency = Map>; function ensure(map: Map>, key: string) { diff --git a/import-export-schema/src/utils/graph/buildGraph.ts b/import-export-schema/src/utils/graph/buildGraph.ts index ce5189f6..5ac3459c 100644 --- a/import-export-schema/src/utils/graph/buildGraph.ts +++ b/import-export-schema/src/utils/graph/buildGraph.ts @@ -7,6 +7,7 @@ import { deterministicGraphSort } from '@/utils/graph/sort'; import type { Graph, SchemaProgressUpdate } from '@/utils/graph/types'; import type { ISchemaSource } from '@/utils/schema/ISchemaSource'; +/** Build a dependency graph from any schema source, reporting progress as we traverse. */ type BuildGraphOptions = { source: ISchemaSource; initialItemTypes: SchemaTypes.ItemType[]; @@ -23,6 +24,17 @@ export async function buildGraph({ onProgress, }: BuildGraphOptions): Promise { const graph: Graph = { nodes: [], edges: [] }; + const hierarchyEdgeSet = new Set(); + const hierarchyEdges: Array<{ source: string; target: string }> = []; + + function recordHierarchyEdge(sourceId: string, targetId: string) { + const key = `${sourceId}->${targetId}`; + if (hierarchyEdgeSet.has(key)) { + return; + } + hierarchyEdgeSet.add(key); + hierarchyEdges.push({ source: sourceId, target: targetId }); + } const knownPluginIds = await source.getKnownPluginIds(); @@ -109,6 +121,20 @@ export async function buildGraph({ knownPluginIds, ); + for (const linkedItemTypeId of linkedItemTypeIds) { + recordHierarchyEdge( + `itemType--${itemType.id}`, + `itemType--${linkedItemTypeId}`, + ); + } + + for (const linkedPluginId of linkedPluginIds) { + recordHierarchyEdge( + `itemType--${itemType.id}`, + `plugin--${linkedPluginId}`, + ); + } + // Include edges when: // - No selection was provided (eg. Import graph) → include all edges // - The source item type is selected @@ -118,7 +144,8 @@ export async function buildGraph({ selectedItemTypeIds.includes(itemType.id) || Array.from(linkedItemTypeIds).some((id) => selectedItemTypeIds.includes(id), - ); + ) || + edges.length > 0; if (includeEdges) { graph.edges.push(...edges); @@ -168,6 +195,10 @@ export async function buildGraph({ const sortedGraph = deterministicGraphSort(graph); if (sortedGraph.nodes.length === 0) return sortedGraph; - const hierarchy = buildHierarchyNodes(sortedGraph, selectedItemTypeIds); + const hierarchy = buildHierarchyNodes( + sortedGraph, + selectedItemTypeIds, + hierarchyEdges, + ); return rebuildGraphWithPositionsFromHierarchy(hierarchy, sortedGraph.edges); } diff --git a/import-export-schema/src/utils/graph/buildHierarchyNodes.ts b/import-export-schema/src/utils/graph/buildHierarchyNodes.ts index 7e840c49..40a34806 100644 --- a/import-export-schema/src/utils/graph/buildHierarchyNodes.ts +++ b/import-export-schema/src/utils/graph/buildHierarchyNodes.ts @@ -1,17 +1,36 @@ import { stratify } from 'd3-hierarchy'; import type { AppNode, Graph } from './types'; +/** + * Build a D3 hierarchy from the graph, optionally preferring certain inbound edges. + */ export function buildHierarchyNodes( graph: Graph, priorityGivenToEdgesComingFromItemTypeIds?: string[], + fallbackEdges: Array<{ source: string; target: string }> = [], ) { const nodeIds = new Set(graph.nodes.map((n) => n.id)); - const targets = new Set(graph.edges.map((e) => e.target)); + const targetsFromGraph = new Set(graph.edges.map((e) => e.target)); + + const fallbackParentsByTarget = new Map>(); + for (const { source, target } of fallbackEdges) { + if (!nodeIds.has(source) || !nodeIds.has(target)) { + continue; + } + const existing = fallbackParentsByTarget.get(target); + if (existing) { + existing.add(source); + } else { + fallbackParentsByTarget.set(target, new Set([source])); + } + } + + const fallbackTargets = new Set(fallbackParentsByTarget.keys()); + const targets = new Set([...targetsFromGraph, ...fallbackTargets]); const rootIds = Array.from(nodeIds).filter((id) => !targets.has(id)); const hasMultipleRoots = rootIds.length > 1; - - const nodesForStratify: AppNode[] = hasMultipleRoots + const nodesForHierarchy: AppNode[] = hasMultipleRoots ? ([ // Synthetic root only used to satisfy single-root requirement { @@ -24,6 +43,14 @@ export function buildHierarchyNodes( ] as AppNode[]) : graph.nodes; + const priorityNodeIds = new Set( + (priorityGivenToEdgesComingFromItemTypeIds ?? []).flatMap((id) => [ + id, + `itemType--${id}`, + `plugin--${id}`, + ]), + ); + return stratify() .id((d) => d.id) .parentId((d) => { @@ -33,22 +60,30 @@ export function buildHierarchyNodes( const edgesPointingToNode = graph.edges.filter((e) => e.target === d.id); - if (!priorityGivenToEdgesComingFromItemTypeIds) { - return edgesPointingToNode[0]?.source; + const fallbackSources = fallbackParentsByTarget.get(d.id); + const fallbackCandidates = fallbackSources + ? Array.from(fallbackSources).map((source) => ({ source })) + : []; + + const candidates = + edgesPointingToNode.length > 0 ? edgesPointingToNode : fallbackCandidates; + + if (candidates.length === 0) { + return candidates[0]?.source; } - if (edgesPointingToNode.length <= 0) { - return edgesPointingToNode[0]?.source; + if (priorityNodeIds.size === 0) { + return candidates[0]?.source; } - const proprityEdges = edgesPointingToNode.filter((e) => - priorityGivenToEdgesComingFromItemTypeIds.includes(e.source), + const priorityEdges = candidates.filter((e) => + priorityNodeIds.has(e.source), ); - const regularEdges = edgesPointingToNode.filter( - (e) => !priorityGivenToEdgesComingFromItemTypeIds.includes(e.source), + const regularEdges = candidates.filter( + (e) => !priorityNodeIds.has(e.source), ); - return [...proprityEdges, ...regularEdges][0]?.source; - })(nodesForStratify); + return [...priorityEdges, ...regularEdges][0]?.source; + })(nodesForHierarchy); } diff --git a/import-export-schema/src/utils/graph/dependencies.ts b/import-export-schema/src/utils/graph/dependencies.ts index 43180c62..8e1c1dd4 100644 --- a/import-export-schema/src/utils/graph/dependencies.ts +++ b/import-export-schema/src/utils/graph/dependencies.ts @@ -4,29 +4,60 @@ import { } from '@/utils/datocms/schema'; import type { Graph } from '@/utils/graph/types'; -export function collectDependencies( - graph: Graph, - selectedItemTypeIds: string[], - installedPluginIds?: Set, -) { - const beforeItemTypeIds = new Set(selectedItemTypeIds); - const nextItemTypeIds = new Set(selectedItemTypeIds); - const nextPluginIds = new Set(); - - const queue = [...selectedItemTypeIds]; +export type DependencyExpansionResult = { + itemTypeIds: Set; + pluginIds: Set; + addedItemTypeIds: string[]; + addedPluginIds: string[]; +}; + +type ExpandOptions = { + graph?: Graph; + seedItemTypeIds: Iterable; + seedPluginIds: Iterable; + installedPluginIds?: Set; +}; + +/** + * Expand the current selection with all linked item types and plugins. + */ +export function expandSelectionWithDependencies({ + graph, + seedItemTypeIds, + seedPluginIds, + installedPluginIds, +}: ExpandOptions): DependencyExpansionResult { + const initialItemIds = Array.from(new Set(seedItemTypeIds)); + const initialPluginIds = Array.from(new Set(seedPluginIds)); + const nextItemTypeIds = new Set(initialItemIds); + const nextPluginIds = new Set(initialPluginIds); + + if (!graph) { + return { + itemTypeIds: nextItemTypeIds, + pluginIds: nextPluginIds, + addedItemTypeIds: [], + addedPluginIds: [], + }; + } + + const queue = [...initialItemIds]; while (queue.length > 0) { - const popped = queue.pop(); - if (!popped) break; - const id = popped; - const node = graph.nodes.find((n) => n.id === `itemType--${id}`); - const fields = node?.type === 'itemType' ? node.data.fields : []; - for (const field of fields) { + const currentId = queue.pop(); + if (!currentId) continue; + const node = graph.nodes.find( + (candidate) => candidate.id === `itemType--${currentId}`, + ); + if (!node || node.type !== 'itemType') continue; + + for (const field of node.data.fields) { for (const linkedId of findLinkedItemTypeIds(field)) { if (!nextItemTypeIds.has(linkedId)) { nextItemTypeIds.add(linkedId); queue.push(linkedId); } } + for (const pluginId of findLinkedPluginIds(field, installedPluginIds)) { nextPluginIds.add(pluginId); } @@ -34,11 +65,16 @@ export function collectDependencies( } const addedItemTypeIds = Array.from(nextItemTypeIds).filter( - (id) => !beforeItemTypeIds.has(id), + (id) => !initialItemIds.includes(id), ); + const addedPluginIds = Array.from(nextPluginIds).filter( + (id) => !initialPluginIds.includes(id), + ); + return { itemTypeIds: nextItemTypeIds, pluginIds: nextPluginIds, addedItemTypeIds, + addedPluginIds, }; } diff --git a/import-export-schema/src/utils/graph/edges.ts b/import-export-schema/src/utils/graph/edges.ts index d03102a2..2a122af9 100644 --- a/import-export-schema/src/utils/graph/edges.ts +++ b/import-export-schema/src/utils/graph/edges.ts @@ -7,6 +7,7 @@ import { } from '@/utils/datocms/schema'; import type { AppEdge } from '@/utils/graph/types'; +/** Build edges for a single item type, aggregating field references with arrows. */ export function buildEdgesForItemType( itemType: SchemaTypes.ItemType, fields: SchemaTypes.Field[], diff --git a/import-export-schema/src/utils/graph/index.ts b/import-export-schema/src/utils/graph/index.ts index 322d1079..d9d3153a 100644 --- a/import-export-schema/src/utils/graph/index.ts +++ b/import-export-schema/src/utils/graph/index.ts @@ -1,3 +1,4 @@ +// Re-export graph helpers for convenient multi-file imports. export * from './analysis'; export * from './buildGraph'; export * from './buildHierarchyNodes'; diff --git a/import-export-schema/src/utils/graph/nodes.ts b/import-export-schema/src/utils/graph/nodes.ts index 1e5169ce..b0bc0a70 100644 --- a/import-export-schema/src/utils/graph/nodes.ts +++ b/import-export-schema/src/utils/graph/nodes.ts @@ -2,6 +2,7 @@ import type { SchemaTypes } from '@datocms/cma-client'; import type { ItemTypeNode } from '@/components/ItemTypeNodeRenderer'; import type { PluginNode } from '@/components/PluginNodeRenderer'; +/** Lightweight constructors for React Flow nodes representing schema entities. */ export function buildPluginNode(plugin: SchemaTypes.Plugin): PluginNode { return { id: `plugin--${plugin.id}`, diff --git a/import-export-schema/src/utils/graph/rebuildGraphWithPositionsFromHierarchy.ts b/import-export-schema/src/utils/graph/rebuildGraphWithPositionsFromHierarchy.ts index 5bbe8091..dbacb854 100644 --- a/import-export-schema/src/utils/graph/rebuildGraphWithPositionsFromHierarchy.ts +++ b/import-export-schema/src/utils/graph/rebuildGraphWithPositionsFromHierarchy.ts @@ -1,6 +1,9 @@ import { type HierarchyNode, tree } from 'd3-hierarchy'; import type { AppNode, Graph } from './types'; +/** + * Convert a D3 hierarchy into positioned React Flow nodes while reusing edge data. + */ export function rebuildGraphWithPositionsFromHierarchy( hierarchy: HierarchyNode, edges: Graph['edges'], diff --git a/import-export-schema/src/utils/graph/sort.ts b/import-export-schema/src/utils/graph/sort.ts index 989e65c6..b9e24fea 100644 --- a/import-export-schema/src/utils/graph/sort.ts +++ b/import-export-schema/src/utils/graph/sort.ts @@ -1,6 +1,7 @@ import { sortBy } from 'lodash-es'; import type { AppNode, Graph } from '@/utils/graph/types'; +/** Stable ordering so layout + list views don't flicker across renders. */ export function deterministicGraphSort(graph: Graph) { return { nodes: sortBy(graph.nodes, [ diff --git a/import-export-schema/src/utils/graph/types.ts b/import-export-schema/src/utils/graph/types.ts index 8f8d21d4..e0a895f7 100644 --- a/import-export-schema/src/utils/graph/types.ts +++ b/import-export-schema/src/utils/graph/types.ts @@ -6,6 +6,7 @@ import { import type { ItemTypeNode } from '@/components/ItemTypeNodeRenderer'; import type { PluginNode } from '@/components/PluginNodeRenderer'; +/** Shared graph typings + edge registration used across export/import canvases. */ export type AppNode = ItemTypeNode | PluginNode; export type AppEdge = FieldEdge; diff --git a/import-export-schema/src/utils/icons.tsx b/import-export-schema/src/utils/icons.tsx index 30900689..fb3e330d 100644 --- a/import-export-schema/src/utils/icons.tsx +++ b/import-export-schema/src/utils/icons.tsx @@ -2,6 +2,7 @@ import DiamondIcon from '@/icons/diamond.svg?react'; import GridIcon from '@/icons/grid-2.svg?react'; import PuzzlePieceIcon from '@/icons/puzzle-piece.svg?react'; +/** Central place to expose schema-related icons for node renderers. */ const BlockIcon = () => { return ; }; diff --git a/import-export-schema/src/utils/isDefined.ts b/import-export-schema/src/utils/isDefined.ts index fdca5ef1..614d1dbf 100644 --- a/import-export-schema/src/utils/isDefined.ts +++ b/import-export-schema/src/utils/isDefined.ts @@ -1,3 +1,4 @@ +/** Type guard that filters out null/undefined/false values in array helpers. */ export function isDefined( value: T | null | undefined | false, ): value is NonNullable> { diff --git a/import-export-schema/src/utils/render.tsx b/import-export-schema/src/utils/render.tsx index af01c15c..306f2dac 100644 --- a/import-export-schema/src/utils/render.tsx +++ b/import-export-schema/src/utils/render.tsx @@ -4,6 +4,7 @@ import { createRoot } from 'react-dom/client'; const container = document.getElementById('root'); const root = createRoot(container!); +/** Render the plugin entry component with React strict mode enabled. */ export function render(component: React.ReactNode): void { root.render({component}); } diff --git a/import-export-schema/src/utils/schema/ExportSchemaSource.ts b/import-export-schema/src/utils/schema/ExportSchemaSource.ts index c3ed703f..2bef84ad 100644 --- a/import-export-schema/src/utils/schema/ExportSchemaSource.ts +++ b/import-export-schema/src/utils/schema/ExportSchemaSource.ts @@ -2,6 +2,7 @@ import type { SchemaTypes } from '@datocms/cma-client'; import type { ExportSchema } from '@/entrypoints/ExportPage/ExportSchema'; import type { ISchemaSource } from './ISchemaSource'; +/** Adapts an export document into the schema source interface for graph building. */ export class ExportSchemaSource implements ISchemaSource { private schema: ExportSchema; diff --git a/import-export-schema/src/utils/schema/ISchemaSource.ts b/import-export-schema/src/utils/schema/ISchemaSource.ts index dec92c84..7d4d425f 100644 --- a/import-export-schema/src/utils/schema/ISchemaSource.ts +++ b/import-export-schema/src/utils/schema/ISchemaSource.ts @@ -1,5 +1,6 @@ import type { SchemaTypes } from '@datocms/cma-client'; +/** Contract implemented by both live project schemas and serialized export docs. */ export interface ISchemaSource { getItemTypeById(id: string): Promise; getPluginById(id: string): Promise; diff --git a/import-export-schema/src/utils/schema/ProjectSchemaSource.ts b/import-export-schema/src/utils/schema/ProjectSchemaSource.ts index 9b2efd88..499877dc 100644 --- a/import-export-schema/src/utils/schema/ProjectSchemaSource.ts +++ b/import-export-schema/src/utils/schema/ProjectSchemaSource.ts @@ -2,6 +2,7 @@ import type { SchemaTypes } from '@datocms/cma-client'; import type { ProjectSchema } from '@/utils/ProjectSchema'; import type { ISchemaSource } from './ISchemaSource'; +/** Adapts the live CMA-backed project schema to the generic graph interface. */ export class ProjectSchemaSource implements ISchemaSource { private schema: ProjectSchema; diff --git a/import-export-schema/src/utils/types.ts b/import-export-schema/src/utils/types.ts index 6f5fc385..866766ba 100644 --- a/import-export-schema/src/utils/types.ts +++ b/import-export-schema/src/utils/types.ts @@ -1,5 +1,6 @@ import type { SchemaTypes } from '@datocms/cma-client'; +/** Canonical export document shapes handled by the importer/exporter. */ export type ExportDocV1 = { version: '1'; entities: Array< diff --git a/import-export-schema/tmp-dist/src/utils/graph/dependencies.js b/import-export-schema/tmp-dist/src/utils/graph/dependencies.js new file mode 100644 index 00000000..7a11fee5 --- /dev/null +++ b/import-export-schema/tmp-dist/src/utils/graph/dependencies.js @@ -0,0 +1,49 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.expandSelectionWithDependencies = expandSelectionWithDependencies; +const schema_1 = require("@/utils/datocms/schema"); +/** + * Expand the current selection with all linked item types and plugins. + */ +function expandSelectionWithDependencies({ graph, seedItemTypeIds, seedPluginIds, installedPluginIds, }) { + const initialItemIds = Array.from(new Set(seedItemTypeIds)); + const initialPluginIds = Array.from(new Set(seedPluginIds)); + const nextItemTypeIds = new Set(initialItemIds); + const nextPluginIds = new Set(initialPluginIds); + if (!graph) { + return { + itemTypeIds: nextItemTypeIds, + pluginIds: nextPluginIds, + addedItemTypeIds: [], + addedPluginIds: [], + }; + } + const queue = [...initialItemIds]; + while (queue.length > 0) { + const currentId = queue.pop(); + if (!currentId) + continue; + const node = graph.nodes.find((candidate) => candidate.id === `itemType--${currentId}`); + if (!node || node.type !== 'itemType') + continue; + for (const field of node.data.fields) { + for (const linkedId of (0, schema_1.findLinkedItemTypeIds)(field)) { + if (!nextItemTypeIds.has(linkedId)) { + nextItemTypeIds.add(linkedId); + queue.push(linkedId); + } + } + for (const pluginId of (0, schema_1.findLinkedPluginIds)(field, installedPluginIds)) { + nextPluginIds.add(pluginId); + } + } + } + const addedItemTypeIds = Array.from(nextItemTypeIds).filter((id) => !initialItemIds.includes(id)); + const addedPluginIds = Array.from(nextPluginIds).filter((id) => !initialPluginIds.includes(id)); + return { + itemTypeIds: nextItemTypeIds, + pluginIds: nextPluginIds, + addedItemTypeIds, + addedPluginIds, + }; +} diff --git a/import-export-schema/tmp-dist/src/utils/graph/types.js b/import-export-schema/tmp-dist/src/utils/graph/types.js new file mode 100644 index 00000000..921e857f --- /dev/null +++ b/import-export-schema/tmp-dist/src/utils/graph/types.js @@ -0,0 +1,7 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.edgeTypes = void 0; +const FieldEdgeRenderer_1 = require("@/components/FieldEdgeRenderer"); +exports.edgeTypes = { + field: FieldEdgeRenderer_1.FieldEdgeRenderer, +}; diff --git a/import-export-schema/tmp-dist/tmp-test-deps.js b/import-export-schema/tmp-dist/tmp-test-deps.js new file mode 100644 index 00000000..a32eeabb --- /dev/null +++ b/import-export-schema/tmp-dist/tmp-test-deps.js @@ -0,0 +1,55 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const dependencies_1 = require("./src/utils/graph/dependencies"); +/* Minimal stubs for SchemaTypes */ +const itemA = { id: 'A', type: 'item_type', attributes: { name: 'A', api_key: 'a', modular_block: false }, relationships: { fieldset: { data: null } } }; +const itemB = { id: 'B', type: 'item_type', attributes: { name: 'B', api_key: 'b', modular_block: false }, relationships: { fieldset: { data: null } } }; +const fieldLink = { + id: 'field1', + type: 'field', + attributes: { + label: 'Field 1', + api_key: 'field_1', + field_type: 'link', + validators: { + item_item_type: { item_types: ['B'] }, + }, + }, + relationships: { + fieldset: { data: null }, + }, +}; +const graph = { + nodes: [ + { + id: 'itemType--A', + type: 'itemType', + position: { x: 0, y: 0 }, + data: { + itemType: itemA, + fields: [fieldLink], + fieldsets: [], + }, + }, + { + id: 'itemType--B', + type: 'itemType', + position: { x: 0, y: 0 }, + data: { + itemType: itemB, + fields: [], + fieldsets: [], + }, + }, + ], + edges: [], +}; +const expansion = (0, dependencies_1.expandSelectionWithDependencies)({ + graph, + seedItemTypeIds: ['A'], + seedPluginIds: [], +}); +console.log({ + itemTypeIds: Array.from(expansion.itemTypeIds), + addedItemTypeIds: expansion.addedItemTypeIds, +}); diff --git a/import-export-schema/tmp-test-deps.ts b/import-export-schema/tmp-test-deps.ts new file mode 100644 index 00000000..9c24b308 --- /dev/null +++ b/import-export-schema/tmp-test-deps.ts @@ -0,0 +1,59 @@ +import type { Node } from '@xyflow/react'; +import type { Graph } from './src/utils/graph/types'; +import { expandSelectionWithDependencies } from './src/utils/graph/dependencies'; + +/* Minimal stubs for SchemaTypes */ +const itemA = { id: 'A', type: 'item_type', attributes: { name: 'A', api_key: 'a', modular_block: false }, relationships: { fieldset: { data: null } } } as any; +const itemB = { id: 'B', type: 'item_type', attributes: { name: 'B', api_key: 'b', modular_block: false }, relationships: { fieldset: { data: null } } } as any; +const fieldLink = { + id: 'field1', + type: 'field', + attributes: { + label: 'Field 1', + api_key: 'field_1', + field_type: 'link', + validators: { + item_item_type: { item_types: ['B'] }, + }, + }, + relationships: { + fieldset: { data: null }, + }, +} as any; + +const graph: Graph = { + nodes: [ + { + id: 'itemType--A', + type: 'itemType', + position: { x: 0, y: 0 }, + data: { + itemType: itemA, + fields: [fieldLink], + fieldsets: [], + }, + } as Node, + { + id: 'itemType--B', + type: 'itemType', + position: { x: 0, y: 0 }, + data: { + itemType: itemB, + fields: [], + fieldsets: [], + }, + } as Node, + ], + edges: [], +}; + +const expansion = expandSelectionWithDependencies({ + graph, + seedItemTypeIds: ['A'], + seedPluginIds: [], +}); + +console.log({ + itemTypeIds: Array.from(expansion.itemTypeIds), + addedItemTypeIds: expansion.addedItemTypeIds, +}); diff --git a/import-export-schema/tsconfig.app.tsbuildinfo b/import-export-schema/tsconfig.app.tsbuildinfo index 8796e417..6aef5035 100644 --- a/import-export-schema/tsconfig.app.tsbuildinfo +++ b/import-export-schema/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/exportstartpanel.tsx","./src/components/field.tsx","./src/components/fieldedgerenderer.tsx","./src/components/graphcanvas.tsx","./src/components/itemtypenoderenderer.tsx","./src/components/pluginnoderenderer.tsx","./src/components/progressoverlay.tsx","./src/components/progressstallnotice.tsx","./src/components/taskprogressoverlay.tsx","./src/components/bezier.ts","./src/entrypoints/config/index.tsx","./src/entrypoints/exporthome/index.tsx","./src/entrypoints/exportpage/entitiestoexportcontext.ts","./src/entrypoints/exportpage/exportitemtypenoderenderer.tsx","./src/entrypoints/exportpage/exportpluginnoderenderer.tsx","./src/entrypoints/exportpage/exportschema.ts","./src/entrypoints/exportpage/inner.tsx","./src/entrypoints/exportpage/largeselectionview.tsx","./src/entrypoints/exportpage/buildexportdoc.ts","./src/entrypoints/exportpage/buildgraphfromschema.ts","./src/entrypoints/exportpage/index.tsx","./src/entrypoints/exportpage/useanimatednodes.tsx","./src/entrypoints/exportpage/useexportgraph.ts","./src/entrypoints/importpage/filedropzone.tsx","./src/entrypoints/importpage/importitemtypenoderenderer.tsx","./src/entrypoints/importpage/importpluginnoderenderer.tsx","./src/entrypoints/importpage/inner.tsx","./src/entrypoints/importpage/largeselectionview.tsx","./src/entrypoints/importpage/resolutionsform.tsx","./src/entrypoints/importpage/selectedentitycontext.tsx","./src/entrypoints/importpage/buildgraphfromexportdoc.ts","./src/entrypoints/importpage/buildimportdoc.ts","./src/entrypoints/importpage/importschema.ts","./src/entrypoints/importpage/index.tsx","./src/entrypoints/importpage/conflictsmanager/collapsible.tsx","./src/entrypoints/importpage/conflictsmanager/conflictscontext.ts","./src/entrypoints/importpage/conflictsmanager/itemtypeconflict.tsx","./src/entrypoints/importpage/conflictsmanager/pluginconflict.tsx","./src/entrypoints/importpage/conflictsmanager/buildconflicts.ts","./src/entrypoints/importpage/conflictsmanager/index.tsx","./src/shared/hooks/usecmaclient.ts","./src/shared/hooks/useconflictsbuilder.ts","./src/shared/hooks/useexportallhandler.ts","./src/shared/hooks/useexportselection.ts","./src/shared/hooks/useprojectschema.ts","./src/shared/tasks/uselongtask.ts","./src/types/lodash-es.d.ts","./src/utils/projectschema.ts","./src/utils/createcmaclient.ts","./src/utils/downloadjson.ts","./src/utils/emojiagnosticsorter.ts","./src/utils/icons.tsx","./src/utils/isdefined.ts","./src/utils/render.tsx","./src/utils/types.ts","./src/utils/datocms/appearance.ts","./src/utils/datocms/fieldtypeinfo.ts","./src/utils/datocms/schema.ts","./src/utils/datocms/validators.ts","./src/utils/graph/analysis.ts","./src/utils/graph/buildgraph.ts","./src/utils/graph/buildhierarchynodes.ts","./src/utils/graph/dependencies.ts","./src/utils/graph/edges.ts","./src/utils/graph/index.ts","./src/utils/graph/nodes.ts","./src/utils/graph/rebuildgraphwithpositionsfromhierarchy.ts","./src/utils/graph/sort.ts","./src/utils/graph/types.ts","./src/utils/schema/exportschemasource.ts","./src/utils/schema/ischemasource.ts","./src/utils/schema/projectschemasource.ts"],"version":"5.7.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/exportstartpanel.tsx","./src/components/field.tsx","./src/components/fieldedgerenderer.tsx","./src/components/graphcanvas.tsx","./src/components/itemtypenoderenderer.tsx","./src/components/pluginnoderenderer.tsx","./src/components/progressoverlay.tsx","./src/components/progressstallnotice.tsx","./src/components/taskoverlaystack.tsx","./src/components/taskprogressoverlay.tsx","./src/components/bezier.ts","./src/entrypoints/config/index.tsx","./src/entrypoints/exporthome/index.tsx","./src/entrypoints/exportpage/dependencyactionspanel.tsx","./src/entrypoints/exportpage/entitiestoexportcontext.ts","./src/entrypoints/exportpage/exportitemtypenoderenderer.tsx","./src/entrypoints/exportpage/exportpluginnoderenderer.tsx","./src/entrypoints/exportpage/exportschema.ts","./src/entrypoints/exportpage/exporttoolbar.tsx","./src/entrypoints/exportpage/inner.tsx","./src/entrypoints/exportpage/largeselectionview.tsx","./src/entrypoints/exportpage/buildexportdoc.ts","./src/entrypoints/exportpage/buildgraphfromschema.ts","./src/entrypoints/exportpage/index.tsx","./src/entrypoints/exportpage/useanimatednodes.tsx","./src/entrypoints/exportpage/useexportgraph.ts","./src/entrypoints/importpage/filedropzone.tsx","./src/entrypoints/importpage/importitemtypenoderenderer.tsx","./src/entrypoints/importpage/importpluginnoderenderer.tsx","./src/entrypoints/importpage/inner.tsx","./src/entrypoints/importpage/largeselectionview.tsx","./src/entrypoints/importpage/resolutionsform.tsx","./src/entrypoints/importpage/selectedentitycontext.tsx","./src/entrypoints/importpage/buildgraphfromexportdoc.ts","./src/entrypoints/importpage/buildimportdoc.ts","./src/entrypoints/importpage/importschema.ts","./src/entrypoints/importpage/index.tsx","./src/entrypoints/importpage/conflictsmanager/collapsible.tsx","./src/entrypoints/importpage/conflictsmanager/conflictscontext.ts","./src/entrypoints/importpage/conflictsmanager/itemtypeconflict.tsx","./src/entrypoints/importpage/conflictsmanager/pluginconflict.tsx","./src/entrypoints/importpage/conflictsmanager/buildconflicts.ts","./src/entrypoints/importpage/conflictsmanager/index.tsx","./src/shared/constants/graph.ts","./src/shared/hooks/usecmaclient.ts","./src/shared/hooks/useconflictsbuilder.ts","./src/shared/hooks/useexportallhandler.ts","./src/shared/hooks/useexportselection.ts","./src/shared/hooks/useprojectschema.ts","./src/shared/hooks/useschemaexporttask.ts","./src/shared/tasks/uselongtask.ts","./src/types/lodash-es.d.ts","./src/utils/projectschema.ts","./src/utils/createcmaclient.ts","./src/utils/debug.ts","./src/utils/downloadjson.ts","./src/utils/emojiagnosticsorter.ts","./src/utils/icons.tsx","./src/utils/isdefined.ts","./src/utils/render.tsx","./src/utils/types.ts","./src/utils/datocms/appearance.ts","./src/utils/datocms/fieldtypeinfo.ts","./src/utils/datocms/schema.ts","./src/utils/datocms/validators.ts","./src/utils/graph/analysis.ts","./src/utils/graph/buildgraph.ts","./src/utils/graph/buildhierarchynodes.ts","./src/utils/graph/dependencies.ts","./src/utils/graph/edges.ts","./src/utils/graph/index.ts","./src/utils/graph/nodes.ts","./src/utils/graph/rebuildgraphwithpositionsfromhierarchy.ts","./src/utils/graph/sort.ts","./src/utils/graph/types.ts","./src/utils/schema/exportschemasource.ts","./src/utils/schema/ischemasource.ts","./src/utils/schema/projectschemasource.ts"],"version":"5.7.3"} \ No newline at end of file From 5385ecb4f16f608295017d12c69a5e9047ad28be Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Thu, 18 Sep 2025 12:21:11 +0200 Subject: [PATCH 08/36] fix --- import-export-schema/README.md | 2 +- import-export-schema/docs/refactor-baseline.md | 2 +- import-export-schema/src/components/ExportStartPanel.tsx | 2 +- import-export-schema/src/shared/hooks/useExportAllHandler.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/import-export-schema/README.md b/import-export-schema/README.md index e98c8a72..0b6dff40 100644 --- a/import-export-schema/README.md +++ b/import-export-schema/README.md @@ -37,7 +37,7 @@ Powerful, safe schema migration for DatoCMS. Export models/blocks and plugins as - For large projects the graph is replaced with a fast list view. - Export the entire schema (one click) - - From Schema > Export, choose “Export entire current schema” to include all models/blocks and plugins. + - From Schema > Export, choose “Export entire schema” to include all models/blocks and plugins. - A progress overlay appears with a cancel button and a stall notice if rate limited; the JSON is downloaded when done. - After export diff --git a/import-export-schema/docs/refactor-baseline.md b/import-export-schema/docs/refactor-baseline.md index 3963ba16..eebe3248 100644 --- a/import-export-schema/docs/refactor-baseline.md +++ b/import-export-schema/docs/refactor-baseline.md @@ -16,7 +16,7 @@ - Trigger cancel during export; verify notice and overlay update. ### Export: Entire Schema -- On Export landing, choose `Export entire current schema`. +- On Export landing, choose `Export entire schema`. - Confirm confirmation dialog text. - Ensure overlay tracks progress and cancel immediately hides overlay while still cancelling. - Validate success toast when done, or graceful alert if schema empty. diff --git a/import-export-schema/src/components/ExportStartPanel.tsx b/import-export-schema/src/components/ExportStartPanel.tsx index 0ae3d06f..423c8d33 100644 --- a/import-export-schema/src/components/ExportStartPanel.tsx +++ b/import-export-schema/src/components/ExportStartPanel.tsx @@ -47,7 +47,7 @@ export function ExportStartPanel({ footerHint, selectLabel = 'Starting models/blocks', startLabel = 'Start export', - exportAllLabel = 'Export entire current schema', + exportAllLabel = 'Export entire schema', }: Props) { const options = useMemo( () => diff --git a/import-export-schema/src/shared/hooks/useExportAllHandler.ts b/import-export-schema/src/shared/hooks/useExportAllHandler.ts index df98e754..8afa567a 100644 --- a/import-export-schema/src/shared/hooks/useExportAllHandler.ts +++ b/import-export-schema/src/shared/hooks/useExportAllHandler.ts @@ -18,7 +18,7 @@ export function useExportAllHandler({ ctx, schema, task }: Options) { return useCallback(async () => { try { const confirmation = await ctx.openConfirm({ - title: 'Export entire current schema?', + title: 'Export entire schema?', content: 'This will export all models, block models, and plugins in the current environment as a single JSON file.', choices: [ From 6697e8e1e389c1ae5ecb500a3c362ffed63529fd Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Thu, 18 Sep 2025 16:55:01 +0200 Subject: [PATCH 09/36] refactor 2 - eletric boogaloo --- .../docs/refactor-baseline.md | 2 +- .../src/components/BlankSlate.tsx | 24 + .../src/components/ExportStartPanel.tsx | 19 +- .../LargeSelectionLayout.module.css | 60 ++ .../src/components/LargeSelectionLayout.tsx | 139 +++ .../src/entrypoints/ExportHome/index.tsx | 4 - .../ExportPage/LargeSelectionView.module.css | 129 +++ .../ExportPage/LargeSelectionView.tsx | 537 ++++------ .../ExportPage/buildGraphFromSchema.ts | 5 +- .../entrypoints/ImportPage/ExportWorkflow.tsx | 76 ++ .../entrypoints/ImportPage/ImportWorkflow.tsx | 77 ++ .../ImportPage/LargeSelectionView.module.css | 98 ++ .../ImportPage/LargeSelectionView.tsx | 365 +++---- .../entrypoints/ImportPage/importSchema.ts | 947 ++++++++++-------- .../src/entrypoints/ImportPage/index.tsx | 721 +++++++------ .../entrypoints/ImportPage/useRecipeLoader.ts | 74 ++ import-export-schema/src/index.css | 2 +- .../src/shared/hooks/useExportSelection.ts | 30 +- .../src/utils/schema/ProjectSchemaSource.ts | 13 +- import-export-schema/tsconfig.app.tsbuildinfo | 2 +- 20 files changed, 1913 insertions(+), 1411 deletions(-) create mode 100644 import-export-schema/src/components/BlankSlate.tsx create mode 100644 import-export-schema/src/components/LargeSelectionLayout.module.css create mode 100644 import-export-schema/src/components/LargeSelectionLayout.tsx create mode 100644 import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.module.css create mode 100644 import-export-schema/src/entrypoints/ImportPage/ExportWorkflow.tsx create mode 100644 import-export-schema/src/entrypoints/ImportPage/ImportWorkflow.tsx create mode 100644 import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.module.css create mode 100644 import-export-schema/src/entrypoints/ImportPage/useRecipeLoader.ts diff --git a/import-export-schema/docs/refactor-baseline.md b/import-export-schema/docs/refactor-baseline.md index eebe3248..9f98b4d7 100644 --- a/import-export-schema/docs/refactor-baseline.md +++ b/import-export-schema/docs/refactor-baseline.md @@ -5,7 +5,7 @@ ### Export: Start From Selection - Launch Export page with no preselected item type. - Select one or more models/blocks via multiselect. -- Press `Start export` and wait for graph to render. +- Press `Export Selected` and wait for graph to render. - Toggle dependency selection; confirm auto-selection adds linked models/plugins. - Export selection; expect download named `export.json` and success toast. diff --git a/import-export-schema/src/components/BlankSlate.tsx b/import-export-schema/src/components/BlankSlate.tsx new file mode 100644 index 00000000..c0e15e7e --- /dev/null +++ b/import-export-schema/src/components/BlankSlate.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react'; + +type Props = { + title: ReactNode; + body: ReactNode; + footer?: ReactNode; +}; + +/** + * Lightweight wrapper around the shared blank-slate markup so pages can focus on content. + */ +export function BlankSlate({ title, body, footer }: Props) { + return ( +
    +
    +
    {title}
    +
    {body}
    +
    + {footer ? ( +
    {footer}
    + ) : null} +
    + ); +} diff --git a/import-export-schema/src/components/ExportStartPanel.tsx b/import-export-schema/src/components/ExportStartPanel.tsx index 423c8d33..142c58de 100644 --- a/import-export-schema/src/components/ExportStartPanel.tsx +++ b/import-export-schema/src/components/ExportStartPanel.tsx @@ -13,15 +13,12 @@ type Props = { itemTypes?: SchemaTypes.ItemType[]; selectedIds: string[]; onSelectedIdsChange: (ids: string[]) => void; - onSelectAllModels: () => void; - onSelectAllBlocks: () => void; onStart: () => void; startDisabled: boolean; onExportAll: () => void | Promise; exportAllDisabled: boolean; title?: string; description?: string; - footerHint?: string; selectLabel?: string; startLabel?: string; exportAllLabel?: string; @@ -36,17 +33,14 @@ export function ExportStartPanel({ itemTypes, selectedIds, onSelectedIdsChange, - onSelectAllModels, - onSelectAllBlocks, onStart, startDisabled, onExportAll, exportAllDisabled, title = 'Start a new export', description = 'Select one or more models/blocks to start selecting what to export.', - footerHint, selectLabel = 'Starting models/blocks', - startLabel = 'Start export', + startLabel = 'Export Selected', exportAllLabel = 'Export entire schema', }: Props) { const options = useMemo( @@ -92,14 +86,6 @@ export function ExportStartPanel({ } />
    -
    - - -
    - {footerHint ? ( -
    {footerHint}
    - ) : null}
    ); } diff --git a/import-export-schema/src/components/LargeSelectionLayout.module.css b/import-export-schema/src/components/LargeSelectionLayout.module.css new file mode 100644 index 00000000..558ec9a1 --- /dev/null +++ b/import-export-schema/src/components/LargeSelectionLayout.module.css @@ -0,0 +1,60 @@ +.container { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px; + padding: 12px 16px; + border-bottom: 1px solid var(--border-color); +} + +.metrics { + font-weight: 600; +} + +.meta { + color: var(--light-body-color); + font-size: 13px; +} + +.notice { + width: 100%; + font-size: 12px; + color: var(--light-body-color); + border-bottom: 1px solid var(--border-color); + padding: 0 0 8px 0; +} + +.search { + margin-left: auto; + min-width: 260px; + max-width: 360px; + flex: 1 1 260px; +} + +.body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.columns { + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; +} + +.footer { + border-top: 1px solid var(--border-color); +} diff --git a/import-export-schema/src/components/LargeSelectionLayout.tsx b/import-export-schema/src/components/LargeSelectionLayout.tsx new file mode 100644 index 00000000..432b6048 --- /dev/null +++ b/import-export-schema/src/components/LargeSelectionLayout.tsx @@ -0,0 +1,139 @@ +import { useId, useMemo, useState } from 'react'; +import { TextField } from 'datocms-react-ui'; +import { + countCycles, + getConnectedComponents, + splitNodesByType, +} from '@/utils/graph/analysis'; +import type { ItemTypeNode } from '@/components/ItemTypeNodeRenderer'; +import type { PluginNode } from '@/components/PluginNodeRenderer'; +import type { Graph } from '@/utils/graph/types'; +import styles from './LargeSelectionLayout.module.css'; + +export type LargeSelectionLayoutRenderArgs = { + itemTypeNodes: ItemTypeNode[]; + pluginNodes: PluginNode[]; + filteredItemTypeNodes: ItemTypeNode[]; + filteredPluginNodes: PluginNode[]; + metrics: { + itemTypeCount: number; + pluginCount: number; + edgeCount: number; + components: number; + cycles: number; + }; +}; + +type Props = { + graph: Graph; + searchLabel: string; + searchPlaceholder: string; + headerNotice?: React.ReactNode; + renderContent: (args: LargeSelectionLayoutRenderArgs) => React.ReactNode; + renderFooter?: (args: LargeSelectionLayoutRenderArgs) => React.ReactNode; +}; + +/** + * Shared scaffold for the large-selection fallback used by both import and export flows. + * Handles metrics, search filtering, and layout so the variants only worry about row UI. + */ +export function LargeSelectionLayout({ + graph, + searchLabel, + searchPlaceholder, + headerNotice, + renderContent, + renderFooter, +}: Props) { + const searchInputId = useId(); + const [query, setQuery] = useState(''); + + const { itemTypeNodes, pluginNodes } = useMemo( + () => splitNodesByType(graph), + [graph], + ); + + const metrics = useMemo(() => { + const components = getConnectedComponents(graph).length; + const cycles = countCycles(graph); + return { + itemTypeCount: itemTypeNodes.length, + pluginCount: pluginNodes.length, + edgeCount: graph.edges.length, + components, + cycles, + }; + }, [graph, itemTypeNodes.length, pluginNodes.length]); + + const filteredItemTypeNodes = useMemo(() => { + if (!query) return itemTypeNodes; + const lower = query.toLowerCase(); + return itemTypeNodes.filter((node) => { + const { itemType } = node.data; + return ( + itemType.attributes.name.toLowerCase().includes(lower) || + itemType.attributes.api_key.toLowerCase().includes(lower) + ); + }); + }, [itemTypeNodes, query]); + + const filteredPluginNodes = useMemo(() => { + if (!query) return pluginNodes; + const lower = query.toLowerCase(); + return pluginNodes.filter((node) => + node.data.plugin.attributes.name.toLowerCase().includes(lower), + ); + }, [pluginNodes, query]); + + return ( +
    +
    +
    + {metrics.itemTypeCount} models • {metrics.pluginCount} plugins •{' '} + {metrics.edgeCount} relations +
    +
    + Components: {metrics.components} • Cycles: {metrics.cycles} +
    + {headerNotice ? ( +
    {headerNotice}
    + ) : null} +
    + setQuery(val)} + textInputProps={{ autoComplete: 'off' }} + /> +
    +
    +
    +
    + {renderContent({ + itemTypeNodes, + pluginNodes, + filteredItemTypeNodes, + filteredPluginNodes, + metrics, + })} +
    + {renderFooter + ? ( +
    + {renderFooter({ + itemTypeNodes, + pluginNodes, + filteredItemTypeNodes, + filteredPluginNodes, + metrics, + })} +
    + ) + : null} +
    +
    + ); +} diff --git a/import-export-schema/src/entrypoints/ExportHome/index.tsx b/import-export-schema/src/entrypoints/ExportHome/index.tsx index 23815dc7..116c8eff 100644 --- a/import-export-schema/src/entrypoints/ExportHome/index.tsx +++ b/import-export-schema/src/entrypoints/ExportHome/index.tsx @@ -30,8 +30,6 @@ export default function ExportHome({ ctx }: Props) { selectedIds: exportInitialItemTypeIds, selectedItemTypes: exportInitialItemTypes, setSelectedIds: setExportInitialItemTypeIds, - selectAllModels: handleSelectAllModels, - selectAllBlocks: handleSelectAllBlocks, } = useExportSelection({ schema: projectSchema }); const [exportStarted, setExportStarted] = useState(false); @@ -73,8 +71,6 @@ export default function ExportHome({ ctx }: Props) { itemTypes={allItemTypes} selectedIds={exportInitialItemTypeIds} onSelectedIdsChange={setExportInitialItemTypeIds} - onSelectAllModels={handleSelectAllModels} - onSelectAllBlocks={handleSelectAllBlocks} onStart={handleStartExport} startDisabled={exportInitialItemTypeIds.length === 0} onExportAll={runExportAll} diff --git a/import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.module.css b/import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.module.css new file mode 100644 index 00000000..0fa43940 --- /dev/null +++ b/import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.module.css @@ -0,0 +1,129 @@ +.column { + padding: 16px; + overflow: auto; + min-height: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.modelsColumn { + flex: 2; +} + +.pluginsColumn { + flex: 1; + border-left: 1px solid var(--border-color); +} + +.list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0; +} + +.listItem { + border-bottom: 1px solid var(--border-color); + padding: 10px 4px; +} + +.modelRow, +.pluginRow { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 12px; + align-items: center; +} + +.pluginRow { + grid-template-columns: auto 1fr; +} + +.checkbox { + width: 16px; + height: 16px; +} + +.modelInfo, +.pluginInfo { + overflow: hidden; +} + +.modelTitle { + font-weight: 600; + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; +} + +.apikey { + color: var(--light-body-color); +} + +.badge { + font-size: 11px; + color: #3b82f6; + background: rgba(59, 130, 246, 0.08); + border: 1px solid rgba(59, 130, 246, 0.25); + border-radius: 4px; + padding: 1px 6px; +} + +.relationships { + color: var(--light-body-color); + font-size: 12px; + margin-top: 4px; +} + +.actions { + display: flex; + align-items: center; + gap: 8px; +} + +.whyPanel { + margin: 8px 0 6px 28px; + background: #fff; + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 8px; +} + +.whyTitle { + font-weight: 600; + margin-bottom: 6px; +} + +.whyList { + margin: 0; + padding-left: 18px; +} + +.whyListItem { + margin-bottom: 6px; +} + +.footerRow { + padding: 12px; + display: flex; + align-items: center; + gap: 8px; +} + +.footerNotice { + color: var(--light-body-color); + font-size: 12px; +} + +.footerSpacer { + flex: 1; +} + +.sectionTitle { + font-weight: 700; + letter-spacing: -0.2px; +} diff --git a/import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.tsx b/import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.tsx index 128accca..ad5a4e8d 100644 --- a/import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.tsx +++ b/import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.tsx @@ -1,14 +1,10 @@ import type { SchemaTypes } from '@datocms/cma-client'; -import { Button, Spinner, TextField } from 'datocms-react-ui'; -import { useId, useMemo, useState } from 'react'; -import { - countCycles, - findInboundEdges, - findOutboundEdges, - getConnectedComponents, - splitNodesByType, -} from '@/utils/graph/analysis'; +import { Button, Spinner } from 'datocms-react-ui'; +import { useMemo, useState } from 'react'; +import { findInboundEdges, findOutboundEdges } from '@/utils/graph/analysis'; +import { LargeSelectionLayout } from '@/components/LargeSelectionLayout'; import type { Graph } from '@/utils/graph/types'; +import styles from './LargeSelectionView.module.css'; type Props = { initialItemTypes: SchemaTypes.ItemType[]; @@ -41,9 +37,6 @@ export default function LargeSelectionView({ areAllDependenciesSelected, selectingDependencies, }: Props) { - const searchInputId = useId(); - const [query, setQuery] = useState(''); - // Track which rows have the dependency explanation expanded. const [expandedWhy, setExpandedWhy] = useState>(new Set()); const initialItemTypeIdSet = useMemo( @@ -51,34 +44,11 @@ export default function LargeSelectionView({ [initialItemTypes], ); - const { itemTypeNodes, pluginNodes } = useMemo( - () => splitNodesByType(graph), - [graph], + const selectedSourceSet = useMemo( + () => new Set(selectedItemTypeIds.map((id) => `itemType--${id}`)), + [selectedItemTypeIds], ); - const components = useMemo(() => getConnectedComponents(graph), [graph]); - const cycles = useMemo(() => countCycles(graph), [graph]); - - const filteredItemTypeNodes = useMemo(() => { - if (!query) return itemTypeNodes; - const q = query.toLowerCase(); - return itemTypeNodes.filter((n) => { - const it = n.data.itemType; - return ( - it.attributes.name.toLowerCase().includes(q) || - it.attributes.api_key.toLowerCase().includes(q) - ); - }); - }, [itemTypeNodes, query]); - - const filteredPluginNodes = useMemo(() => { - if (!query) return pluginNodes; - const q = query.toLowerCase(); - return pluginNodes.filter((n) => - n.data.plugin.attributes.name.toLowerCase().includes(q), - ); - }, [pluginNodes, query]); - function toggleItemType(id: string) { setSelectedItemTypeIds( selectedItemTypeIds.includes(id) @@ -96,313 +66,218 @@ export default function LargeSelectionView({ } function toggleWhy(id: string) { - const next = new Set(expandedWhy); - if (next.has(id)) next.delete(id); - else next.add(id); - setExpandedWhy(next); + setExpandedWhy((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); } - // Track selected nodes by React Flow id to surface "why included" reasons quickly. - const selectedSourceSet = useMemo( - () => new Set(selectedItemTypeIds.map((id) => `itemType--${id}`)), - [selectedItemTypeIds], - ); - return ( -
    -
    -
    - {itemTypeNodes.length} models • {pluginNodes.length} plugins •{' '} - {graph.edges.length} relations -
    -
    - Components: {components.length} • Cycles: {cycles} -
    -
    - setQuery(val)} - textInputProps={{ autoComplete: 'off' }} - /> -
    -
    - -
    -
    - Models -
      - {filteredItemTypeNodes.map((n) => { - const it = n.data.itemType; - const locked = initialItemTypeIdSet.has(it.id); - const checked = selectedItemTypeIds.includes(it.id); - const inbound = findInboundEdges(graph, `itemType--${it.id}`); - const outbound = findOutboundEdges(graph, `itemType--${it.id}`); - const isExpanded = expandedWhy.has(it.id); - const reasons = findInboundEdges( - graph, - `itemType--${it.id}`, - selectedSourceSet, - ); + ( + <> +
      + Models +
        + {filteredItemTypeNodes.map((node) => { + const itemType = node.data.itemType; + const locked = initialItemTypeIdSet.has(itemType.id); + const checked = selectedItemTypeIds.includes(itemType.id); + const inbound = findInboundEdges( + graph, + `itemType--${itemType.id}`, + ); + const outbound = findOutboundEdges( + graph, + `itemType--${itemType.id}`, + ); + const isExpanded = expandedWhy.has(itemType.id); + const reasons = findInboundEdges( + graph, + `itemType--${itemType.id}`, + selectedSourceSet, + ); - return ( -
      • -
        - toggleItemType(it.id)} - style={{ width: 16, height: 16 }} - /> -
        -
        - {it.attributes.name}{' '} - - ({it.attributes.api_key}) - {' '} - - {it.attributes.modular_block ? 'Block' : 'Model'} - + return ( +
      • +
        + toggleItemType(itemType.id)} + className={styles.checkbox} + /> +
        +
        + {itemType.attributes.name}{' '} + + ({itemType.attributes.api_key}) + {' '} + + {itemType.attributes.modular_block + ? 'Block' + : 'Model'} + +
        +
        + + ← {inbound.length} inbound + {' '} + •{' '} + + → {outbound.length} outbound + +
        -
        - - ← {inbound.length} inbound - {' '} - •{' '} - - → {outbound.length} outbound - +
        + {reasons.length > 0 ? ( + + ) : null}
        -
        - {reasons.length > 0 && ( - - )} -
        -
        - {isExpanded && reasons.length > 0 && ( -
        -
        - Included because: + {isExpanded && reasons.length > 0 ? ( +
        +
        Included because:
        +
          + {reasons.map((edge) => { + const sourceNode = graph.nodes.find( + (nd) => nd.id === edge.source, + ); + if (!sourceNode) return null; + const sourceItemType = + sourceNode.type === 'itemType' + ? sourceNode.data.itemType + : undefined; + return ( +
        • + {sourceItemType ? ( + <> + Selected model{' '} + + {sourceItemType.attributes.name} + {' '} + references it via fields:{' '} + + + ) : ( + <> + Referenced in fields:{' '} + + + )} +
        • + ); + })} +
        -
          - {reasons.map((edge) => { - const sourceNode = graph.nodes.find( - (nd) => nd.id === edge.source, - ); - if (!sourceNode) return null; - const srcIt = - sourceNode.type === 'itemType' - ? sourceNode.data.itemType - : undefined; - return ( -
        • - {srcIt ? ( - <> - Selected model{' '} - {srcIt.attributes.name}{' '} - references it via fields:{' '} - - - ) : ( - <> - Referenced in fields:{' '} - - - )} -
        • - ); - })} -
        -
        - )} -
      • - ); - })} -
      -
    -
    - Plugins -
      - {filteredPluginNodes.map((n) => { - const pl = n.data.plugin; - const checked = selectedPluginIds.includes(pl.id); - const inbound = findInboundEdges(graph, `plugin--${pl.id}`); - return ( -
    • -
      - togglePlugin(pl.id)} - style={{ width: 16, height: 16 }} - /> -
      -
      - {pl.attributes.name} -
      -
      - ← {inbound.length} inbound from models + ) : null} +
    • + ); + })} +
    + +
    + Plugins +
      + {filteredPluginNodes.map((node) => { + const plugin = node.data.plugin; + const checked = selectedPluginIds.includes(plugin.id); + const inbound = findInboundEdges( + graph, + `plugin--${plugin.id}`, + ); + + return ( +
    • +
      + togglePlugin(plugin.id)} + className={styles.checkbox} + /> +
      +
      + {plugin.attributes.name} +
      +
      + ← {inbound.length} inbound from models +
      -
    - - ); - })} - -
    -
    - -
    -
    - Graph view hidden due to size. + + ); + })} + + + + )} + renderFooter={() => ( +
    +
    Graph view hidden due to size.
    + + {selectingDependencies ? : null} +
    +
    - - {selectingDependencies && } -
    - -
    -
    + )} + /> ); } function SectionTitle({ children }: { children: React.ReactNode }) { - return ( -
    - {children} -
    - ); + return
    {children}
    ; } function FieldsList({ fields }: { fields: SchemaTypes.Field[] }) { @@ -410,7 +285,7 @@ function FieldsList({ fields }: { fields: SchemaTypes.Field[] }) { return ( <> {fields - .map((f) => `${f.attributes.label} (${f.attributes.api_key})`) + .map((field) => `${field.attributes.label} (${field.attributes.api_key})`) .join(', ')} ); diff --git a/import-export-schema/src/entrypoints/ExportPage/buildGraphFromSchema.ts b/import-export-schema/src/entrypoints/ExportPage/buildGraphFromSchema.ts index d420eaf0..51fb3aff 100644 --- a/import-export-schema/src/entrypoints/ExportPage/buildGraphFromSchema.ts +++ b/import-export-schema/src/entrypoints/ExportPage/buildGraphFromSchema.ts @@ -23,8 +23,11 @@ export async function buildGraphFromSchema({ selectedItemTypeIds, schema, onProgress, + installedPluginIds, }: Options): Promise { - const source = new ProjectSchemaSource(schema); + const source = new ProjectSchemaSource(schema, { + installedPluginIds, + }); return buildGraph({ source, initialItemTypes, diff --git a/import-export-schema/src/entrypoints/ImportPage/ExportWorkflow.tsx b/import-export-schema/src/entrypoints/ImportPage/ExportWorkflow.tsx new file mode 100644 index 00000000..3344b4d0 --- /dev/null +++ b/import-export-schema/src/entrypoints/ImportPage/ExportWorkflow.tsx @@ -0,0 +1,76 @@ +import type { ComponentProps } from 'react'; +import type { SchemaTypes } from '@datocms/cma-client'; +import { ExportStartPanel } from '@/components/ExportStartPanel'; +import ExportInner from '../ExportPage/Inner'; +import type { ProjectSchema } from '@/utils/ProjectSchema'; + +export type ExportWorkflowPrepareProgress = Parameters< + NonNullable['onPrepareProgress']> +>[0]; + +type Props = { + projectSchema: ProjectSchema; + exportStarted: boolean; + exportInitialSelectId: string; + allItemTypes?: SchemaTypes.ItemType[]; + exportInitialItemTypeIds: string[]; + exportInitialItemTypes: SchemaTypes.ItemType[]; + setSelectedIds: (ids: string[]) => void; + onStart: () => void; + onExportAll: () => void; + exportAllDisabled: boolean; + onGraphPrepared: () => void; + onPrepareProgress: (update: ExportWorkflowPrepareProgress) => void; + onClose: () => void; + onExportSelection: (itemTypeIds: string[], pluginIds: string[]) => void; +}; + +/** + * Renders the export tab experience inside the unified Import page. + */ +export function ExportWorkflow({ + projectSchema, + exportStarted, + exportInitialSelectId, + allItemTypes, + exportInitialItemTypeIds, + exportInitialItemTypes, + setSelectedIds, + onStart, + onExportAll, + exportAllDisabled, + onGraphPrepared, + onPrepareProgress, + onClose, + onExportSelection, +}: Props) { + if (!exportStarted) { + return ( +
    + +
    + ); + } + + return ( +
    + +
    + ); +} diff --git a/import-export-schema/src/entrypoints/ImportPage/ImportWorkflow.tsx b/import-export-schema/src/entrypoints/ImportPage/ImportWorkflow.tsx new file mode 100644 index 00000000..c5c0c78c --- /dev/null +++ b/import-export-schema/src/entrypoints/ImportPage/ImportWorkflow.tsx @@ -0,0 +1,77 @@ +import type { RenderPageCtx } from 'datocms-plugin-sdk'; +import { Spinner } from 'datocms-react-ui'; +import { BlankSlate } from '@/components/BlankSlate'; +import type { ProjectSchema } from '@/utils/ProjectSchema'; +import type { ExportDoc } from '@/utils/types'; +import type { ExportSchema } from '../ExportPage/ExportSchema'; +import { Inner } from './Inner'; +import type { Conflicts } from './ConflictsManager/buildConflicts'; +import { ConflictsContext } from './ConflictsManager/ConflictsContext'; +import FileDropZone from './FileDropZone'; +import ResolutionsForm, { type Resolutions } from './ResolutionsForm'; + +type Props = { + ctx: RenderPageCtx; + projectSchema: ProjectSchema; + exportSchema: [string, ExportSchema] | undefined; + loadingRecipe: boolean; + conflicts: Conflicts | undefined; + onDrop: (filename: string, doc: ExportDoc) => Promise; + onImport: (resolutions: Resolutions) => Promise; +}; + +/** + * Encapsulates the import-side UX, from file drop through conflict resolution. + */ +export function ImportWorkflow({ + ctx, + projectSchema, + exportSchema, + loadingRecipe, + conflicts, + onDrop, + onImport, +}: Props) { + return ( + + {(button) => { + if (exportSchema) { + if (!conflicts) { + return ; + } + + return ( + + + + + + ); + } + + if (loadingRecipe) { + return ; + } + + return ( + +

    + Drag and drop your exported JSON file here, or click the + button to select one from your computer. +

    + {button} + + } + /> + ); + }} +
    + ); +} diff --git a/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.module.css b/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.module.css new file mode 100644 index 00000000..31396244 --- /dev/null +++ b/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.module.css @@ -0,0 +1,98 @@ +.columns { + display: flex; + flex: 1; + min-height: 0; +} + +.column { + padding: 16px; + overflow: auto; + min-height: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.modelsColumn { + flex: 2; +} + +.pluginsColumn { + flex: 1; + border-left: 1px solid var(--border-color); +} + +.list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0; +} + +.listItem { + border-bottom: 1px solid var(--border-color); + padding: 10px 4px; +} + +.rowButton, +.rowButtonActive { + width: 100%; + background: transparent; + border: 0; + text-align: left; + cursor: pointer; + padding: 8px; + border-radius: 6px; + display: block; +} + +.rowButtonActive { + background: rgba(51, 94, 234, 0.08); +} + +.rowLayout { + display: flex; + flex-direction: column; + gap: 8px; +} + +.rowTop { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.entityName { + font-weight: 600; + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; +} + +.apikey { + color: var(--light-body-color); +} + +.badge { + font-size: 11px; + color: #3b82f6; + background: rgba(59, 130, 246, 0.08); + border: 1px solid rgba(59, 130, 246, 0.25); + border-radius: 4px; + padding: 1px 6px; +} + +.relationships { + color: var(--light-body-color); + font-size: 12px; + margin-top: 4px; +} + +.sectionTitle { + font-weight: 700; + letter-spacing: -0.2px; +} diff --git a/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.tsx b/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.tsx index 8656dd00..80da741d 100644 --- a/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.tsx @@ -1,15 +1,11 @@ import type { SchemaTypes } from '@datocms/cma-client'; -import { Button, TextField } from 'datocms-react-ui'; -import { useContext, useId, useMemo, useState } from 'react'; -import { - countCycles, - findInboundEdges, - findOutboundEdges, - getConnectedComponents, - splitNodesByType, -} from '@/utils/graph/analysis'; +import { Button } from 'datocms-react-ui'; +import { useContext } from 'react'; +import { findInboundEdges, findOutboundEdges } from '@/utils/graph/analysis'; +import { LargeSelectionLayout } from '@/components/LargeSelectionLayout'; import type { Graph } from '@/utils/graph/types'; import { SelectedEntityContext } from './SelectedEntityContext'; +import styles from './LargeSelectionView.module.css'; type Props = { graph: Graph; @@ -21,165 +17,83 @@ type Props = { * export-side list but drives the detail sidebar for conflicts. */ export default function LargeSelectionView({ graph, onSelect }: Props) { - const searchInputId = useId(); - const [query, setQuery] = useState(''); - // Keep the row selection in sync with the conflict/resolution panels. const selected = useContext(SelectedEntityContext).entity; + const handleSelectItemType = (itemType: SchemaTypes.ItemType) => { + onSelect(itemType); + }; - const { itemTypeNodes, pluginNodes } = useMemo( - () => splitNodesByType(graph), - [graph], - ); - - const components = useMemo(() => getConnectedComponents(graph), [graph]); - const cycles = useMemo(() => countCycles(graph), [graph]); + const handleSelectPlugin = (plugin: SchemaTypes.Plugin) => { + onSelect(plugin); + }; - const filteredItemTypeNodes = useMemo(() => { - if (!query) return itemTypeNodes; - const q = query.toLowerCase(); - return itemTypeNodes.filter((n) => { - const it = n.data.itemType; - return ( - it.attributes.name.toLowerCase().includes(q) || - it.attributes.api_key.toLowerCase().includes(q) - ); - }); - }, [itemTypeNodes, query]); + const isItemTypeSelected = (id: string) => + selected?.type === 'item_type' && selected.id === id; - const filteredPluginNodes = useMemo(() => { - if (!query) return pluginNodes; - const q = query.toLowerCase(); - return pluginNodes.filter((n) => - n.data.plugin.attributes.name.toLowerCase().includes(q), - ); - }, [pluginNodes, query]); + const isPluginSelected = (id: string) => + selected?.type === 'plugin' && selected.id === id; return ( -
    -
    -
    - {itemTypeNodes.length} models • {pluginNodes.length} plugins •{' '} - {graph.edges.length} relations -
    -
    - Components: {components.length} • Cycles: {cycles} -
    -
    - Graph view is hidden due to size. -
    -
    - setQuery(val)} - textInputProps={{ autoComplete: 'off' }} - /> -
    -
    - -
    -
    - Models -
      - {filteredItemTypeNodes.map((n) => { - const it = n.data.itemType; - const inbound = findInboundEdges(graph, `itemType--${it.id}`); - const outbound = findOutboundEdges(graph, `itemType--${it.id}`); - const isSelected = - selected?.type === 'item_type' && selected.id === it.id; + ( +
      +
      + Models +
        + {filteredItemTypeNodes.map((node) => { + const itemType = node.data.itemType; + const inbound = findInboundEdges( + graph, + `itemType--${itemType.id}`, + ); + const outbound = findOutboundEdges( + graph, + `itemType--${itemType.id}`, + ); + const selectedRow = isItemTypeSelected(itemType.id); - return ( -
      • -
        onSelect(it)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onSelect(it); + return ( +
      • +
      -
      +
      ← {inbound.length} inbound {' '} @@ -189,108 +103,71 @@ export default function LargeSelectionView({ graph, onSelect }: Props) {
      -
      - -
      -
    -
    - - ); - })} - - - Plugins -
      - {filteredPluginNodes.map((n) => { - const pl = n.data.plugin; - const inbound = findInboundEdges(graph, `plugin--${pl.id}`); - const isSelected = - selected?.type === 'plugin' && selected.id === pl.id; + + + ); + })} +
    + +
    + Plugins +
      + {filteredPluginNodes.map((node) => { + const plugin = node.data.plugin; + const inbound = findInboundEdges( + graph, + `plugin--${plugin.id}`, + ); + const selectedRow = isPluginSelected(plugin.id); - return ( -
    • -
      onSelect(pl)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onSelect(pl); + return ( +
    • +
    -
    +
    ← {inbound.length} inbound from models
    -
    - -
    -
    -
    - - ); - })} - + + + ); + })} + +
    -
    -
    + )} + /> ); } function SectionTitle({ children }: { children: React.ReactNode }) { return ( -
    - {children} -
    +
    {children}
    ); } diff --git a/import-export-schema/src/entrypoints/ImportPage/importSchema.ts b/import-export-schema/src/entrypoints/ImportPage/importSchema.ts index af8e4d09..2b6185e8 100644 --- a/import-export-schema/src/entrypoints/ImportPage/importSchema.ts +++ b/import-export-schema/src/entrypoints/ImportPage/importSchema.ts @@ -30,205 +30,228 @@ export type ImportResult = { pluginIdByExportId: Record; }; -/** - * Applies an import document to the target project while reporting granular progress. - */ -export default async function importSchema( - importDoc: ImportDoc, - client: Client, - updateProgress: (progress: ImportProgress) => void, - opts?: { shouldCancel?: () => boolean }, -): Promise { - // const [client, unsubscribe] = await withEventsSubscription(rawClient); +type ProgressUpdate = (progress: ImportProgress) => void; - // Precompute a fixed total so goal never grows - const pluginCreates = importDoc.plugins.entitiesToCreate.length; - const itemTypeCreates = importDoc.itemTypes.entitiesToCreate.length; - const fieldsetCreates = importDoc.itemTypes.entitiesToCreate.reduce( - (acc, it) => acc + it.fieldsets.length, - 0, - ); - const fieldCreates = importDoc.itemTypes.entitiesToCreate.reduce( - (acc, it) => acc + it.fields.length, - 0, - ); - const finalizeUpdates = itemTypeCreates; // one finalize step per created item type - const reorderBatches = itemTypeCreates; // one reorder batch per created item type - - const total = - pluginCreates + - itemTypeCreates + - fieldsetCreates + - fieldCreates + - finalizeUpdates + - reorderBatches; +type ShouldCancel = () => boolean; - let finished = 0; - updateProgress({ total, finished }); +/** + * Reports task progress while guarding against cancellation between steps. + */ +class ProgressTracker { + private finished = 0; - const shouldCancel = opts?.shouldCancel ?? (() => false); + constructor( + private readonly total: number, + private readonly update: ProgressUpdate, + private readonly shouldCancel: ShouldCancel, + ) {} - function checkCancel() { - if (shouldCancel()) { + checkCancel() { + if (this.shouldCancel()) { throw new Error('Import cancelled'); } } - // debug helper is module-scoped to be available in helpers below - - // Wrap API calls so each step updates the overlay and respects cancellation. - function trackWithLabel( - labelForArgs: (...args: TArgs) => string, - promiseGeneratorFn: (...args: TArgs) => Promise, - ): (...args: TArgs) => Promise { - return async (...args: TArgs) => { - let label: string | undefined; - try { - checkCancel(); - label = labelForArgs(...args); - updateProgress({ total, finished, label }); - const result = await promiseGeneratorFn(...args); - checkCancel(); - return result; - } finally { - finished += 1; - // Keep last known label for continuity - updateProgress({ total, finished, label }); - } - }; + private report(label?: string) { + this.update({ total: this.total, finished: this.finished, label }); } - /** - * Concurrency-limited mapper that preserves order and stops scheduling new work after - * cancellation while letting in-flight jobs finish. - */ - async function pMap( - items: readonly T[], - limit: number, - mapper: (item: T, index: number) => Promise, - ): Promise { - const results: R[] = new Array(items.length); - let nextIndex = 0; - let cancelledError: unknown | null = null; - - async function worker() { - while (true) { - if (cancelledError) return; - const current = nextIndex; - if (current >= items.length) return; - // Reserve index slot - nextIndex += 1; - try { - checkCancel(); - const res = await mapper(items[current], current); - results[current] = res; - } catch (e) { - // Stop scheduling more work; remember error to throw later - cancelledError = e; - return; - } - } + async run( + labelForCall: (...args: TArgs) => string, + fn: (...args: TArgs) => Promise, + ...args: TArgs + ): Promise { + let label: string | undefined; + try { + this.checkCancel(); + label = labelForCall(...args); + this.report(label); + const result = await fn(...args); + this.checkCancel(); + return result; + } finally { + this.finished += 1; + this.report(label); } - - const workers = Array.from( - { length: Math.max(1, Math.min(limit, items.length)) }, - worker, - ); - await Promise.all(workers); - if (cancelledError) throw cancelledError; - return results; } +} - checkCancel(); - const { locales } = await client.site.find(); - checkCancel(); +type ImportMappings = { + itemTypeIds: Map; + fieldIds: Map; + fieldsetIds: Map; + pluginIds: Map; +}; + +type ImportContext = { + client: Client; + tracker: ProgressTracker; + locales: string[]; + importDoc: ImportDoc; + mappings: ImportMappings; +}; - const itemTypeIdMappings: Map = new Map(); - const fieldIdMappings: Map = new Map(); - const fieldsetIdMappings: Map = new Map(); - const pluginIdMappings: Map = new Map(); +/** + * Pre-generate project-side IDs for every entity that will be created during import. + */ +function prepareMappings(importDoc: ImportDoc): ImportMappings { + const itemTypeIds = new Map(); + const fieldIds = new Map(); + const fieldsetIds = new Map(); + const pluginIds = new Map(); - // Pre-assign project IDs so relationships can reference them during creation. for (const toCreate of importDoc.itemTypes.entitiesToCreate) { - itemTypeIdMappings.set(toCreate.entity.id, generateId()); + itemTypeIds.set(toCreate.entity.id, generateId()); for (const field of toCreate.fields) { - fieldIdMappings.set(field.id, generateId()); + fieldIds.set(field.id, generateId()); } for (const fieldset of toCreate.fieldsets) { - fieldsetIdMappings.set(fieldset.id, generateId()); + fieldsetIds.set(fieldset.id, generateId()); } } for (const [exportId, projectId] of Object.entries( importDoc.itemTypes.idsToReuse, )) { - itemTypeIdMappings.set(exportId, projectId); + itemTypeIds.set(exportId, projectId); } - for (const toCreate of importDoc.plugins.entitiesToCreate) { - pluginIdMappings.set(toCreate.id, generateId()); + for (const plugin of importDoc.plugins.entitiesToCreate) { + pluginIds.set(plugin.id, generateId()); } for (const [exportId, projectId] of Object.entries( importDoc.plugins.idsToReuse, )) { - pluginIdMappings.set(exportId, projectId); + pluginIds.set(exportId, projectId); } - // Create new plugins (parallel with limited concurrency) - checkCancel(); - await pMap(importDoc.plugins.entitiesToCreate, 4, async (plugin) => - trackWithLabel( - (p: SchemaTypes.Plugin) => - `Creating plugin: ${ - p.attributes.name || p.attributes.package_name || p.id - }`, - async (p: SchemaTypes.Plugin) => { - const data: SchemaTypes.PluginCreateSchema['data'] = { - type: 'plugin', - id: pluginIdMappings.get(p.id), - attributes: p.attributes.package_name - ? pick(p.attributes, ['package_name']) - : p.meta.version === '2' - ? omit(p.attributes, ['parameters']) - : omit(p.attributes, [ - 'parameter_definitions', - 'field_types', - 'plugin_type', - 'parameters', - ]), - }; - - try { - debugLog('Creating plugin', data); - const { data: created } = await client.plugins.rawCreate({ data }); - - if (!isEqual(created.attributes.parameters, {})) { - try { - await client.plugins.update(created.id, { - parameters: created.attributes.parameters, - }); - } catch (_e) { - // ignore invalid legacy parameters + return { itemTypeIds, fieldIds, fieldsetIds, pluginIds }; +} + +/** + * Concurrency-limited map that respects cancellation signals between iterations. + */ +async function pMap( + items: readonly T[], + limit: number, + iteratee: (item: T, index: number) => Promise, + checkCancel: () => void, +): Promise { + const results: R[] = new Array(items.length); + let nextIndex = 0; + let error: unknown = null; + + async function worker() { + while (true) { + if (error) return; + const current = nextIndex; + if (current >= items.length) return; + nextIndex += 1; + try { + checkCancel(); + const result = await iteratee(items[current], current); + results[current] = result; + } catch (err) { + error = err; + return; + } + } + } + + const workers = Array.from( + { length: Math.max(1, Math.min(limit, items.length)) }, + () => worker(), + ); + await Promise.all(workers); + if (error) throw error; + return results; +} + +/** + * Install any plugins bundled with the export before creating linked entities. + */ +async function createPluginsPhase(context: ImportContext) { + const { + client, + tracker, + importDoc: { + plugins: { entitiesToCreate: pluginsToCreate }, + }, + mappings: { pluginIds }, + } = context; + + await pMap( + pluginsToCreate, + 4, + (plugin) => + tracker.run( + (p: SchemaTypes.Plugin) => + `Creating plugin: ${ + p.attributes.name || p.attributes.package_name || p.id + }`, + async (p: SchemaTypes.Plugin) => { + const data: SchemaTypes.PluginCreateSchema['data'] = { + type: 'plugin', + id: pluginIds.get(p.id), + attributes: p.attributes.package_name + ? pick(p.attributes, ['package_name']) + : p.meta.version === '2' + ? omit(p.attributes, ['parameters']) + : omit(p.attributes, [ + 'parameter_definitions', + 'field_types', + 'plugin_type', + 'parameters', + ]), + }; + + try { + debugLog('Creating plugin', data); + const { data: created } = await client.plugins.rawCreate({ data }); + + if (!isEqual(created.attributes.parameters, {})) { + try { + await client.plugins.update(created.id, { + parameters: created.attributes.parameters, + }); + } catch { + // ignore invalid legacy parameters + } } + debugLog('Created plugin', created); + } catch (error) { + console.error('Failed to create plugin', data, error); } - debugLog('Created plugin', created); - } catch (e) { - console.error('Failed to create plugin', data, e); - } - }, - )(plugin), + }, + plugin, + ), + () => tracker.checkCancel(), ); +} + +/** + * Create item types (models and blocks) and return the freshly created records. + */ +async function createItemTypesPhase( + context: ImportContext, +): Promise> { + const { + client, + tracker, + importDoc: { + itemTypes: { entitiesToCreate: itemTypesToCreate }, + }, + mappings: { itemTypeIds }, + } = context; - // Create new item types (parallel with limited concurrency) - checkCancel(); - const createdItemTypes: Array = await pMap( - importDoc.itemTypes.entitiesToCreate, + return pMap( + itemTypesToCreate, 3, - async (toCreate) => - trackWithLabel( + (toCreate) => + tracker.run( (t: ImportDoc['itemTypes']['entitiesToCreate'][number]) => `Creating ${t.entity.attributes.modular_block ? 'block' : 'model'}: ${ t.rename?.name || t.entity.attributes.name @@ -236,7 +259,7 @@ export default async function importSchema( async (t: ImportDoc['itemTypes']['entitiesToCreate'][number]) => { const data: SchemaTypes.ItemTypeCreateSchema['data'] = { type: 'item_type', - id: itemTypeIdMappings.get(t.entity.id), + id: itemTypeIds.get(t.entity.id), attributes: omit(t.entity.attributes, [ 'has_singleton_item', 'ordering_direction', @@ -244,107 +267,146 @@ export default async function importSchema( ]), }; + if (t.rename) { + data.attributes.name = t.rename.name; + data.attributes.api_key = t.rename.apiKey; + } + try { - if (t.rename) { - data.attributes.name = t.rename.name; - data.attributes.api_key = t.rename.apiKey; - } debugLog('Creating item type', data); - const { data: itemType } = await client.itemTypes.rawCreate({ - data, - }); - debugLog('Created item type', itemType); - return itemType; - } catch (e) { - console.error('Failed to create item type', data, e); + const { data: created } = await client.itemTypes.rawCreate({ data }); + debugLog('Created item type', created); + return created; + } catch (error) { + console.error('Failed to create item type', data, error); + return undefined; } }, - )(toCreate), + toCreate, + ), + () => tracker.checkCancel(), ); +} + +/** + * Create fieldsets and fields for each item type, respecting dependencies and validators. + */ +async function createFieldsetsAndFieldsPhase( + context: ImportContext, +) { + const { + client, + tracker, + locales, + importDoc: { + itemTypes: { entitiesToCreate: itemTypesToCreate }, + }, + mappings, + } = context; - // Create fieldsets and fields (parallelized per stage, limited per item type) - checkCancel(); await pMap( - importDoc.itemTypes.entitiesToCreate, + itemTypesToCreate, 2, - async ({ - entity: { id: itemTypeId, attributes: itemTypeAttrs }, - fields, - fieldsets, - }) => { - // Fieldsets first (required by fields referencing them) - await pMap(fieldsets, 4, async (fieldset) => - trackWithLabel( - (_fs: SchemaTypes.Fieldset) => - `Creating fieldset in ${itemTypeAttrs.name}`, - async (fs: SchemaTypes.Fieldset) => { - const data: SchemaTypes.FieldsetCreateSchema['data'] = { - ...omit(fs, ['relationships']), - id: fieldsetIdMappings.get(fs.id), - }; - - try { - debugLog('Creating fieldset', data); - const itemTypeProjectId = getOrThrow( - itemTypeIdMappings, - itemTypeId, - 'fieldset create', - ); - const { data: created } = await client.fieldsets.rawCreate( - itemTypeProjectId, - { data }, - ); - debugLog('Created fieldset', created); - } catch (e) { - console.error('Failed to create fieldset', data, e); - } - }, - )(fieldset), + async ({ entity, fields, fieldsets }) => { + const itemTypeId = entity.id; + + await pMap( + fieldsets, + 4, + (fieldset) => + tracker.run( + (_fs: SchemaTypes.Fieldset) => + `Creating fieldset in ${entity.attributes.name}`, + async (fs: SchemaTypes.Fieldset) => { + const data: SchemaTypes.FieldsetCreateSchema['data'] = { + ...omit(fs, ['relationships']), + id: mappings.fieldsetIds.get(fs.id), + }; + + try { + debugLog('Creating fieldset', data); + const itemTypeProjectId = getOrThrow( + mappings.itemTypeIds, + itemTypeId, + 'fieldset create', + ); + const { data: created } = await client.fieldsets.rawCreate( + itemTypeProjectId, + { data }, + ); + debugLog('Created fieldset', created); + } catch (error) { + console.error('Failed to create fieldset', data, error); + } + }, + fieldset, + ), + () => tracker.checkCancel(), ); const nonSlugFields = fields.filter( (field) => field.attributes.field_type !== 'slug', ); - await pMap(nonSlugFields, 6, async (field) => - trackWithLabel( - (f: SchemaTypes.Field) => - `Creating field ${f.attributes.label || f.attributes.api_key} in ${itemTypeAttrs.name}`, - (f: SchemaTypes.Field) => - importField(f, { - client, - locales, - fieldIdMappings, - pluginIdMappings, - fieldsetIdMappings, - itemTypeIdMappings, - }), - )(field), + await pMap( + nonSlugFields, + 6, + (field) => + tracker.run( + (f: SchemaTypes.Field) => + `Creating field ${f.attributes.label || f.attributes.api_key} in ${entity.attributes.name}`, + (f: SchemaTypes.Field) => + importField(f, { + client, + locales, + mappings, + }), + field, + ), + () => tracker.checkCancel(), ); const slugFields = fields.filter( (field) => field.attributes.field_type === 'slug', ); - await pMap(slugFields, 4, async (field) => - trackWithLabel( - (f: SchemaTypes.Field) => - `Creating field ${f.attributes.label || f.attributes.api_key} in ${itemTypeAttrs.name}`, - (f: SchemaTypes.Field) => - importField(f, { - client, - locales, - fieldIdMappings, - pluginIdMappings, - fieldsetIdMappings, - itemTypeIdMappings, - }), - )(field), + await pMap( + slugFields, + 4, + (field) => + tracker.run( + (f: SchemaTypes.Field) => + `Creating field ${f.attributes.label || f.attributes.api_key} in ${entity.attributes.name}`, + (f: SchemaTypes.Field) => + importField(f, { + client, + locales, + mappings, + }), + field, + ), + () => tracker.checkCancel(), ); }, + () => tracker.checkCancel(), ); +} - // Finalize new item types +/** + * Apply relationship and ordering metadata that requires created field IDs. + */ +async function finalizeItemTypesPhase( + context: ImportContext, + createdItemTypes: Array, +) { + const { + client, + tracker, + importDoc: { + itemTypes: { entitiesToCreate: itemTypesToCreate }, + }, + mappings, + } = context; const relationshipsToUpdate = [ 'ordering_field', @@ -354,192 +416,261 @@ export default async function importSchema( 'presentation_title_field', 'presentation_image_field', ] as const; - const attributesToUpdate = ['ordering_direction', 'ordering_meta']; - checkCancel(); - await pMap(importDoc.itemTypes.entitiesToCreate, 3, async (toCreate) => - trackWithLabel( - (t: ImportDoc['itemTypes']['entitiesToCreate'][number]) => - `Finalizing ${t.entity.attributes.modular_block ? 'block' : 'model'}: ${t.rename?.name || t.entity.attributes.name}`, - async (t: ImportDoc['itemTypes']['entitiesToCreate'][number]) => { - const id = getOrThrow( - itemTypeIdMappings, - t.entity.id, - 'finalize item type', - ); - const createdItemType = find(createdItemTypes, { id }); - if (!createdItemType) { - throw new Error(`Item type not found after creation: ${id}`); - } - - const data: SchemaTypes.ItemTypeUpdateSchema['data'] = { - type: 'item_type', - id, - attributes: pick(t.entity.attributes, attributesToUpdate), - relationships: relationshipsToUpdate.reduce( - (acc, relationshipName) => { - const handle = get( - t.entity, - `relationships.${relationshipName}.data`, - ); + await pMap( + itemTypesToCreate, + 3, + (toCreate) => + tracker.run( + (t: ImportDoc['itemTypes']['entitiesToCreate'][number]) => + `Finalizing ${t.entity.attributes.modular_block ? 'block' : 'model'}: ${ + t.rename?.name || t.entity.attributes.name + }`, + async (t: ImportDoc['itemTypes']['entitiesToCreate'][number]) => { + const id = getOrThrow( + mappings.itemTypeIds, + t.entity.id, + 'finalize item type', + ); + const createdItemType = find(createdItemTypes, { id }); + if (!createdItemType) { + throw new Error(`Item type not found after creation: ${id}`); + } - return { - ...acc, - [relationshipName]: { - data: handle - ? { - type: 'field', - id: getOrThrow( - fieldIdMappings, - handle.id, - 'finalize relationships', - ), - } - : null, - }, - }; - }, - {} as NonNullable< - SchemaTypes.ItemTypeUpdateSchema['data']['relationships'] - >, - ), - }; - - try { - debugLog('Finalize diff snapshot', { - relationships: data.relationships, - currentAttributes: pick( - createdItemType.attributes, - attributesToUpdate, - ), - currentRelationships: pick( - createdItemType.relationships, - relationshipsToUpdate, + const data: SchemaTypes.ItemTypeUpdateSchema['data'] = { + type: 'item_type', + id, + attributes: pick(t.entity.attributes, attributesToUpdate), + relationships: relationshipsToUpdate.reduce( + (acc, relationshipName) => { + const handle = get( + t.entity, + `relationships.${relationshipName}.data`, + ); + + return { + ...acc, + [relationshipName]: { + data: handle + ? { + type: 'field', + id: getOrThrow( + mappings.fieldIds, + handle.id, + 'finalize relationships', + ), + } + : null, + }, + }; + }, + {} as NonNullable< + SchemaTypes.ItemTypeUpdateSchema['data']['relationships'] + >, ), - }); - if ( - !isEqual( - data.relationships, - pick(createdItemType.relationships, relationshipsToUpdate), - ) || - !isEqual( - data.attributes, - pick(createdItemType.attributes, attributesToUpdate), - ) - ) { - debugLog('Finalizing item type', data); - const { data: updatedItemType } = await client.itemTypes.rawUpdate( - id, - { data }, - ); - debugLog('Finalized item type', updatedItemType); + }; + + try { + debugLog('Finalize diff snapshot', { + relationships: data.relationships, + currentAttributes: pick( + createdItemType.attributes, + attributesToUpdate, + ), + currentRelationships: pick( + createdItemType.relationships, + relationshipsToUpdate, + ), + }); + if ( + !isEqual( + data.relationships, + pick(createdItemType.relationships, relationshipsToUpdate), + ) || + !isEqual( + data.attributes, + pick(createdItemType.attributes, attributesToUpdate), + ) + ) { + debugLog('Finalizing item type', data); + const { data: updatedItemType } = await client.itemTypes.rawUpdate( + id, + { data }, + ); + debugLog('Finalized item type', updatedItemType); + } + } catch (error) { + console.error('Failed to finalize item type', data, error); } - } catch (e) { - console.error('Failed to finalize item type', data, e); - } - }, - )(toCreate), + }, + toCreate, + ), + () => tracker.checkCancel(), ); +} - // Reorder fields and fieldsets - checkCancel(); - await pMap(importDoc.itemTypes.entitiesToCreate, 3, async (obj) => - trackWithLabel( - (o: ImportDoc['itemTypes']['entitiesToCreate'][number]) => { - const { entity: itemType } = o; - return `Reordering fields/fieldsets for ${itemType.attributes.name}`; - }, - async (o: ImportDoc['itemTypes']['entitiesToCreate'][number]) => { - const { entity: itemType, fields, fieldsets } = o; - const allEntities = [...fieldsets, ...fields]; - - if (allEntities.length <= 1) { - return; - } - - try { - debugLog('Reordering fields/fieldsets for item type', { - itemTypeId: getOrThrow( - itemTypeIdMappings, - itemType.id, - 'reorder start log', - ), - }); - for (const entity of sortBy(allEntities, [ - 'attributes', - 'position', - ])) { - checkCancel(); - if (entity.type === 'fieldset') { - await client.fieldsets.update( - getOrThrow(fieldsetIdMappings, entity.id, 'fieldset reorder'), - { - position: entity.attributes.position, - }, - ); - } else { - await client.fields.update( - getOrThrow(fieldIdMappings, entity.id, 'field reorder'), - { - position: entity.attributes.position, - }, - ); +/** + * Restore the original ordering for fieldsets and fields to match the export. + */ +async function reorderEntitiesPhase(context: ImportContext) { + const { + client, + tracker, + importDoc: { + itemTypes: { entitiesToCreate: itemTypesToCreate }, + }, + mappings, + } = context; + + await pMap( + itemTypesToCreate, + 3, + (obj) => + tracker.run( + (o: ImportDoc['itemTypes']['entitiesToCreate'][number]) => { + const { entity } = o; + return `Reordering fields/fieldsets for ${entity.attributes.name}`; + }, + async (o: ImportDoc['itemTypes']['entitiesToCreate'][number]) => { + const { entity: itemType, fields, fieldsets } = o; + const allEntities = [...fieldsets, ...fields]; + + if (allEntities.length <= 1) { + return; + } + + try { + debugLog('Reordering fields/fieldsets for item type', { + itemTypeId: getOrThrow( + mappings.itemTypeIds, + itemType.id, + 'reorder start log', + ), + }); + for (const entity of sortBy(allEntities, [ + 'attributes', + 'position', + ])) { + tracker.checkCancel(); + if (entity.type === 'fieldset') { + await client.fieldsets.update( + getOrThrow( + mappings.fieldsetIds, + entity.id, + 'fieldset reorder', + ), + { + position: entity.attributes.position, + }, + ); + } else { + await client.fields.update( + getOrThrow(mappings.fieldIds, entity.id, 'field reorder'), + { + position: entity.attributes.position, + }, + ); + } } + debugLog('Reordered fields/fieldsets for item type', { + itemTypeId: getOrThrow( + mappings.itemTypeIds, + itemType.id, + 'reorder log', + ), + }); + } catch (error) { + console.error('Failed to reorder fields/fieldsets', error); } - debugLog('Reordered fields/fieldsets for item type', { - itemTypeId: getOrThrow( - itemTypeIdMappings, - itemType.id, - 'reorder log', - ), - }); - } catch (e) { - console.error('Failed to reorder fields/fieldsets', e); - } - }, - )(obj), + }, + obj, + ), + () => tracker.checkCancel(), + ); +} + +export default async function importSchema( + importDoc: ImportDoc, + client: Client, + updateProgress: ProgressUpdate, + opts?: { shouldCancel?: ShouldCancel }, +): Promise { + const shouldCancel = opts?.shouldCancel ?? (() => false); + + const pluginCreates = importDoc.plugins.entitiesToCreate.length; + const itemTypeCreates = importDoc.itemTypes.entitiesToCreate.length; + const fieldsetCreates = importDoc.itemTypes.entitiesToCreate.reduce( + (acc, it) => acc + it.fieldsets.length, + 0, + ); + const fieldCreates = importDoc.itemTypes.entitiesToCreate.reduce( + (acc, it) => acc + it.fields.length, + 0, ); + const finalizeUpdates = itemTypeCreates; + const reorderBatches = itemTypeCreates; + + const total = + pluginCreates + + itemTypeCreates + + fieldsetCreates + + fieldCreates + + finalizeUpdates + + reorderBatches; + + const tracker = new ProgressTracker(total, updateProgress, shouldCancel); + + tracker.checkCancel(); + const { locales } = await client.site.find(); + tracker.checkCancel(); + + const mappings = prepareMappings(importDoc); + const context: ImportContext = { + client, + tracker, + locales, + importDoc, + mappings, + }; + + await createPluginsPhase(context); + const createdItemTypes = await createItemTypesPhase(context); + await createFieldsetsAndFieldsPhase(context); + await finalizeItemTypesPhase(context, createdItemTypes); + await reorderEntitiesPhase(context); - // unsubscribe(); return { - itemTypeIdByExportId: Object.fromEntries(itemTypeIdMappings), - fieldIdByExportId: Object.fromEntries(fieldIdMappings), - fieldsetIdByExportId: Object.fromEntries(fieldsetIdMappings), - pluginIdByExportId: Object.fromEntries(pluginIdMappings), + itemTypeIdByExportId: Object.fromEntries(mappings.itemTypeIds), + fieldIdByExportId: Object.fromEntries(mappings.fieldIds), + fieldsetIdByExportId: Object.fromEntries(mappings.fieldsetIds), + pluginIdByExportId: Object.fromEntries(mappings.pluginIds), }; } type ImportFieldOptions = { client: Client; locales: string[]; - fieldIdMappings: Map; - fieldsetIdMappings: Map; - itemTypeIdMappings: Map; - pluginIdMappings: Map; + mappings: ImportMappings; }; +/** + * Create a single field in the target project, translating validators and appearance. + */ async function importField( field: SchemaTypes.Field, - { - client, - locales, - fieldIdMappings, - fieldsetIdMappings, - itemTypeIdMappings, - pluginIdMappings, - }: ImportFieldOptions, + { client, locales, mappings }: ImportFieldOptions, ) { const data: SchemaTypes.FieldCreateSchema['data'] = { ...field, - id: fieldIdMappings.get(field.id), + id: mappings.fieldIds.get(field.id), relationships: { fieldset: { data: field.relationships.fieldset.data ? { type: 'fieldset', id: getOrThrow( - fieldsetIdMappings, + mappings.fieldsetIds, field.relationships.fieldset.data.id, 'field appearance fieldset mapping', ), @@ -551,12 +682,12 @@ async function importField( const validators = [ ...validatorsContainingLinks.filter( - (i) => i.field_type === field.attributes.field_type, + (item) => item.field_type === field.attributes.field_type, ), ...validatorsContainingBlocks.filter( - (i) => i.field_type === field.attributes.field_type, + (item) => item.field_type === field.attributes.field_type, ), - ].map((i) => i.validator); + ].map((item) => item.validator); for (const validator of validators) { const fieldLinkedItemTypeIds = get( @@ -566,9 +697,9 @@ async function importField( const newIds: string[] = []; - for (const fieldLinkedItemTypeId of fieldLinkedItemTypeIds) { - const maybe = itemTypeIdMappings.get(fieldLinkedItemTypeId); - if (maybe) newIds.push(maybe); + for (const fieldLinkedItemTypeId of fieldLinkedItemTypeIds ?? []) { + const mapped = mappings.itemTypeIds.get(fieldLinkedItemTypeId); + if (mapped) newIds.push(mapped); } const validatorsContainer = (data.attributes.validators ?? {}) as Record< @@ -585,7 +716,7 @@ async function importField( if (slugTitleFieldValidator) { const mapped = getOrThrow( - fieldIdMappings, + mappings.fieldIds, slugTitleFieldValidator.title_field_id, 'slug title field', ); @@ -594,14 +725,12 @@ async function importField( }; } - // Clear appearance to reconstruct a valid target-project configuration below - // (fixes typo 'appeareance' that prevented reset) - // Avoid delete operator; set to undefined to omit when serialized (data.attributes as { appearance?: unknown }).appearance = undefined; - // Also clear legacy misspelled property if present (data.attributes as { appeareance?: unknown }).appeareance = undefined; - // Build a safe appearance configuration regardless of source shape - const nextAppearance = await mapAppearanceToProject(field, pluginIdMappings); + const nextAppearance = await mapAppearanceToProject( + field, + mappings.pluginIds, + ); if (field.attributes.localized) { const oldDefaultValues = field.attributes.default_value as Record< @@ -609,18 +738,16 @@ async function importField( unknown >; data.attributes.default_value = Object.fromEntries( - locales.map((locale) => [locale, oldDefaultValues[locale] || null]), + locales.map((locale) => [locale, oldDefaultValues?.[locale] ?? null]), ); } - // mapAppearanceToProject already remaps editor/addons and ensures parameters - data.attributes.appearance = nextAppearance; try { debugLog('Creating field', data); const itemTypeProjectId = getOrThrow( - itemTypeIdMappings, + mappings.itemTypeIds, field.relationships.item_type.data.id, 'field create', ); @@ -631,7 +758,7 @@ async function importField( }, ); debugLog('Created field', createdField); - } catch (e) { - console.error('Failed to create field', data, e); + } catch (error) { + console.error('Failed to create field', data, error); } } diff --git a/import-export-schema/src/entrypoints/ImportPage/index.tsx b/import-export-schema/src/entrypoints/ImportPage/index.tsx index 03edb6b0..bb62e364 100644 --- a/import-export-schema/src/entrypoints/ImportPage/index.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/index.tsx @@ -1,27 +1,22 @@ -// Removed unused icons import { ReactFlowProvider } from '@xyflow/react'; import type { RenderPageCtx } from 'datocms-plugin-sdk'; -import { Canvas, Spinner } from 'datocms-react-ui'; -import { useEffect, useId, useState } from 'react'; -import { ExportStartPanel } from '@/components/ExportStartPanel'; +import { Canvas } from 'datocms-react-ui'; +import { useCallback, useEffect, useId, useMemo, useState } from 'react'; import { TaskOverlayStack } from '@/components/TaskOverlayStack'; import { useConflictsBuilder } from '@/shared/hooks/useConflictsBuilder'; import { useExportAllHandler } from '@/shared/hooks/useExportAllHandler'; import { useExportSelection } from '@/shared/hooks/useExportSelection'; import { useProjectSchema } from '@/shared/hooks/useProjectSchema'; import { useSchemaExportTask } from '@/shared/hooks/useSchemaExportTask'; -import { useLongTask } from '@/shared/tasks/useLongTask'; +import { useLongTask, type UseLongTaskResult } from '@/shared/tasks/useLongTask'; import type { ExportDoc } from '@/utils/types'; import { ExportSchema } from '../ExportPage/ExportSchema'; -import ExportInner from '../ExportPage/Inner'; -// PostExportSummary removed: exports now download directly with a toast +import { ExportWorkflow, type ExportWorkflowPrepareProgress } from './ExportWorkflow'; +import { ImportWorkflow } from './ImportWorkflow'; import { buildImportDoc } from './buildImportDoc'; -import { ConflictsContext } from './ConflictsManager/ConflictsContext'; -import FileDropZone from './FileDropZone'; -import { Inner } from './Inner'; +import type { Resolutions } from './ResolutionsForm'; +import { useRecipeLoader } from './useRecipeLoader'; import importSchema from './importSchema'; -// PostImportSummary removed: after import we just show a toast and reset -import ResolutionsForm, { type Resolutions } from './ResolutionsForm'; type Props = { ctx: RenderPageCtx; @@ -29,6 +24,51 @@ type Props = { hideModeToggle?: boolean; }; +type ModeToggleProps = { + mode: 'import' | 'export'; + onChange: (mode: 'import' | 'export') => void; +}; + +function ModeToggle({ mode, onChange }: ModeToggleProps) { + return ( +
    +
    +
    + + +
    +
    +
    + ); +} + /** * Unified Import/Export entrypoint rendered inside the Schema sidebar page. Handles * file drops, conflict resolution, and the alternate export tab. @@ -39,56 +79,11 @@ export function ImportPage({ hideModeToggle = false, }: Props) { const exportInitialSelectId = useId(); - const params = new URLSearchParams(ctx.location.search); - const recipeUrl = params.get('recipe_url'); - const recipeTitle = params.get('recipe_title'); - const [loadingRecipeByUrl, setLoadingRecipeByUrl] = useState(false); - - useEffect(() => { - // Optional shortcut: pre-load an export recipe from a shared URL. - async function run() { - if (!recipeUrl) { - return; - } - - try { - setLoadingRecipeByUrl(true); - const uri = new URL(recipeUrl); - - const response = await fetch(recipeUrl); - const body = await response.json(); - - const schema = new ExportSchema(body as ExportDoc); - const fallbackName = uri.pathname.split('/').pop() || 'Imported schema'; - setExportSchema([recipeTitle || fallbackName, schema]); - } finally { - setLoadingRecipeByUrl(false); - } - } - - run(); - }, [recipeUrl]); - + const [mode, setMode] = useState<'import' | 'export'>(initialMode); const [exportSchema, setExportSchema] = useState< [string, ExportSchema] | undefined >(); - - // Local tab to switch between importing a file and exporting from selection - // Toggle between the import dropzone and export selector screens. - const [mode, setMode] = useState<'import' | 'export'>(initialMode); - - // Removed postImportSummary: no post-import overview screen - - // Parse the dropped JSON and hydrate our `ExportSchema` helper. - async function handleDrop(filename: string, doc: ExportDoc) { - try { - const schema = new ExportSchema(doc); - setExportSchema([filename, schema]); - } catch (e) { - console.error(e); - ctx.alert(e instanceof Error ? e.message : 'Invalid export file!'); - } - } + const [exportStarted, setExportStarted] = useState(false); const projectSchema = useProjectSchema(ctx); const client = projectSchema.client; @@ -103,16 +98,11 @@ export function ImportPage({ }); const conflictsTask = useLongTask(); - // Removed adminDomain lookup; no post-import summary links needed - - const [exportStarted, setExportStarted] = useState(false); const { allItemTypes, selectedIds: exportInitialItemTypeIds, selectedItemTypes: exportInitialItemTypes, setSelectedIds: setExportInitialItemTypeIds, - selectAllModels: handleSelectAllModels, - selectAllBlocks: handleSelectAllBlocks, } = useExportSelection({ schema: projectSchema, enabled: mode === 'export' }); const { conflicts, setConflicts } = useConflictsBuilder({ @@ -121,23 +111,157 @@ export function ImportPage({ task: conflictsTask.controller, }); + const handleRecipeLoaded = useCallback( + ({ label, schema }: { label: string; schema: ExportSchema }) => { + setExportSchema([label, schema]); + setMode('import'); + }, + [], + ); + + const handleRecipeError = useCallback( + (error: unknown) => { + console.error('Failed to load shared export recipe', error); + ctx.alert('Could not load the shared export recipe.'); + }, + [ctx], + ); + + const { loading: loadingRecipeByUrl } = useRecipeLoader( + ctx, + handleRecipeLoaded, + { onError: handleRecipeError }, + ); + + const handleDrop = useCallback( + async (filename: string, doc: ExportDoc) => { + try { + const schema = new ExportSchema(doc); + setExportSchema([filename, schema]); + setMode('import'); + } catch (error) { + console.error(error); + ctx.alert(error instanceof Error ? error.message : 'Invalid export file!'); + } + }, + [ctx], + ); + const runExportAll = useExportAllHandler({ ctx, schema: projectSchema, task: exportAllTask.controller, }); - const handleStartExportSelection = () => { + const handleStartExportSelection = useCallback(() => { exportPreparingTask.controller.start({ label: 'Preparing export…', }); setExportStarted(true); - }; + }, [exportPreparingTask.controller]); + + const handleExportGraphPrepared = useCallback(() => { + exportPreparingTask.controller.complete({ + label: 'Graph prepared', + }); + }, [exportPreparingTask.controller]); + + const handleExportPrepareProgress = useCallback( + (progress: ExportWorkflowPrepareProgress) => { + if (exportPreparingTask.state.status !== 'running') { + exportPreparingTask.controller.start(progress); + } else { + exportPreparingTask.controller.setProgress(progress); + } + }, + [exportPreparingTask.controller, exportPreparingTask.state.status], + ); + + const handleExportClose = useCallback(() => { + setExportStarted(false); + exportPreparingTask.controller.reset(); + }, [exportPreparingTask.controller]); + + const handleExportSelection = useCallback( + (itemTypeIds: string[], pluginIds: string[]) => { + if (exportInitialItemTypeIds.length === 0) { + return; + } + runSelectionExport({ + rootItemTypeId: exportInitialItemTypeIds[0], + itemTypeIds, + pluginIds, + }); + }, + [exportInitialItemTypeIds, runSelectionExport], + ); + + const handleImport = useCallback( + async (resolutions: Resolutions) => { + if (!exportSchema || !conflicts) { + throw new Error('Invariant'); + } + + try { + importTask.controller.start({ + done: 0, + total: 1, + label: 'Preparing import…', + }); + + const importDoc = await buildImportDoc( + exportSchema[1], + conflicts, + resolutions, + ); + + await importSchema( + importDoc, + client, + (progress) => { + if (!importTask.controller.isCancelRequested()) { + importTask.controller.setProgress({ + done: progress.finished, + total: progress.total, + label: progress.label, + }); + } + }, + { + shouldCancel: () => importTask.controller.isCancelRequested(), + }, + ); + + if (importTask.controller.isCancelRequested()) { + throw new Error('Import cancelled'); + } + + importTask.controller.complete({ + done: importTask.state.progress.total, + total: importTask.state.progress.total, + label: 'Import completed', + }); + ctx.notice('Import completed successfully.'); + setExportSchema(undefined); + setConflicts(undefined); + } catch (error) { + console.error(error); + if (error instanceof Error && error.message === 'Import cancelled') { + importTask.controller.complete({ label: 'Import cancelled' }); + ctx.notice('Import canceled'); + } else { + importTask.controller.fail(error); + ctx.alert('Import could not be completed successfully.'); + } + } finally { + importTask.controller.reset(); + } + }, + [client, conflicts, ctx, exportSchema, importTask, setConflicts], + ); - // Listen for bottom Cancel action from ConflictsManager useEffect(() => { - // ConflictsManager dispatches a custom event when the user cancels from the sidebar CTA. - const onRequestCancel = async () => { + const handleCancelRequest = async () => { if (!exportSchema) return; const result = await ctx.openConfirm({ title: 'Cancel the import?', @@ -163,326 +287,185 @@ export function ImportPage({ window.addEventListener( 'import:request-cancel', - onRequestCancel as unknown as EventListener, + handleCancelRequest as unknown as EventListener, ); return () => { window.removeEventListener( 'import:request-cancel', - onRequestCancel as unknown as EventListener, + handleCancelRequest as unknown as EventListener, ); }; - }, [exportSchema, ctx]); - - async function handleImport(resolutions: Resolutions) { - if (!exportSchema || !conflicts) { - throw new Error('Invariant'); - } - - try { - importTask.controller.start({ - done: 0, - total: 1, - label: 'Preparing import…', - }); - - const importDoc = await buildImportDoc( - exportSchema[1], - conflicts, - resolutions, - ); + }, [ctx, exportSchema]); - // Execute the import while streaming progress updates into the overlay. - await importSchema( - importDoc, - client, - (p) => { - if (!importTask.controller.isCancelRequested()) { - importTask.controller.setProgress({ - done: p.finished, - total: p.total, - label: p.label, - }); - } - }, - { - shouldCancel: () => importTask.controller.isCancelRequested(), - }, - ); - - if (importTask.controller.isCancelRequested()) { - throw new Error('Import cancelled'); - } - - // Success: notify and reset to initial idle state - importTask.controller.complete({ - done: importTask.state.progress.total, - total: importTask.state.progress.total, - label: 'Import completed', - }); - ctx.notice('Import completed successfully.'); - setExportSchema(undefined); - setConflicts(undefined); - } catch (e) { - console.error(e); - if (e instanceof Error && e.message === 'Import cancelled') { - importTask.controller.complete({ label: 'Import cancelled' }); - ctx.notice('Import canceled'); - } else { - importTask.controller.fail(e); - ctx.alert('Import could not be completed successfully.'); - } - } finally { - importTask.controller.reset(); - } - } + const overlayItems = useMemo( + () => [ + buildImportOverlay(ctx, importTask, exportSchema), + buildExportAllOverlay(exportAllTask), + buildConflictsOverlay(conflictsTask), + buildExportPrepOverlay(exportPreparingTask), + buildExportSelectionOverlay(exportSelectionTask), + ], + [ + ctx, + exportSchema, + importTask, + exportAllTask, + conflictsTask, + exportPreparingTask, + exportSelectionTask, + ], + ); return (
    - {exportSchema - ? null - : !hideModeToggle && ( -
    -
    -
    - - -
    -
    -
    - )} + {exportSchema || hideModeToggle ? null : ( + + )}
    {mode === 'import' ? ( - - {(button) => - exportSchema ? ( - conflicts ? ( - - - - - - ) : ( - - ) - ) : loadingRecipeByUrl ? ( - - ) : ( -
    -
    -
    - Upload your schema export file -
    - -
    -

    - Drag and drop your exported JSON file here, or click - the button to select one from your computer. -

    - {button} -
    -
    -
    - {hideModeToggle - ? '💡 Need to bulk export your schema? Go to the Export page under Schema.' - : '💡 Need to bulk export your schema? Switch to the Export tab above.'} -
    -
    - ) - } -
    + ) : ( -
    - {!exportStarted ? ( - - ) : ( - { - exportPreparingTask.controller.complete({ - label: 'Graph prepared', - }); - }} - onPrepareProgress={(p) => { - // ensure overlay shows determinate progress - if (exportPreparingTask.state.status !== 'running') { - exportPreparingTask.controller.start(p); - } else { - exportPreparingTask.controller.setProgress(p); - } - }} - onClose={() => { - // Return to selection screen with current picks preserved - setExportStarted(false); - exportPreparingTask.controller.reset(); - }} - onExport={(itemTypeIds, pluginIds) => - runSelectionExport({ - rootItemTypeId: exportInitialItemTypeIds[0], - itemTypeIds, - pluginIds, - }) - } - /> - )} - {/* Fallback note removed per UX request */} -
    + )}
    - - state.cancelRequested - ? 'Cancelling import…' - : 'Sit tight, we’re applying models, fields, and plugins…', - ariaLabel: 'Import in progress', - progressLabel: (progress, state) => - state.cancelRequested - ? 'Stopping at next safe point…' - : (progress.label ?? ''), - cancel: () => ({ - label: 'Cancel import', - intent: importTask.state.cancelRequested ? 'muted' : 'negative', - disabled: importTask.state.cancelRequested, - onCancel: async () => { - if (!exportSchema) return; - const result = await ctx.openConfirm({ - title: 'Cancel import in progress?', - content: - 'Stopping now can leave partial changes in your project. Some models or blocks may be created without relationships, some fields or fieldsets may already exist, and plugin installations or editor settings may be incomplete. You can run the import again to finish or manually clean up. Are you sure you want to cancel?', - choices: [ - { - label: 'Yes, cancel the import', - value: 'yes', - intent: 'negative', - }, - ], - cancel: { - label: 'Nevermind', - value: false, - intent: 'positive', - }, - }); - - if (result === 'yes') { - importTask.controller.requestCancel(); - } - }, - }), - }, - { - id: 'export-all', - task: exportAllTask, - title: 'Exporting entire schema', - subtitle: 'Sit tight, we’re gathering models, blocks, and plugins…', - ariaLabel: 'Export in progress', - progressLabel: (progress) => - progress.label ?? 'Loading project schema…', - cancel: () => ({ - label: 'Cancel export', - intent: exportAllTask.state.cancelRequested - ? 'muted' - : 'negative', - disabled: exportAllTask.state.cancelRequested, - onCancel: () => exportAllTask.controller.requestCancel(), - }), - }, - { - id: 'conflicts', - task: conflictsTask, - title: 'Preparing import', - subtitle: - 'Sit tight, we’re scanning your export against the project…', - ariaLabel: 'Preparing import', - progressLabel: (progress) => progress.label ?? 'Preparing import…', - overlayZIndex: 9998, - }, - { - id: 'export-prep', - task: exportPreparingTask, - title: 'Preparing export', - subtitle: - 'Sit tight, we’re setting up your models, blocks, and plugins…', - ariaLabel: 'Preparing export', - progressLabel: (progress) => progress.label ?? 'Preparing export…', - }, - { - id: 'export-selection', - task: exportSelectionTask, - title: 'Exporting selection', - subtitle: 'Sit tight, we’re gathering models, blocks, and plugins…', - ariaLabel: 'Export in progress', - progressLabel: (progress) => progress.label ?? 'Preparing export…', - cancel: () => ({ - label: 'Cancel export', - intent: exportSelectionTask.state.cancelRequested - ? 'muted' - : 'negative', - disabled: exportSelectionTask.state.cancelRequested, - onCancel: () => exportSelectionTask.controller.requestCancel(), - }), - }, - ]} - /> + ); } + +type OverlayConfig = Parameters[0]['items'][number]; + +function buildImportOverlay( + ctx: RenderPageCtx, + importTask: UseLongTaskResult, + exportSchema: [string, ExportSchema] | undefined, +): OverlayConfig { + return { + id: 'import', + task: importTask, + title: 'Import in progress', + subtitle: (state) => + state.cancelRequested + ? 'Cancelling import…' + : 'Sit tight, we’re applying models, fields, and plugins…', + ariaLabel: 'Import in progress', + progressLabel: (progress, state) => + state.cancelRequested ? 'Stopping at next safe point…' : progress.label ?? '', + cancel: () => ({ + label: 'Cancel import', + intent: importTask.state.cancelRequested ? 'muted' : 'negative', + disabled: importTask.state.cancelRequested, + onCancel: async () => { + if (!exportSchema) return; + const result = await ctx.openConfirm({ + title: 'Cancel import in progress?', + content: + 'Stopping now can leave partial changes in your project. Some models or blocks may be created without relationships, some fields or fieldsets may already exist, and plugin installations or editor settings may be incomplete. You can run the import again to finish or manually clean up. Are you sure you want to cancel?', + choices: [ + { + label: 'Yes, cancel the import', + value: 'yes', + intent: 'negative', + }, + ], + cancel: { + label: 'Nevermind', + value: false, + intent: 'positive', + }, + }); + + if (result === 'yes') { + importTask.controller.requestCancel(); + } + }, + }), + }; +} + +function buildExportAllOverlay(exportAllTask: UseLongTaskResult): OverlayConfig { + return { + id: 'export-all', + task: exportAllTask, + title: 'Exporting entire schema', + subtitle: 'Sit tight, we’re gathering models, blocks, and plugins…', + ariaLabel: 'Export in progress', + progressLabel: (progress) => progress.label ?? 'Loading project schema…', + cancel: () => ({ + label: 'Cancel export', + intent: exportAllTask.state.cancelRequested ? 'muted' : 'negative', + disabled: exportAllTask.state.cancelRequested, + onCancel: () => exportAllTask.controller.requestCancel(), + }), + }; +} + +function buildConflictsOverlay(conflictsTask: UseLongTaskResult): OverlayConfig { + return { + id: 'conflicts', + task: conflictsTask, + title: 'Preparing import', + subtitle: 'Sit tight, we’re scanning your export against the project…', + ariaLabel: 'Preparing import', + progressLabel: (progress) => progress.label ?? 'Preparing import…', + overlayZIndex: 9998, + }; +} + +function buildExportPrepOverlay(exportPreparingTask: UseLongTaskResult): OverlayConfig { + return { + id: 'export-prep', + task: exportPreparingTask, + title: 'Preparing export', + subtitle: 'Sit tight, we’re setting up your models, blocks, and plugins…', + ariaLabel: 'Preparing export', + progressLabel: (progress) => progress.label ?? 'Preparing export…', + }; +} + +function buildExportSelectionOverlay( + exportSelectionTask: UseLongTaskResult, +): OverlayConfig { + return { + id: 'export-selection', + task: exportSelectionTask, + title: 'Exporting selection', + subtitle: 'Sit tight, we’re gathering models, blocks, and plugins…', + ariaLabel: 'Export in progress', + progressLabel: (progress) => progress.label ?? 'Preparing export…', + cancel: () => ({ + label: 'Cancel export', + intent: exportSelectionTask.state.cancelRequested ? 'muted' : 'negative', + disabled: exportSelectionTask.state.cancelRequested, + onCancel: () => exportSelectionTask.controller.requestCancel(), + }), + }; +} diff --git a/import-export-schema/src/entrypoints/ImportPage/useRecipeLoader.ts b/import-export-schema/src/entrypoints/ImportPage/useRecipeLoader.ts new file mode 100644 index 00000000..1393f40c --- /dev/null +++ b/import-export-schema/src/entrypoints/ImportPage/useRecipeLoader.ts @@ -0,0 +1,74 @@ +import { useEffect, useState } from 'react'; +import type { RenderPageCtx } from 'datocms-plugin-sdk'; +import type { ExportDoc } from '@/utils/types'; +import { ExportSchema } from '../ExportPage/ExportSchema'; + +type RecipeLoaderResult = { + loading: boolean; +}; + +type RecipeLoadedCallback = (payload: { + label: string; + schema: ExportSchema; +}) => void; + +type RecipeLoaderOptions = { + onError?: (error: unknown) => void; +}; + +/** + * Watches the page URL for the optional recipe parameters and hydrates an export schema + * when present, exposing a simple loading flag to the caller. + */ +export function useRecipeLoader( + ctx: RenderPageCtx, + onLoaded: RecipeLoadedCallback, + { onError }: RecipeLoaderOptions = {}, +): RecipeLoaderResult { + const [loading, setLoading] = useState(false); + const locationSearch = ctx.location.search; + + useEffect(() => { + const params = new URLSearchParams(locationSearch); + const recipeUrlValue = params.get('recipe_url'); + if (!recipeUrlValue) { + return; + } + const recipeUrl = recipeUrlValue; + + const recipeTitle = params.get('recipe_title'); + let cancelled = false; + + async function run() { + try { + setLoading(true); + const response = await fetch(recipeUrl); + const body = (await response.json()) as ExportDoc; + const schema = new ExportSchema(body); + if (cancelled) return; + const parsedUrl = new URL(recipeUrl); + const fallbackName = + parsedUrl.pathname.split('/').pop() || 'Imported schema'; + onLoaded({ + label: recipeTitle || fallbackName, + schema, + }); + } catch (error) { + if (!cancelled) { + onError?.(error); + console.error('Failed to load recipe export', error); + } + } finally { + if (!cancelled) setLoading(false); + } + } + + void run(); + + return () => { + cancelled = true; + }; + }, [locationSearch, onLoaded, onError]); + + return { loading }; +} diff --git a/import-export-schema/src/index.css b/import-export-schema/src/index.css index 0c5087eb..7f3361a9 100644 --- a/import-export-schema/src/index.css +++ b/import-export-schema/src/index.css @@ -916,7 +916,7 @@ button.chip:focus-visible { border: 1px solid var(--border-color); box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); border-radius: 4px; - max-width: 600px; + width: 750px; padding: var(--spacing-xxl); /* Ensure some breathing room from toolbar/top */ margin-top: var(--spacing-xl); diff --git a/import-export-schema/src/shared/hooks/useExportSelection.ts b/import-export-schema/src/shared/hooks/useExportSelection.ts index c87f903f..9a3da015 100644 --- a/import-export-schema/src/shared/hooks/useExportSelection.ts +++ b/import-export-schema/src/shared/hooks/useExportSelection.ts @@ -1,5 +1,5 @@ import type { SchemaTypes } from '@datocms/cma-client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { isDefined } from '@/utils/isDefined'; import type { ProjectSchema } from '@/utils/ProjectSchema'; @@ -13,8 +13,6 @@ type UseExportSelectionResult = { selectedIds: string[]; selectedItemTypes: SchemaTypes.ItemType[]; setSelectedIds: (ids: string[]) => void; - selectAllModels: () => void; - selectAllBlocks: () => void; }; /** @@ -71,36 +69,10 @@ export function useExportSelection({ ); }, [enabled, itemTypesById, selectedIds.join('-')]); - const selectAllModels = useCallback(() => { - if (!allItemTypes) { - return; - } - - setSelectedIds( - allItemTypes - .filter((it) => !it.attributes.modular_block) - .map((it) => it.id), - ); - }, [allItemTypes]); - - const selectAllBlocks = useCallback(() => { - if (!allItemTypes) { - return; - } - - setSelectedIds( - allItemTypes - .filter((it) => it.attributes.modular_block) - .map((it) => it.id), - ); - }, [allItemTypes]); - return { allItemTypes, selectedIds, selectedItemTypes, setSelectedIds, - selectAllModels, - selectAllBlocks, }; } diff --git a/import-export-schema/src/utils/schema/ProjectSchemaSource.ts b/import-export-schema/src/utils/schema/ProjectSchemaSource.ts index 499877dc..6751742c 100644 --- a/import-export-schema/src/utils/schema/ProjectSchemaSource.ts +++ b/import-export-schema/src/utils/schema/ProjectSchemaSource.ts @@ -5,9 +5,14 @@ import type { ISchemaSource } from './ISchemaSource'; /** Adapts the live CMA-backed project schema to the generic graph interface. */ export class ProjectSchemaSource implements ISchemaSource { private schema: ProjectSchema; + private cachedPluginIds?: Set; - constructor(schema: ProjectSchema) { + constructor( + schema: ProjectSchema, + options: { installedPluginIds?: Set } = {}, + ) { this.schema = schema; + this.cachedPluginIds = options.installedPluginIds; } async getItemTypeById(id: string): Promise { @@ -25,7 +30,11 @@ export class ProjectSchemaSource implements ISchemaSource { } async getKnownPluginIds(): Promise> { + if (this.cachedPluginIds) { + return this.cachedPluginIds; + } const plugins = await this.schema.getAllPlugins(); - return new Set(plugins.map((p) => p.id)); + this.cachedPluginIds = new Set(plugins.map((p) => p.id)); + return this.cachedPluginIds; } } diff --git a/import-export-schema/tsconfig.app.tsbuildinfo b/import-export-schema/tsconfig.app.tsbuildinfo index 6aef5035..1192d20a 100644 --- a/import-export-schema/tsconfig.app.tsbuildinfo +++ b/import-export-schema/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/exportstartpanel.tsx","./src/components/field.tsx","./src/components/fieldedgerenderer.tsx","./src/components/graphcanvas.tsx","./src/components/itemtypenoderenderer.tsx","./src/components/pluginnoderenderer.tsx","./src/components/progressoverlay.tsx","./src/components/progressstallnotice.tsx","./src/components/taskoverlaystack.tsx","./src/components/taskprogressoverlay.tsx","./src/components/bezier.ts","./src/entrypoints/config/index.tsx","./src/entrypoints/exporthome/index.tsx","./src/entrypoints/exportpage/dependencyactionspanel.tsx","./src/entrypoints/exportpage/entitiestoexportcontext.ts","./src/entrypoints/exportpage/exportitemtypenoderenderer.tsx","./src/entrypoints/exportpage/exportpluginnoderenderer.tsx","./src/entrypoints/exportpage/exportschema.ts","./src/entrypoints/exportpage/exporttoolbar.tsx","./src/entrypoints/exportpage/inner.tsx","./src/entrypoints/exportpage/largeselectionview.tsx","./src/entrypoints/exportpage/buildexportdoc.ts","./src/entrypoints/exportpage/buildgraphfromschema.ts","./src/entrypoints/exportpage/index.tsx","./src/entrypoints/exportpage/useanimatednodes.tsx","./src/entrypoints/exportpage/useexportgraph.ts","./src/entrypoints/importpage/filedropzone.tsx","./src/entrypoints/importpage/importitemtypenoderenderer.tsx","./src/entrypoints/importpage/importpluginnoderenderer.tsx","./src/entrypoints/importpage/inner.tsx","./src/entrypoints/importpage/largeselectionview.tsx","./src/entrypoints/importpage/resolutionsform.tsx","./src/entrypoints/importpage/selectedentitycontext.tsx","./src/entrypoints/importpage/buildgraphfromexportdoc.ts","./src/entrypoints/importpage/buildimportdoc.ts","./src/entrypoints/importpage/importschema.ts","./src/entrypoints/importpage/index.tsx","./src/entrypoints/importpage/conflictsmanager/collapsible.tsx","./src/entrypoints/importpage/conflictsmanager/conflictscontext.ts","./src/entrypoints/importpage/conflictsmanager/itemtypeconflict.tsx","./src/entrypoints/importpage/conflictsmanager/pluginconflict.tsx","./src/entrypoints/importpage/conflictsmanager/buildconflicts.ts","./src/entrypoints/importpage/conflictsmanager/index.tsx","./src/shared/constants/graph.ts","./src/shared/hooks/usecmaclient.ts","./src/shared/hooks/useconflictsbuilder.ts","./src/shared/hooks/useexportallhandler.ts","./src/shared/hooks/useexportselection.ts","./src/shared/hooks/useprojectschema.ts","./src/shared/hooks/useschemaexporttask.ts","./src/shared/tasks/uselongtask.ts","./src/types/lodash-es.d.ts","./src/utils/projectschema.ts","./src/utils/createcmaclient.ts","./src/utils/debug.ts","./src/utils/downloadjson.ts","./src/utils/emojiagnosticsorter.ts","./src/utils/icons.tsx","./src/utils/isdefined.ts","./src/utils/render.tsx","./src/utils/types.ts","./src/utils/datocms/appearance.ts","./src/utils/datocms/fieldtypeinfo.ts","./src/utils/datocms/schema.ts","./src/utils/datocms/validators.ts","./src/utils/graph/analysis.ts","./src/utils/graph/buildgraph.ts","./src/utils/graph/buildhierarchynodes.ts","./src/utils/graph/dependencies.ts","./src/utils/graph/edges.ts","./src/utils/graph/index.ts","./src/utils/graph/nodes.ts","./src/utils/graph/rebuildgraphwithpositionsfromhierarchy.ts","./src/utils/graph/sort.ts","./src/utils/graph/types.ts","./src/utils/schema/exportschemasource.ts","./src/utils/schema/ischemasource.ts","./src/utils/schema/projectschemasource.ts"],"version":"5.7.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/blankslate.tsx","./src/components/exportstartpanel.tsx","./src/components/field.tsx","./src/components/fieldedgerenderer.tsx","./src/components/graphcanvas.tsx","./src/components/itemtypenoderenderer.tsx","./src/components/largeselectionlayout.tsx","./src/components/pluginnoderenderer.tsx","./src/components/progressoverlay.tsx","./src/components/progressstallnotice.tsx","./src/components/taskoverlaystack.tsx","./src/components/taskprogressoverlay.tsx","./src/components/bezier.ts","./src/entrypoints/config/index.tsx","./src/entrypoints/exporthome/index.tsx","./src/entrypoints/exportpage/dependencyactionspanel.tsx","./src/entrypoints/exportpage/entitiestoexportcontext.ts","./src/entrypoints/exportpage/exportitemtypenoderenderer.tsx","./src/entrypoints/exportpage/exportpluginnoderenderer.tsx","./src/entrypoints/exportpage/exportschema.ts","./src/entrypoints/exportpage/exporttoolbar.tsx","./src/entrypoints/exportpage/inner.tsx","./src/entrypoints/exportpage/largeselectionview.tsx","./src/entrypoints/exportpage/buildexportdoc.ts","./src/entrypoints/exportpage/buildgraphfromschema.ts","./src/entrypoints/exportpage/index.tsx","./src/entrypoints/exportpage/useanimatednodes.tsx","./src/entrypoints/exportpage/useexportgraph.ts","./src/entrypoints/importpage/exportworkflow.tsx","./src/entrypoints/importpage/filedropzone.tsx","./src/entrypoints/importpage/importitemtypenoderenderer.tsx","./src/entrypoints/importpage/importpluginnoderenderer.tsx","./src/entrypoints/importpage/importworkflow.tsx","./src/entrypoints/importpage/inner.tsx","./src/entrypoints/importpage/largeselectionview.tsx","./src/entrypoints/importpage/resolutionsform.tsx","./src/entrypoints/importpage/selectedentitycontext.tsx","./src/entrypoints/importpage/buildgraphfromexportdoc.ts","./src/entrypoints/importpage/buildimportdoc.ts","./src/entrypoints/importpage/importschema.ts","./src/entrypoints/importpage/index.tsx","./src/entrypoints/importpage/userecipeloader.ts","./src/entrypoints/importpage/conflictsmanager/collapsible.tsx","./src/entrypoints/importpage/conflictsmanager/conflictscontext.ts","./src/entrypoints/importpage/conflictsmanager/itemtypeconflict.tsx","./src/entrypoints/importpage/conflictsmanager/pluginconflict.tsx","./src/entrypoints/importpage/conflictsmanager/buildconflicts.ts","./src/entrypoints/importpage/conflictsmanager/index.tsx","./src/shared/constants/graph.ts","./src/shared/hooks/usecmaclient.ts","./src/shared/hooks/useconflictsbuilder.ts","./src/shared/hooks/useexportallhandler.ts","./src/shared/hooks/useexportselection.ts","./src/shared/hooks/useprojectschema.ts","./src/shared/hooks/useschemaexporttask.ts","./src/shared/tasks/uselongtask.ts","./src/types/lodash-es.d.ts","./src/utils/projectschema.ts","./src/utils/createcmaclient.ts","./src/utils/debug.ts","./src/utils/downloadjson.ts","./src/utils/emojiagnosticsorter.ts","./src/utils/icons.tsx","./src/utils/isdefined.ts","./src/utils/render.tsx","./src/utils/types.ts","./src/utils/datocms/appearance.ts","./src/utils/datocms/fieldtypeinfo.ts","./src/utils/datocms/schema.ts","./src/utils/datocms/validators.ts","./src/utils/graph/analysis.ts","./src/utils/graph/buildgraph.ts","./src/utils/graph/buildhierarchynodes.ts","./src/utils/graph/dependencies.ts","./src/utils/graph/edges.ts","./src/utils/graph/index.ts","./src/utils/graph/nodes.ts","./src/utils/graph/rebuildgraphwithpositionsfromhierarchy.ts","./src/utils/graph/sort.ts","./src/utils/graph/types.ts","./src/utils/schema/exportschemasource.ts","./src/utils/schema/ischemasource.ts","./src/utils/schema/projectschemasource.ts"],"version":"5.7.3"} \ No newline at end of file From 738327a2e7ed2a53ab33a8474250818258981992 Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Fri, 19 Sep 2025 01:08:33 +0200 Subject: [PATCH 10/36] better export screen --- import-export-schema/AGENTS.md | 2 +- import-export-schema/README.md | 2 +- .../src/components/ExportLandingPanel.tsx | 53 +++++++ ...tartPanel.tsx => ExportSelectionPanel.tsx} | 59 +++---- .../src/entrypoints/ExportHome/index.tsx | 150 ++++++++++-------- .../entrypoints/ImportPage/ExportWorkflow.tsx | 52 ++++-- .../src/entrypoints/ImportPage/index.tsx | 33 +++- import-export-schema/src/index.css | 12 +- import-export-schema/tsconfig.app.tsbuildinfo | 2 +- 9 files changed, 231 insertions(+), 134 deletions(-) create mode 100644 import-export-schema/src/components/ExportLandingPanel.tsx rename import-export-schema/src/components/{ExportStartPanel.tsx => ExportSelectionPanel.tsx} (59%) diff --git a/import-export-schema/AGENTS.md b/import-export-schema/AGENTS.md index 19f36ae9..99c029d9 100644 --- a/import-export-schema/AGENTS.md +++ b/import-export-schema/AGENTS.md @@ -13,7 +13,7 @@ ## Coding Style & Naming Conventions - Language stack: TypeScript + React 18 with Vite. - Follow Biome defaults (2-space indent, single quotes, sorted imports). Run `npm run format` prior to commits. -- Use PascalCase for components (`ExportStartPanel.tsx`), camelCase for functions/variables, and PascalCase for types/interfaces. +- Use PascalCase for components (`ExportLandingPanel.tsx`), camelCase for functions/variables, and PascalCase for types/interfaces. - Prefer CSS modules; reference class names via `styles.` and reuse design tokens from `datocms-react-ui`. ## Security & Configuration Tips diff --git a/import-export-schema/README.md b/import-export-schema/README.md index 0b6dff40..b8abe82a 100644 --- a/import-export-schema/README.md +++ b/import-export-schema/README.md @@ -77,7 +77,7 @@ Powerful, safe schema migration for DatoCMS. Export models/blocks and plugins as - `useExportGraph`, `useExportAllHandler`, and `useConflictsBuilder` encapsulate schema loading logic. - Shared UI: - `ProgressOverlay` renders the full-screen overlay with accessible ARIA props and cancel handling. - - `ExportStartPanel` powers the initial export selector in both ExportHome and ImportPage. + - `ExportLandingPanel` and `ExportSelectionPanel` handle the two-step export start flow in both ExportHome and ImportPage. - Graph utilities expose a single entry point (`@/utils/graph`) with `SchemaProgressUpdate` progress typing. ## Export File Format diff --git a/import-export-schema/src/components/ExportLandingPanel.tsx b/import-export-schema/src/components/ExportLandingPanel.tsx new file mode 100644 index 00000000..3a75f6fe --- /dev/null +++ b/import-export-schema/src/components/ExportLandingPanel.tsx @@ -0,0 +1,53 @@ +import { Button } from 'datocms-react-ui'; + +interface Props { + onSelectModels: () => void; + onExportAll: () => void | Promise; + exportAllDisabled: boolean; + title?: string; + description?: string; + selectLabel?: string; + exportAllLabel?: string; +} + +/** + * First step of the export flow that offers the quick actions without surfacing + * the detailed selection UI. + */ +export function ExportLandingPanel({ + onSelectModels, + onExportAll, + exportAllDisabled, + title = 'Start a new export', + description = 'Choose how you want to start the export process.', + selectLabel = 'Export select models', + exportAllLabel = 'Export entire schema', +}: Props) { + return ( +
    +
    {title}
    +
    +

    {description}

    +
    + + +
    +
    +
    + ); +} diff --git a/import-export-schema/src/components/ExportStartPanel.tsx b/import-export-schema/src/components/ExportSelectionPanel.tsx similarity index 59% rename from import-export-schema/src/components/ExportStartPanel.tsx rename to import-export-schema/src/components/ExportSelectionPanel.tsx index 142c58de..1e9b9778 100644 --- a/import-export-schema/src/components/ExportStartPanel.tsx +++ b/import-export-schema/src/components/ExportSelectionPanel.tsx @@ -14,41 +14,40 @@ type Props = { selectedIds: string[]; onSelectedIdsChange: (ids: string[]) => void; onStart: () => void; + onBack: () => void; startDisabled: boolean; - onExportAll: () => void | Promise; - exportAllDisabled: boolean; title?: string; description?: string; selectLabel?: string; startLabel?: string; - exportAllLabel?: string; + backLabel?: string; }; /** - * Blank-slate panel that lets editors pick the starting set of models/blocks and kick - * off either a targeted or full-schema export. + * Secondary step of the export flow that lets editors pick targeted models/blocks + * before jumping into the dependency graph. */ -export function ExportStartPanel({ +export function ExportSelectionPanel({ selectId, itemTypes, selectedIds, onSelectedIdsChange, onStart, + onBack, startDisabled, - onExportAll, - exportAllDisabled, - title = 'Start a new export', - description = 'Select one or more models/blocks to start selecting what to export.', + title = 'Select models to export', + description = + 'Choose the models and blocks you want to inspect. You can refine the selection on the next screen.', selectLabel = 'Starting models/blocks', - startLabel = 'Export Selected', - exportAllLabel = 'Export entire schema', + startLabel = 'Export selection', + backLabel = 'Back', }: Props) { const options = useMemo( () => - (itemTypes ?? []).map((it) => ({ - value: it.id, - label: `${it.attributes.name}${ - it.attributes.modular_block ? ' (Block)' : '' + (itemTypes ?? []).map((itemType) => ({ + value: itemType.id, + label: `${itemType.attributes.name}${ + itemType.attributes.modular_block ? ' (Block)' : '' }`, })), [itemTypes], @@ -56,7 +55,7 @@ export function ExportStartPanel({ // React-Select expects objects; keep them memoized so the control stays controlled. const value = useMemo( - () => options.filter((opt) => selectedIds.includes(opt.value)), + () => options.filter((option) => selectedIds.includes(option.value)), [options, selectedIds], ); @@ -81,31 +80,17 @@ export function ExportStartPanel({ value={value} onChange={(multi) => onSelectedIdsChange( - Array.isArray(multi) ? multi.map((o) => o.value) : [], + Array.isArray(multi) ? multi.map((option) => option.value) : [], ) } />
    -
    - -
    -
    -
    diff --git a/import-export-schema/src/entrypoints/ExportHome/index.tsx b/import-export-schema/src/entrypoints/ExportHome/index.tsx index 116c8eff..a9a9c658 100644 --- a/import-export-schema/src/entrypoints/ExportHome/index.tsx +++ b/import-export-schema/src/entrypoints/ExportHome/index.tsx @@ -2,7 +2,8 @@ import { ReactFlowProvider } from '@xyflow/react'; import type { RenderPageCtx } from 'datocms-plugin-sdk'; import { Canvas } from 'datocms-react-ui'; import { useId, useState } from 'react'; -import { ExportStartPanel } from '@/components/ExportStartPanel'; +import { ExportLandingPanel } from '@/components/ExportLandingPanel'; +import { ExportSelectionPanel } from '@/components/ExportSelectionPanel'; import { TaskOverlayStack } from '@/components/TaskOverlayStack'; import { useExportAllHandler } from '@/shared/hooks/useExportAllHandler'; import { useExportSelection } from '@/shared/hooks/useExportSelection'; @@ -15,16 +16,16 @@ type Props = { ctx: RenderPageCtx; }; +type ExportView = 'landing' | 'selection' | 'graph'; + /** - * Landing page for the export workflow. Guides the user from the initial selection - * state into the detailed graph view while coordinating the long-running tasks. + * Landing page for the export workflow. Guides the user from the initial action + * choice into the detailed graph view while coordinating the long-running tasks. */ export default function ExportHome({ ctx }: Props) { const exportInitialSelectId = useId(); const projectSchema = useProjectSchema(ctx); - // adminDomain and post-export overview removed; we download and toast only - const { allItemTypes, selectedIds: exportInitialItemTypeIds, @@ -32,7 +33,7 @@ export default function ExportHome({ ctx }: Props) { setSelectedIds: setExportInitialItemTypeIds, } = useExportSelection({ schema: projectSchema }); - const [exportStarted, setExportStarted] = useState(false); + const [view, setView] = useState('landing'); const exportAllTask = useLongTask(); const exportPreparingTask = useLongTask(); @@ -51,78 +52,93 @@ export default function ExportHome({ ctx }: Props) { task: exportAllTask.controller, }); - const handleStartExport = () => { + const handleStartSelection = () => { + if (exportInitialItemTypeIds.length === 0) { + return; + } exportPreparingTask.controller.start({ label: 'Preparing export…', }); setExportPreparingPercent(0.1); - setExportStarted(true); + setView('graph'); }; + let body: JSX.Element; + + if (view === 'graph') { + body = ( + { + setExportPreparingPercent(1); + exportPreparingTask.controller.complete({ + label: 'Graph prepared', + }); + }} + onPrepareProgress={(progress) => { + if (exportPreparingTask.state.status !== 'running') { + exportPreparingTask.controller.start(progress); + } else { + exportPreparingTask.controller.setProgress(progress); + } + + const hasFixedTotal = (progress.total ?? 0) > 0; + const raw = hasFixedTotal ? progress.done / progress.total : 0; + + if (!hasFixedTotal) { + setExportPreparingPercent((prev) => + Math.min(0.25, Math.max(prev, prev + 0.02)), + ); + } else { + const mapped = 0.25 + raw * 0.75; + setExportPreparingPercent((prev) => + Math.max(prev, Math.min(1, mapped)), + ); + } + }} + onClose={() => { + setView('selection'); + setExportPreparingPercent(0.1); + exportPreparingTask.controller.reset(); + }} + onExport={(itemTypeIds, pluginIds) => + runSelectionExport({ + rootItemTypeId: exportInitialItemTypeIds[0], + itemTypeIds, + pluginIds, + }) + } + /> + ); + } else if (view === 'selection') { + body = ( + setView('landing')} + startDisabled={exportInitialItemTypeIds.length === 0} + /> + ); + } else { + body = ( + setView('selection')} + onExportAll={runExportAll} + exportAllDisabled={exportAllTask.state.status === 'running'} + /> + ); + } + return (
    -
    - {!exportStarted ? ( - - ) : ( - { - setExportPreparingPercent(1); - exportPreparingTask.controller.complete({ - label: 'Graph prepared', - }); - }} - onPrepareProgress={(p) => { - // ensure overlay shows determinate progress - if (exportPreparingTask.state.status !== 'running') { - exportPreparingTask.controller.start(p); - } else { - exportPreparingTask.controller.setProgress(p); - } - const hasFixedTotal = (p.total ?? 0) > 0; - const raw = hasFixedTotal ? p.done / p.total : 0; - if (!hasFixedTotal) { - // Indeterminate scanning: gently advance up to 25% - setExportPreparingPercent((prev) => - Math.min(0.25, Math.max(prev, prev + 0.02)), - ); - } else { - // Determinate build: map to [0.25, 1] - const mapped = 0.25 + raw * 0.75; - setExportPreparingPercent((prev) => - Math.max(prev, Math.min(1, mapped)), - ); - } - }} - onClose={() => { - // Return to selection screen with current picks preserved - setExportStarted(false); - exportPreparingTask.controller.reset(); - }} - onExport={(itemTypeIds, pluginIds) => - runSelectionExport({ - rootItemTypeId: exportInitialItemTypeIds[0], - itemTypeIds, - pluginIds, - }) - } - /> - )} -
    +
    {body}
    diff --git a/import-export-schema/src/entrypoints/ImportPage/ExportWorkflow.tsx b/import-export-schema/src/entrypoints/ImportPage/ExportWorkflow.tsx index 3344b4d0..99918db2 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ExportWorkflow.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ExportWorkflow.tsx @@ -1,6 +1,7 @@ import type { ComponentProps } from 'react'; import type { SchemaTypes } from '@datocms/cma-client'; -import { ExportStartPanel } from '@/components/ExportStartPanel'; +import { ExportLandingPanel } from '@/components/ExportLandingPanel'; +import { ExportSelectionPanel } from '@/components/ExportSelectionPanel'; import ExportInner from '../ExportPage/Inner'; import type { ProjectSchema } from '@/utils/ProjectSchema'; @@ -8,15 +9,19 @@ export type ExportWorkflowPrepareProgress = Parameters< NonNullable['onPrepareProgress']> >[0]; +export type ExportWorkflowView = 'landing' | 'selection' | 'graph'; + type Props = { projectSchema: ProjectSchema; - exportStarted: boolean; + view: ExportWorkflowView; exportInitialSelectId: string; allItemTypes?: SchemaTypes.ItemType[]; exportInitialItemTypeIds: string[]; exportInitialItemTypes: SchemaTypes.ItemType[]; setSelectedIds: (ids: string[]) => void; - onStart: () => void; + onShowSelection: () => void; + onBackToLanding: () => void; + onStartSelection: () => void; onExportAll: () => void; exportAllDisabled: boolean; onGraphPrepared: () => void; @@ -30,13 +35,15 @@ type Props = { */ export function ExportWorkflow({ projectSchema, - exportStarted, + view, exportInitialSelectId, allItemTypes, exportInitialItemTypeIds, exportInitialItemTypes, setSelectedIds, - onStart, + onShowSelection, + onBackToLanding, + onStartSelection, onExportAll, exportAllDisabled, onGraphPrepared, @@ -44,18 +51,32 @@ export function ExportWorkflow({ onClose, onExportSelection, }: Props) { - if (!exportStarted) { + if (view === 'graph') { + return ( +
    + +
    + ); + } + + if (view === 'selection') { return (
    -
    ); @@ -63,13 +84,10 @@ export function ExportWorkflow({ return (
    -
    ); diff --git a/import-export-schema/src/entrypoints/ImportPage/index.tsx b/import-export-schema/src/entrypoints/ImportPage/index.tsx index bb62e364..edcb7d1b 100644 --- a/import-export-schema/src/entrypoints/ImportPage/index.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/index.tsx @@ -11,7 +11,7 @@ import { useSchemaExportTask } from '@/shared/hooks/useSchemaExportTask'; import { useLongTask, type UseLongTaskResult } from '@/shared/tasks/useLongTask'; import type { ExportDoc } from '@/utils/types'; import { ExportSchema } from '../ExportPage/ExportSchema'; -import { ExportWorkflow, type ExportWorkflowPrepareProgress } from './ExportWorkflow'; +import { ExportWorkflow, type ExportWorkflowPrepareProgress, type ExportWorkflowView } from './ExportWorkflow'; import { ImportWorkflow } from './ImportWorkflow'; import { buildImportDoc } from './buildImportDoc'; import type { Resolutions } from './ResolutionsForm'; @@ -83,7 +83,7 @@ export function ImportPage({ const [exportSchema, setExportSchema] = useState< [string, ExportSchema] | undefined >(); - const [exportStarted, setExportStarted] = useState(false); + const [exportView, setExportView] = useState('landing'); const projectSchema = useProjectSchema(ctx); const client = projectSchema.client; @@ -98,6 +98,11 @@ export function ImportPage({ }); const conflictsTask = useLongTask(); + useEffect(() => { + setExportView('landing'); + exportPreparingTask.controller.reset(); + }, [mode, exportPreparingTask.controller]); + const { allItemTypes, selectedIds: exportInitialItemTypeIds, @@ -153,12 +158,19 @@ export function ImportPage({ task: exportAllTask.controller, }); + const handleShowExportSelection = useCallback(() => { + setExportView('selection'); + }, []); + const handleStartExportSelection = useCallback(() => { + if (exportInitialItemTypeIds.length === 0) { + return; + } exportPreparingTask.controller.start({ label: 'Preparing export…', }); - setExportStarted(true); - }, [exportPreparingTask.controller]); + setExportView('graph'); + }, [exportInitialItemTypeIds, exportPreparingTask.controller]); const handleExportGraphPrepared = useCallback(() => { exportPreparingTask.controller.complete({ @@ -178,7 +190,12 @@ export function ImportPage({ ); const handleExportClose = useCallback(() => { - setExportStarted(false); + setExportView('selection'); + exportPreparingTask.controller.reset(); + }, [exportPreparingTask.controller]); + + const handleBackToLanding = useCallback(() => { + setExportView('landing'); exportPreparingTask.controller.reset(); }, [exportPreparingTask.controller]); @@ -337,13 +354,15 @@ export function ImportPage({ ) : ( Date: Fri, 19 Sep 2025 02:29:10 +0200 Subject: [PATCH 11/36] better conflict manager --- .../ConflictsManager/Collapsible.tsx | 13 +- .../ConflictsManager/ItemTypeConflict.tsx | 166 ++++--- .../ConflictsManager/PluginConflict.tsx | 67 +-- .../ImportPage/ConflictsManager/index.tsx | 412 ++++++++++++------ import-export-schema/src/index.css | 56 ++- 5 files changed, 482 insertions(+), 232 deletions(-) diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/Collapsible.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/Collapsible.tsx index 5398e0cf..1e33891a 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/Collapsible.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/Collapsible.tsx @@ -2,6 +2,7 @@ import type { SchemaTypes } from '@datocms/cma-client'; import { faCaretRight as faCollapsed, faCaretDown as faExpanded, + faCircleExclamation, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; @@ -11,6 +12,7 @@ import { SelectedEntityContext } from '../SelectedEntityContext'; type Props = { entity: SchemaTypes.ItemType | SchemaTypes.Plugin; invalid?: boolean; + hasConflict?: boolean; title: ReactNode; children: ReactNode; }; @@ -21,6 +23,7 @@ type Props = { export default function Collapsible({ entity, invalid, + hasConflict = false, title, children, }: Props) { @@ -49,6 +52,7 @@ export default function Collapsible({ 'conflict', isSelected && 'conflict--selected', invalid && 'conflict--invalid', + hasConflict && 'conflict--has-conflict', )} ref={elRef} > @@ -60,7 +64,14 @@ export default function Collapsible({ aria-controls={`conflict-panel-${entity.id}`} id={`conflict-button-${entity.id}`} > - {title} + + {title} + {hasConflict ? ( + + + Conflict + + ) : null}
    = { type Props = { exportItemType: SchemaTypes.ItemType; - projectItemType: SchemaTypes.ItemType; + projectItemType?: SchemaTypes.ItemType; }; /** @@ -25,95 +25,129 @@ export function ItemTypeConflict({ exportItemType, projectItemType }: Props) { const nameId = useId(); const apiKeyId = useId(); const fieldPrefix = `itemType-${exportItemType.id}`; - const resolution = useResolutionStatusForItemType(exportItemType.id)!; + const resolution = useResolutionStatusForItemType(exportItemType.id); const node = useReactFlow().getNode(`itemType--${exportItemType.id}`); const exportType = exportItemType.attributes.modular_block ? 'block' : 'model'; - const projectType = projectItemType.attributes.modular_block + const projectType = projectItemType?.attributes.modular_block ? 'block' : 'model'; + const resolutionValues = resolution?.values; + const resolutionStrategy = resolutionValues?.strategy; + + const resolutionStrategyIsRename = resolutionStrategy === 'rename'; + const resolutionStrategyIsReuseExisting = resolutionStrategy === 'reuseExisting'; + + const renameReady = + resolutionStrategyIsRename && + !!resolutionValues?.name && + !!resolutionValues?.apiKey && + !resolution?.invalid; + + const reuseReady = resolutionStrategyIsReuseExisting && !resolution?.invalid; + + const conflictResolved = Boolean(projectItemType) && (renameReady || reuseReady); + + const hasConflict = Boolean(projectItemType) && !conflictResolved; + // Base strategy options; reuse is only valid for matching model/block types. - const options: Option[] = [ - { label: `Import ${exportType} using a different name`, value: 'rename' }, - ]; - - if ( - exportItemType.attributes.modular_block === - projectItemType.attributes.modular_block - ) { + const options: Option[] = []; + + if (projectItemType) { options.push({ - label: `Reuse the existing ${exportType}`, - value: 'reuseExisting', + label: `Import ${exportType} using a different name`, + value: 'rename', }); + + if ( + exportItemType.attributes.modular_block === + projectItemType.attributes.modular_block + ) { + options.push({ + label: `Reuse the existing ${exportType}`, + value: 'reuseExisting', + }); + } } if (!node) { return null; } + const isInvalid = hasConflict && Boolean(resolution?.invalid); + return ( -

    - The project already has a {projectType} called{' '} - - {projectItemType.attributes.name} - {' '} - ({projectItemType.attributes.api_key}). -

    - - {({ input, meta: { error } }) => ( - > - {...input} - id={selectId} - label="To resolve this conflict:" - selectInputProps={{ - options, - }} - value={ - options.find((option) => input.value === option.value) ?? null - } - onChange={(option) => input.onChange(option ? option.value : null)} - placeholder="Select..." - error={error} - /> - )} - - {resolution.values.strategy === 'rename' && ( + {projectItemType ? ( <> -
    - - {({ input, meta: { error } }) => ( - - )} - -
    -
    - - {({ input, meta: { error } }) => ( - - )} - -
    +

    + The project already has a {projectType} called{' '} + + {projectItemType.attributes.name} + {' '} + ({projectItemType.attributes.api_key}). +

    + + {({ input, meta: { error } }) => ( + > + {...input} + id={selectId} + label="To resolve this conflict:" + selectInputProps={{ + options, + }} + value={ + options.find((option) => input.value === option.value) ?? null + } + onChange={(option) => + input.onChange(option ? option.value : null) + } + placeholder="Select..." + error={error} + /> + )} + + {resolutionStrategyIsRename && ( + <> +
    + + {({ input, meta: { error } }) => ( + + )} + +
    +
    + + {({ input, meta: { error } }) => ( + + )} + +
    + + )} + ) : ( +

    No conflicts detected for this name and api key.

    )}
    ); diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx index 5a3acc4a..d5f45939 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx @@ -22,48 +22,65 @@ const options: Option[] = [ type Props = { exportPlugin: SchemaTypes.Plugin; - projectPlugin: SchemaTypes.Plugin; + projectPlugin?: SchemaTypes.Plugin; }; /** Presents resolution choices for plugin conflicts (reuse vs. skip). */ export function PluginConflict({ exportPlugin, projectPlugin }: Props) { const selectId = useId(); const fieldPrefix = `plugin-${exportPlugin.id}`; - const resolution = useResolutionStatusForPlugin(exportPlugin.id)!; + const resolution = useResolutionStatusForPlugin(exportPlugin.id); const node = useReactFlow().getNode(`plugin--${exportPlugin.id}`); if (!node) { return null; } + const strategy = resolution?.values?.strategy; + const hasValidResolution = Boolean( + !resolution?.invalid && + (strategy === 'reuseExisting' || strategy === 'skip'), + ); + + const hasConflict = Boolean(projectPlugin) && !hasValidResolution; + return ( -

    - The project already has the plugin{' '} - {projectPlugin.attributes.name}. -

    - - {({ input, meta: { error } }) => ( - > - {...input} - id={selectId} - label="To resolve this conflict:" - selectInputProps={{ - options, - }} - value={ - options.find((option) => input.value === option.value) ?? null - } - onChange={(option) => input.onChange(option ? option.value : null)} - placeholder="Select..." - error={error} - /> - )} - + {projectPlugin ? ( + <> +

    + The project already has the plugin{' '} + {projectPlugin.attributes.name}. +

    + + {({ input, meta: { error } }) => ( + > + {...input} + id={selectId} + label="To resolve this conflict:" + selectInputProps={{ + options, + }} + value={ + options.find((option) => input.value === option.value) ?? null + } + onChange={(option) => + input.onChange(option ? option.value : null) + } + placeholder="Select..." + error={error} + /> + )} + + + ) : ( +

    No conflicts detected for this name or URL.

    + )}
    ); } diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx index 52df44fc..5ea8c5c0 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx @@ -1,8 +1,8 @@ import type { SchemaTypes } from '@datocms/cma-client'; import type { RenderPageCtx } from 'datocms-plugin-sdk'; -import { Button } from 'datocms-react-ui'; -import { defaults, groupBy, map, mapValues, sortBy } from 'lodash-es'; -import { useContext, useMemo } from 'react'; +import { Button, SwitchInput } from 'datocms-react-ui'; +import { get } from 'lodash-es'; +import { useContext, useMemo, useState } from 'react'; import { useFormState } from 'react-final-form'; import type { ExportSchema } from '@/entrypoints/ExportPage/ExportSchema'; import { getTextWithoutRepresentativeEmojiAndPadding } from '@/utils/emojiAgnosticSorter'; @@ -11,6 +11,18 @@ import { ConflictsContext } from './ConflictsContext'; import { ItemTypeConflict } from './ItemTypeConflict'; import { PluginConflict } from './PluginConflict'; +const localeAwareCollator = new Intl.Collator(undefined, { + sensitivity: 'base', + numeric: true, +}); + +function sortEntriesByDisplayName( + items: T[], + getName: (item: T) => string, +) { + return [...items].sort((a, b) => localeAwareCollator.compare(getName(a), getName(b))); +} + type Props = { exportSchema: ExportSchema; schema: ProjectSchema; @@ -26,180 +38,304 @@ export default function ConflictsManager({ schema: _schema, }: Props) { const conflicts = useContext(ConflictsContext); - const { submitting, valid, validating } = useFormState({ + const [showOnlyConflicts, setShowOnlyConflicts] = useState(false); + const formState = useFormState({ subscription: { submitting: true, valid: true, validating: true, + values: true, + errors: true, }, }); + const { + submitting, + valid, + validating, + values: formValues = {}, + errors: formErrors = {}, + } = formState as { + submitting: boolean; + valid: boolean; + validating: boolean; + values: Record; + errors: Record; + }; + + type ItemTypeEntry = { + exportItemType: SchemaTypes.ItemType; + projectItemType?: SchemaTypes.ItemType; + }; + + type PluginEntry = { + exportPlugin: SchemaTypes.Plugin; + projectPlugin?: SchemaTypes.Plugin; + }; const groupedItemTypes = useMemo(() => { + const empty: Record<'blocks' | 'models', ItemTypeEntry[]> = { + blocks: [], + models: [], + }; + if (!conflicts) { - return { blocks: [], models: [] }; + return empty; } - return defaults( - mapValues( - groupBy( - map( - conflicts.itemTypes, - ( - projectItemType: SchemaTypes.ItemType, - exportItemTypeId: string, - ) => { - const exportItemType = - exportSchema.getItemTypeById(exportItemTypeId); - return { exportItemTypeId, exportItemType, projectItemType }; - }, - ), - ({ exportItemType }: { exportItemType: SchemaTypes.ItemType }) => - exportItemType?.attributes.modular_block ? 'blocks' : 'models', - ), - (group: Array<{ exportItemType: SchemaTypes.ItemType }>) => - sortBy( - group, - ({ exportItemType }: { exportItemType: SchemaTypes.ItemType }) => - getTextWithoutRepresentativeEmojiAndPadding( - exportItemType.attributes.name, - ), - ), - ), + const entries: ItemTypeEntry[] = exportSchema.itemTypes.map( + (exportItemType) => ({ + exportItemType, + projectItemType: conflicts.itemTypes[String(exportItemType.id)] ?? undefined, + }), + ); + + const grouped = entries.reduce>( + (accumulator, entry) => { + const key: 'blocks' | 'models' = entry.exportItemType.attributes + .modular_block + ? 'blocks' + : 'models'; + accumulator[key].push(entry); + return accumulator; + }, { blocks: [], models: [] }, ); + + const sortByStatusThenName = (items: ItemTypeEntry[]) => + sortEntriesByDisplayName( + [...items].sort((a, b) => { + const aHasConflict = Boolean(a.projectItemType); + const bHasConflict = Boolean(b.projectItemType); + if (aHasConflict === bHasConflict) { + return 0; + } + return aHasConflict ? -1 : 1; + }), + (entry) => + getTextWithoutRepresentativeEmojiAndPadding( + entry.exportItemType.attributes.name, + ), + ); + + return { + blocks: sortByStatusThenName(grouped.blocks), + models: sortByStatusThenName(grouped.models), + }; }, [conflicts, exportSchema]); - // Deterministic sorting keeps plugin conflicts stable between renders. - const sortedPlugins = useMemo(() => { + // Deterministic sorting keeps plugin ordering stable between renders. + const pluginEntries = useMemo(() => { if (!conflicts) { - return [] as Array<{ - exportPluginId: string; - exportPlugin: SchemaTypes.Plugin; - projectPlugin: SchemaTypes.Plugin; - }>; + return []; } - return sortBy( - map( - conflicts.plugins, - (projectPlugin: SchemaTypes.Plugin, exportPluginId: string) => { - const exportPlugin = exportSchema.getPluginById(exportPluginId); - return { exportPluginId, exportPlugin, projectPlugin }; - }, - ), - ({ exportPlugin }: { exportPlugin: SchemaTypes.Plugin }) => - exportPlugin.attributes.name, + const entries: PluginEntry[] = exportSchema.plugins.map((exportPlugin) => ({ + exportPlugin, + projectPlugin: conflicts.plugins[String(exportPlugin.id)] ?? undefined, + })); + + const conflictFirst = [...entries].sort((a, b) => { + const aHasConflict = Boolean(a.projectPlugin); + const bHasConflict = Boolean(b.projectPlugin); + if (aHasConflict === bHasConflict) { + return 0; + } + return aHasConflict ? -1 : 1; + }); + + return sortEntriesByDisplayName( + conflictFirst, + (entry) => + getTextWithoutRepresentativeEmojiAndPadding( + entry.exportPlugin.attributes.name, + ), ); }, [conflicts, exportSchema]); + function isItemTypeConflictUnresolved( + exportItemType: SchemaTypes.ItemType, + projectItemType?: SchemaTypes.ItemType, + ) { + if (!projectItemType) { + return false; + } + + const fieldPrefix = `itemType-${exportItemType.id}`; + const strategy = get(formValues, [fieldPrefix, 'strategy']); + const hasErrors = Boolean(get(formErrors, [fieldPrefix])); + + if (!strategy) { + return true; + } + + if (hasErrors) { + return true; + } + + if (strategy === 'rename') { + const name = get(formValues, [fieldPrefix, 'name']); + const apiKey = get(formValues, [fieldPrefix, 'apiKey']); + return !(name && apiKey); + } + + if (strategy === 'reuseExisting') { + return false; + } + + return true; + } + + function isPluginConflictUnresolved( + exportPlugin: SchemaTypes.Plugin, + projectPlugin?: SchemaTypes.Plugin, + ) { + if (!projectPlugin) { + return false; + } + + const fieldPrefix = `plugin-${exportPlugin.id}`; + const strategy = get(formValues, [fieldPrefix, 'strategy']); + const hasErrors = Boolean(get(formErrors, [fieldPrefix])); + + if (!strategy) { + return true; + } + + if (hasErrors) { + return true; + } + + return false; + } + + const visibleModels = groupedItemTypes.models.filter(({ + exportItemType, + projectItemType, + }) => + showOnlyConflicts + ? isItemTypeConflictUnresolved(exportItemType, projectItemType) + : true, + ); + + const visibleBlocks = groupedItemTypes.blocks.filter(({ + exportItemType, + projectItemType, + }) => + showOnlyConflicts + ? isItemTypeConflictUnresolved(exportItemType, projectItemType) + : true, + ); + + const visiblePlugins = pluginEntries.filter(({ exportPlugin, projectPlugin }) => + showOnlyConflicts + ? isPluginConflictUnresolved(exportPlugin, projectPlugin) + : true, + ); + if (!conflicts) { return null; } const itemTypeConflictCount = - groupedItemTypes.blocks.length + groupedItemTypes.models.length; - const pluginConflictCount = sortedPlugins.length; + groupedItemTypes.blocks.filter(({ projectItemType }) => projectItemType) + .length + + groupedItemTypes.models.filter(({ projectItemType }) => projectItemType) + .length; + const pluginConflictCount = pluginEntries.filter( + ({ projectPlugin }) => projectPlugin, + ).length; - const noPotentialConflicts = - itemTypeConflictCount === 0 && pluginConflictCount === 0; + const hasConflicts = + itemTypeConflictCount > 0 || pluginConflictCount > 0; return (
    -
    - Import conflicts -
    +
    Schema overview
    +
    - {noPotentialConflicts ? ( + {!hasConflicts && (

    All set — no conflicting models, blocks, or plugins were found in this import.

    - ) : ( -
    - {groupedItemTypes.models.length > 0 && ( -
    -
    - Models ({groupedItemTypes.models.length}) -
    -
    - {groupedItemTypes.models.map( - ({ - exportItemTypeId, - exportItemType, - projectItemType, - }: { - exportItemTypeId: string; - exportItemType: SchemaTypes.ItemType; - projectItemType: SchemaTypes.ItemType; - }) => ( - - ), - )} -
    -
    - )} - - {groupedItemTypes.blocks.length > 0 && ( -
    -
    - Block models ({groupedItemTypes.blocks.length}) -
    -
    - {groupedItemTypes.blocks.map( - ({ - exportItemTypeId, - exportItemType, - projectItemType, - }: { - exportItemTypeId: string; - exportItemType: SchemaTypes.ItemType; - projectItemType: SchemaTypes.ItemType; - }) => ( - - ), - )} -
    -
    - )} - - {sortedPlugins.length > 0 && ( -
    -
    - Plugins ({sortedPlugins.length}) -
    -
    - {sortedPlugins.map( - ({ - exportPluginId, - exportPlugin, - projectPlugin, - }: { - exportPluginId: string; - exportPlugin: SchemaTypes.Plugin; - projectPlugin: SchemaTypes.Plugin; - }) => ( - - ), - )} -
    -
    - )} + )} + + {visibleModels.length > 0 && ( +
    +
    + Models ({visibleModels.length}) +
    +
    + {visibleModels.map( + ({ exportItemType, projectItemType }) => ( + + ), + )} +
    +
    + )} + + {visibleBlocks.length > 0 && ( +
    +
    + Block models ({visibleBlocks.length}) +
    +
    + {visibleBlocks.map( + ({ exportItemType, projectItemType }) => ( + + ), + )} +
    +
    + )} + + {visiblePlugins.length > 0 && ( +
    +
    + Plugins ({visiblePlugins.length}) +
    +
    + {visiblePlugins.map(({ exportPlugin, projectPlugin }) => ( + + ))} +
    )}
    diff --git a/import-export-schema/src/index.css b/import-export-schema/src/index.css index db68dbe3..adf67f84 100644 --- a/import-export-schema/src/index.css +++ b/import-export-schema/src/index.css @@ -985,12 +985,13 @@ button.chip:focus-visible { /* Slightly tighter padding for redesigned conflicts UI */ } + .conflict { border-bottom: 1px solid var(--border-color); &.conflict--selected { - border-top: 8px solid var(--border-color); - border-bottom: 8px solid var(--border-color); + border-top: 4px solid var(--border-color); + border-bottom: 4px solid var(--border-color); } &:first-child { @@ -998,6 +999,27 @@ button.chip:focus-visible { } } +.conflict--has-conflict { + border: 1px solid rgba(217, 48, 37, 0.22); + border-radius: 8px; + background: rgba(217, 48, 37, 0.04); + margin: 2px 0; +} + +.conflict--has-conflict.conflict--selected { + border-top: 1px solid rgba(217, 48, 37, 0.22); + border-bottom: 1px solid rgba(217, 48, 37, 0.22); + background: rgba(217, 48, 37, 0.06); +} + +.conflict--has-conflict:first-child { + border-top: 1px solid rgba(217, 48, 37, 0.22); +} + +.conflict--has-conflict + .conflict { + border-top: 1px solid var(--border-color); +} + .conflict__title { font-weight: 700; appearance: none; @@ -1013,6 +1035,36 @@ button.chip:focus-visible { padding: 10px 12px; } +.conflict--has-conflict .conflict__title { + color: color-mix(in srgb, var(--base-body-color), #6d1f16 35%); +} + +.conflict__title__text { + flex: 1; + min-width: 0; + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: inherit; +} + +.conflict__badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + color: #a0312a; + background-color: rgba(217, 48, 37, 0.12); +} + +.conflict__badge svg { + width: 12px; + height: 12px; +} + .conflict__content { padding: 12px 16px; /* reduce inner padding for a tighter look */ } From 07dcaa1b630232f0e9f4b8a76e4f41e803b80e1c Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Fri, 19 Sep 2025 11:28:44 +0200 Subject: [PATCH 12/36] better export overview --- .../SchemaOverview}/Collapsible.tsx | 5 +- .../SchemaOverview}/SelectedEntityContext.tsx | 0 .../ExportPage/ExportItemTypeNodeRenderer.tsx | 9 +- .../ExportPage/ExportPluginNodeRenderer.tsx | 3 + .../ExportPage/ExportSchemaOverview.tsx | 345 ++++++++++++++++++ .../entrypoints/ExportPage/ExportToolbar.tsx | 23 +- .../src/entrypoints/ExportPage/Inner.tsx | 288 ++++++++++----- .../ConflictsManager/ItemTypeConflict.tsx | 2 +- .../ConflictsManager/PluginConflict.tsx | 2 +- .../ImportPage/ImportItemTypeNodeRenderer.tsx | 2 +- .../ImportPage/ImportPluginNodeRenderer.tsx | 2 +- .../src/entrypoints/ImportPage/Inner.tsx | 2 +- .../ImportPage/LargeSelectionView.tsx | 2 +- import-export-schema/src/index.css | 111 +++++- import-export-schema/tsconfig.app.tsbuildinfo | 2 +- 15 files changed, 674 insertions(+), 124 deletions(-) rename import-export-schema/src/{entrypoints/ImportPage/ConflictsManager => components/SchemaOverview}/Collapsible.tsx (95%) rename import-export-schema/src/{entrypoints/ImportPage => components/SchemaOverview}/SelectedEntityContext.tsx (100%) create mode 100644 import-export-schema/src/entrypoints/ExportPage/ExportSchemaOverview.tsx diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/Collapsible.tsx b/import-export-schema/src/components/SchemaOverview/Collapsible.tsx similarity index 95% rename from import-export-schema/src/entrypoints/ImportPage/ConflictsManager/Collapsible.tsx rename to import-export-schema/src/components/SchemaOverview/Collapsible.tsx index 1e33891a..896e2b34 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/Collapsible.tsx +++ b/import-export-schema/src/components/SchemaOverview/Collapsible.tsx @@ -7,7 +7,7 @@ import { import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; import { type ReactNode, useContext, useEffect, useRef } from 'react'; -import { SelectedEntityContext } from '../SelectedEntityContext'; +import { SelectedEntityContext } from './SelectedEntityContext'; type Props = { entity: SchemaTypes.ItemType | SchemaTypes.Plugin; @@ -15,6 +15,7 @@ type Props = { hasConflict?: boolean; title: ReactNode; children: ReactNode; + className?: string; }; /** @@ -26,6 +27,7 @@ export default function Collapsible({ hasConflict = false, title, children, + className, }: Props) { const elRef = useRef(null); @@ -53,6 +55,7 @@ export default function Collapsible({ isSelected && 'conflict--selected', invalid && 'conflict--invalid', hasConflict && 'conflict--has-conflict', + className, )} ref={elRef} > diff --git a/import-export-schema/src/entrypoints/ImportPage/SelectedEntityContext.tsx b/import-export-schema/src/components/SchemaOverview/SelectedEntityContext.tsx similarity index 100% rename from import-export-schema/src/entrypoints/ImportPage/SelectedEntityContext.tsx rename to import-export-schema/src/components/SchemaOverview/SelectedEntityContext.tsx diff --git a/import-export-schema/src/entrypoints/ExportPage/ExportItemTypeNodeRenderer.tsx b/import-export-schema/src/entrypoints/ExportPage/ExportItemTypeNodeRenderer.tsx index ef4e49ae..800da24d 100644 --- a/import-export-schema/src/entrypoints/ExportPage/ExportItemTypeNodeRenderer.tsx +++ b/import-export-schema/src/entrypoints/ExportPage/ExportItemTypeNodeRenderer.tsx @@ -1,9 +1,11 @@ import type { NodeProps } from '@xyflow/react'; +import classNames from 'classnames'; import { useContext } from 'react'; import { type ItemTypeNode, ItemTypeNodeRenderer, } from '@/components/ItemTypeNodeRenderer'; +import { SelectedEntityContext } from '@/components/SchemaOverview/SelectedEntityContext'; import { EntitiesToExportContext } from '@/entrypoints/ExportPage/EntitiesToExportContext'; /** @@ -12,16 +14,21 @@ import { EntitiesToExportContext } from '@/entrypoints/ExportPage/EntitiesToExpo export function ExportItemTypeNodeRenderer(props: NodeProps) { const { itemType } = props.data; const entitiesToExport = useContext(EntitiesToExportContext); + const selectedEntityContext = useContext(SelectedEntityContext); const excluded = entitiesToExport && !entitiesToExport.itemTypeIds.includes(itemType.id); + const isFocused = selectedEntityContext.entity === itemType; return ( ); } diff --git a/import-export-schema/src/entrypoints/ExportPage/ExportPluginNodeRenderer.tsx b/import-export-schema/src/entrypoints/ExportPage/ExportPluginNodeRenderer.tsx index c645560d..86132876 100644 --- a/import-export-schema/src/entrypoints/ExportPage/ExportPluginNodeRenderer.tsx +++ b/import-export-schema/src/entrypoints/ExportPage/ExportPluginNodeRenderer.tsx @@ -5,6 +5,7 @@ import { type PluginNode, PluginNodeRenderer, } from '@/components/PluginNodeRenderer'; +import { SelectedEntityContext } from '@/components/SchemaOverview/SelectedEntityContext'; import { EntitiesToExportContext } from '@/entrypoints/ExportPage/EntitiesToExportContext'; /** @@ -14,6 +15,7 @@ export function ExportPluginNodeRenderer(props: NodeProps) { const { plugin } = props.data; const entitiesToExport = useContext(EntitiesToExportContext); + const selectedEntityContext = useContext(SelectedEntityContext); return ( ) { entitiesToExport && !entitiesToExport.pluginIds.includes(plugin.id) && 'app-node--excluded', + selectedEntityContext.entity === plugin && 'app-node--focused', )} /> ); diff --git a/import-export-schema/src/entrypoints/ExportPage/ExportSchemaOverview.tsx b/import-export-schema/src/entrypoints/ExportPage/ExportSchemaOverview.tsx new file mode 100644 index 00000000..7b3cece3 --- /dev/null +++ b/import-export-schema/src/entrypoints/ExportPage/ExportSchemaOverview.tsx @@ -0,0 +1,345 @@ +import type { SchemaTypes } from '@datocms/cma-client'; +import { Spinner } from 'datocms-react-ui'; +import { useMemo } from 'react'; +import Collapsible from '@/components/SchemaOverview/Collapsible'; +import type { ItemTypeNode } from '@/components/ItemTypeNodeRenderer'; +import type { PluginNode } from '@/components/PluginNodeRenderer'; +import { getTextWithoutRepresentativeEmojiAndPadding } from '@/utils/emojiAgnosticSorter'; +import type { Graph } from '@/utils/graph/types'; + +const localeAwareCollator = new Intl.Collator(undefined, { + sensitivity: 'base', + numeric: true, +}); + +type ItemTypeEntry = { + itemType: SchemaTypes.ItemType; + selected: boolean; +}; + +type PluginEntry = { + plugin: SchemaTypes.Plugin; + selected: boolean; +}; + +type ItemTypeBuckets = { + selected: ItemTypeEntry[]; + unselected: ItemTypeEntry[]; +}; + +type GroupedItemTypes = { + models: ItemTypeBuckets; + blocks: ItemTypeBuckets; +}; + +type PluginBuckets = { + selected: PluginEntry[]; + unselected: PluginEntry[]; +}; + +type Props = { + graph?: Graph; + selectedItemTypeIds: string[]; + selectedPluginIds: string[]; +}; + +function sortEntriesByDisplayName(entries: T[], getName: (entry: T) => string) { + return [...entries].sort((a, b) => + localeAwareCollator.compare(getName(a), getName(b)), + ); +} + +function isItemTypeNode(node: Graph['nodes'][number]): node is ItemTypeNode { + return node.type === 'itemType'; +} + +function isPluginNode(node: Graph['nodes'][number]): node is PluginNode { + return node.type === 'plugin'; +} + +function renderItemTypeEntry(entry: ItemTypeEntry) { + const { + itemType: { + attributes: { name, modular_block: isBlock }, + }, + } = entry; + + const className = entry.selected + ? 'schema-overview__item schema-overview__item--selected' + : 'schema-overview__item schema-overview__item--unselected'; + + return ( + +

    + This {isBlock ? 'block model' : 'model'} is currently{' '} + {entry.selected ? 'selected for export.' : 'not part of this export.'} +

    +

    + {entry.selected + ? 'The exported schema JSON will include this ' + : 'Select it '} + {entry.selected ? '.' : ' from the graph to include it.'} +

    +
    + ); +} + +function renderPluginEntry(entry: PluginEntry) { + const { + plugin: { + attributes: { name }, + }, + } = entry; + + const className = entry.selected + ? 'schema-overview__item schema-overview__item--selected' + : 'schema-overview__item schema-overview__item--unselected'; + + return ( + +

    + {name}{' '} + is {entry.selected ? 'selected for export.' : 'not selected yet.'} +

    +

    + {entry.selected + ? 'The exported recipe will include this plugin entry.' + : 'Choose this plugin in the dependency graph if it should be exported.'} +

    +
    + ); +} + +function renderItemTypeGroup( + title: string, + entries: ItemTypeEntry[], + keyPrefix: string, +) { + if (entries.length === 0) { + return null; + } + + return ( +
    +
    + {title} ({entries.length}) +
    +
    + {entries.map((entry) => renderItemTypeEntry(entry))} +
    +
    + ); +} + +function renderPluginGroup( + title: string, + entries: PluginEntry[], + keyPrefix: string, +) { + if (entries.length === 0) { + return null; + } + + return ( +
    +
    + {title} ({entries.length}) +
    +
    + {entries.map((entry) => renderPluginEntry(entry))} +
    +
    + ); +} + +function SchemaOverviewCategory({ + title, + groups, +}: { + title: string; + groups: Array; +}) { + const filteredGroups = groups.filter( + (group): group is JSX.Element => Boolean(group), + ); + if (filteredGroups.length === 0) { + return null; + } + + return ( +
    +
    {title}
    + {filteredGroups} +
    + ); +} + +export function ExportSchemaOverview({ + graph, + selectedItemTypeIds, + selectedPluginIds, +}: Props) { + const groupedItemTypes = useMemo(() => { + const empty: GroupedItemTypes = { + models: { selected: [], unselected: [] }, + blocks: { selected: [], unselected: [] }, + }; + + if (!graph) { + return empty; + } + + const entries = graph.nodes.filter(isItemTypeNode).map((node) => ({ + itemType: node.data.itemType, + selected: selectedItemTypeIds.includes(node.data.itemType.id), + })); + + for (const entry of entries) { + // TypeScript work-around to keep strong typing during population. + const bucketRef = entry.itemType.attributes.modular_block + ? empty.blocks + : empty.models; + + if (entry.selected) { + bucketRef.selected.push(entry); + } else { + bucketRef.unselected.push(entry); + } + } + + const sortItemTypes = (items: ItemTypeEntry[]) => + sortEntriesByDisplayName(items, (entry) => + getTextWithoutRepresentativeEmojiAndPadding( + entry.itemType.attributes.name, + ), + ); + + return { + models: { + selected: sortItemTypes(empty.models.selected), + unselected: sortItemTypes(empty.models.unselected), + }, + blocks: { + selected: sortItemTypes(empty.blocks.selected), + unselected: sortItemTypes(empty.blocks.unselected), + }, + }; + }, [graph, selectedItemTypeIds]); + + const pluginBuckets = useMemo(() => { + const empty: PluginBuckets = { selected: [], unselected: [] }; + + if (!graph) { + return empty; + } + + for (const node of graph.nodes.filter(isPluginNode)) { + const entry: PluginEntry = { + plugin: node.data.plugin, + selected: selectedPluginIds.includes(node.data.plugin.id), + }; + + if (entry.selected) { + empty.selected.push(entry); + } else { + empty.unselected.push(entry); + } + } + + const sortPlugins = (items: PluginEntry[]) => + sortEntriesByDisplayName(items, (entry) => + getTextWithoutRepresentativeEmojiAndPadding( + entry.plugin.attributes.name, + ), + ); + + return { + selected: sortPlugins(empty.selected), + unselected: sortPlugins(empty.unselected), + }; + }, [graph, selectedPluginIds]); + + const selectedCount = + groupedItemTypes.models.selected.length + + groupedItemTypes.blocks.selected.length + + pluginBuckets.selected.length; + const unselectedCount = + groupedItemTypes.models.unselected.length + + groupedItemTypes.blocks.unselected.length + + pluginBuckets.unselected.length; + + if (!graph) { + return ( +
    +
    +
    Schema overview
    +
    +
    +
    + +
    +
    +
    + ); + } + + const selectedGroups = [ + renderItemTypeGroup('Models', groupedItemTypes.models.selected, 'selected-models'), + renderItemTypeGroup( + 'Block models', + groupedItemTypes.blocks.selected, + 'selected-blocks', + ), + renderPluginGroup('Plugins', pluginBuckets.selected, 'selected-plugins'), + ]; + + const unselectedGroups = [ + renderItemTypeGroup( + 'Models', + groupedItemTypes.models.unselected, + 'unselected-models', + ), + renderItemTypeGroup( + 'Block models', + groupedItemTypes.blocks.unselected, + 'unselected-blocks', + ), + renderPluginGroup('Plugins', pluginBuckets.unselected, 'unselected-plugins'), + ]; + + return ( +
    +
    +
    Schema overview
    +
    + {selectedCount} selected + + {unselectedCount} not selected +
    +
    +
    + {selectedCount === 0 && unselectedCount === 0 ? ( +
    +

    + Nothing to show yet — pick a model or plugin to build the export + graph. +

    +
    + ) : null} + + + +
    +
    + ); +} diff --git a/import-export-schema/src/entrypoints/ExportPage/ExportToolbar.tsx b/import-export-schema/src/entrypoints/ExportPage/ExportToolbar.tsx index f364b734..93e1ad82 100644 --- a/import-export-schema/src/entrypoints/ExportPage/ExportToolbar.tsx +++ b/import-export-schema/src/entrypoints/ExportPage/ExportToolbar.tsx @@ -1,20 +1,14 @@ import type { SchemaTypes } from '@datocms/cma-client'; -import { faXmark } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import type { RenderPageCtx } from 'datocms-plugin-sdk'; -import { Button } from 'datocms-react-ui'; import styles from './ExportToolbar.module.css'; type Props = { - ctx: RenderPageCtx; initialItemTypes: SchemaTypes.ItemType[]; - onClose?: () => void; }; /** * Header bar for the export flow, displaying the active title and close action. */ -export function ExportToolbar({ ctx, initialItemTypes, onClose }: Props) { +export function ExportToolbar({ initialItemTypes }: Props) { const title = initialItemTypes.length === 1 ? `Export ${initialItemTypes[0].attributes.name}` @@ -24,21 +18,6 @@ export function ExportToolbar({ ctx, initialItemTypes, onClose }: Props) {
    {title}
    -
    ); } diff --git a/import-export-schema/src/entrypoints/ExportPage/Inner.tsx b/import-export-schema/src/entrypoints/ExportPage/Inner.tsx index df01e271..285ddcab 100644 --- a/import-export-schema/src/entrypoints/ExportPage/Inner.tsx +++ b/import-export-schema/src/entrypoints/ExportPage/Inner.tsx @@ -1,5 +1,7 @@ import type { SchemaTypes } from '@datocms/cma-client'; -import type { NodeMouseHandler, NodeTypes } from '@xyflow/react'; +import { useReactFlow, type NodeMouseHandler, type NodeTypes } from '@xyflow/react'; +import { faXmark } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import type { ProjectSchema } from '@/utils/ProjectSchema'; import '@xyflow/react/dist/style.css'; import type { RenderPageCtx } from 'datocms-plugin-sdk'; @@ -11,10 +13,12 @@ import { GRAPH_NODE_THRESHOLD } from '@/shared/constants/graph'; import { debugLog } from '@/utils/debug'; import { expandSelectionWithDependencies } from '@/utils/graph/dependencies'; import { type AppNode, edgeTypes, type Graph } from '@/utils/graph/types'; +import { SelectedEntityContext } from '@/components/SchemaOverview/SelectedEntityContext'; import { DependencyActionsPanel } from './DependencyActionsPanel'; import { EntitiesToExportContext } from './EntitiesToExportContext'; import { ExportItemTypeNodeRenderer } from './ExportItemTypeNodeRenderer'; import { ExportPluginNodeRenderer } from './ExportPluginNodeRenderer'; +import { ExportSchemaOverview } from './ExportSchemaOverview'; import { ExportToolbar } from './ExportToolbar'; import LargeSelectionView from './LargeSelectionView'; import { useAnimatedNodes } from './useAnimatedNodes'; @@ -57,6 +61,7 @@ export default function Inner({ onSelectingDependenciesChange, }: Props) { const ctx = useCtx(); + const { fitBounds, fitView } = useReactFlow(); // Track the current selection while ensuring initial models stay checked. const [selectedItemTypeIds, setSelectedItemTypeIds] = useState( @@ -69,6 +74,9 @@ export default function Inner({ itemTypeIds: Set; pluginIds: Set; }>({ itemTypeIds: new Set(), pluginIds: new Set() }); + const [focusedEntity, setFocusedEntity] = useState< + SchemaTypes.ItemType | SchemaTypes.Plugin | undefined + >(undefined); const { graph, error, refresh } = useExportGraph({ initialItemTypes, @@ -92,6 +100,54 @@ export default function Inner({ return discovered.size > 0 ? discovered : undefined; }, [installedPluginIds, graph]); + const handleClose = useCallback(() => { + if (onClose) { + onClose(); + return; + } + ctx.navigateTo( + `${ctx.isEnvironmentPrimary ? '' : `/environments/${ctx.environment}`}/configuration/p/${ctx.plugin.id}/pages/export`, + ); + }, [ctx, onClose]); + + const handleSelectEntity = useCallback( + ( + newEntity: SchemaTypes.ItemType | SchemaTypes.Plugin | undefined, + zoomIn = false, + ) => { + setFocusedEntity(newEntity); + + if (!zoomIn) { + return; + } + + if (!graph) { + return; + } + + if (!newEntity) { + fitView({ duration: 800 }); + return; + } + + const node = graph.nodes.find((node) => + newEntity.type === 'plugin' + ? node.type === 'plugin' && node.data.plugin.id === newEntity.id + : node.type === 'itemType' && node.data.itemType.id === newEntity.id, + ); + + if (!node) { + return; + } + + fitBounds( + { x: node.position.x, y: node.position.y, width: 200, height: 200 }, + { duration: 800, padding: 1 }, + ); + }, + [fitBounds, fitView, graph], + ); + // Overlay is controlled by parent; we signal prepared after each build // Keep selection in sync if the parent changes the initial set of item types @@ -117,6 +173,7 @@ export default function Inner({ const onNodeClick: NodeMouseHandler = useCallback( (_, node) => { if (node.type === 'itemType') { + setFocusedEntity(node.data.itemType); if (initialItemTypes.some((it) => `itemType--${it.id}` === node.id)) { return; } @@ -129,6 +186,7 @@ export default function Inner({ } if (node.type === 'plugin') { + setFocusedEntity(node.data.plugin); setSelectedPluginIds((old) => old.includes(node.data.plugin.id) ? without(old, node.data.plugin.id) @@ -270,102 +328,150 @@ export default function Inner({ return (
    - +
    -
    - {!graph && !error ? ( - - ) : error ? ( -
    -
    Could not load export graph
    + +
    + {!graph && !error ? ( + + ) : error ? (
    - {(() => { - const anyErr = error as unknown as { - response?: { status?: number }; - }; - const status = anyErr?.response?.status; - if (status === 429) { - return "You're being rate-limited by the API (429). Please wait a few seconds and try again."; - } - if (status === 401 || status === 403) { - return 'You do not have permission to load the project schema. Please check your credentials and try again.'; - } - if (status && status >= 500) { - return 'The API is temporarily unavailable. Please try again shortly.'; - } - return 'An unexpected error occurred while preparing the export. Please try again.'; - })()} +
    Could not load export graph
    +
    + {(() => { + const anyErr = error as unknown as { + response?: { status?: number }; + }; + const status = anyErr?.response?.status; + if (status === 429) { + return "You're being rate-limited by the API (429). Please wait a few seconds and try again."; + } + if (status === 401 || status === 403) { + return 'You do not have permission to load the project schema. Please check your credentials and try again.'; + } + if (status && status >= 500) { + return 'The API is temporarily unavailable. Please try again shortly.'; + } + return 'An unexpected error occurred while preparing the export. Please try again.'; + })()} +
    +
    - -
    - ) : ( - - {showGraph ? ( - <> - - - onExport(selectedItemTypeIds, selectedPluginIds) - } - /> - - ) : ( - - )} - - )} -
    +
    +
    +
    + +
    + + {showGraph ? ( + <> + + + onExport(selectedItemTypeIds, selectedPluginIds) + } + /> + + ) : ( + + )} + +
    +
    +
    +
    + +
    +
    +
    + )} +
    +
    ); diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx index 7fb62756..fa743be4 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx @@ -4,7 +4,7 @@ import { SelectField, TextField } from 'datocms-react-ui'; import { useId } from 'react'; import { Field } from 'react-final-form'; import { useResolutionStatusForItemType } from '../ResolutionsForm'; -import Collapsible from './Collapsible'; +import Collapsible from '@/components/SchemaOverview/Collapsible'; type Option = { label: string; value: string }; type SelectGroup = { diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx index d5f45939..1d6e9581 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx @@ -4,7 +4,7 @@ import { SelectField } from 'datocms-react-ui'; import { useId } from 'react'; import { Field } from 'react-final-form'; import { useResolutionStatusForPlugin } from '../ResolutionsForm'; -import Collapsible from './Collapsible'; +import Collapsible from '@/components/SchemaOverview/Collapsible'; type Option = { label: string; value: string }; type SelectGroup = { diff --git a/import-export-schema/src/entrypoints/ImportPage/ImportItemTypeNodeRenderer.tsx b/import-export-schema/src/entrypoints/ImportPage/ImportItemTypeNodeRenderer.tsx index 373c3441..307f4258 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ImportItemTypeNodeRenderer.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ImportItemTypeNodeRenderer.tsx @@ -7,7 +7,7 @@ import { } from '@/components/ItemTypeNodeRenderer'; import { ConflictsContext } from '@/entrypoints/ImportPage/ConflictsManager/ConflictsContext'; import { useResolutionStatusForItemType } from '@/entrypoints/ImportPage/ResolutionsForm'; -import { SelectedEntityContext } from '@/entrypoints/ImportPage/SelectedEntityContext'; +import { SelectedEntityContext } from '@/components/SchemaOverview/SelectedEntityContext'; /** * Renders import graph item-type nodes, overlaying conflict and resolution state styling. diff --git a/import-export-schema/src/entrypoints/ImportPage/ImportPluginNodeRenderer.tsx b/import-export-schema/src/entrypoints/ImportPage/ImportPluginNodeRenderer.tsx index d1c9d03c..64fcee88 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ImportPluginNodeRenderer.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ImportPluginNodeRenderer.tsx @@ -6,7 +6,7 @@ import { PluginNodeRenderer, } from '@/components/PluginNodeRenderer'; import { ConflictsContext } from '@/entrypoints/ImportPage/ConflictsManager/ConflictsContext'; -import { SelectedEntityContext } from '@/entrypoints/ImportPage/SelectedEntityContext'; +import { SelectedEntityContext } from '@/components/SchemaOverview/SelectedEntityContext'; import { useResolutionStatusForPlugin } from './ResolutionsForm'; export function ImportPluginNodeRenderer(props: NodeProps) { diff --git a/import-export-schema/src/entrypoints/ImportPage/Inner.tsx b/import-export-schema/src/entrypoints/ImportPage/Inner.tsx index 6906f4cc..69038b69 100644 --- a/import-export-schema/src/entrypoints/ImportPage/Inner.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/Inner.tsx @@ -17,7 +17,7 @@ import { ImportItemTypeNodeRenderer } from './ImportItemTypeNodeRenderer'; import { ImportPluginNodeRenderer } from './ImportPluginNodeRenderer'; import LargeSelectionView from './LargeSelectionView'; import { useSkippedItemsAndPluginIds } from './ResolutionsForm'; -import { SelectedEntityContext } from './SelectedEntityContext'; +import { SelectedEntityContext } from '@/components/SchemaOverview/SelectedEntityContext'; // Map React Flow node types to the dedicated renderers for import graphs. const nodeTypes: NodeTypes = { diff --git a/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.tsx b/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.tsx index 80da741d..01878617 100644 --- a/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.tsx @@ -4,7 +4,7 @@ import { useContext } from 'react'; import { findInboundEdges, findOutboundEdges } from '@/utils/graph/analysis'; import { LargeSelectionLayout } from '@/components/LargeSelectionLayout'; import type { Graph } from '@/utils/graph/types'; -import { SelectedEntityContext } from './SelectedEntityContext'; +import { SelectedEntityContext } from '@/components/SchemaOverview/SelectedEntityContext'; import styles from './LargeSelectionView.module.css'; type Props = { diff --git a/import-export-schema/src/index.css b/import-export-schema/src/index.css index adf67f84..cfc50634 100644 --- a/import-export-schema/src/index.css +++ b/import-export-schema/src/index.css @@ -969,7 +969,9 @@ button.chip:focus-visible { .import__graph, -.import__details { +.import__details, +.export__graph, +.export__details { position: absolute; top: 0; left: 0; @@ -978,13 +980,21 @@ button.chip:focus-visible { overflow: auto; } -.import__details { +.import__details, +.export__details { border-left: 1px solid var(--border-color); box-sizing: border-box; background: #fff; /* Slightly tighter padding for redesigned conflicts UI */ } +.export__graph-close { + position: absolute; + top: 16px; + right: 16px; + z-index: 5; +} + .conflict { border-bottom: 1px solid var(--border-color); @@ -1069,6 +1079,103 @@ button.chip:focus-visible { padding: 12px 16px; /* reduce inner padding for a tighter look */ } +.schema-overview__summary { + margin-top: 10px; + font-size: 12px; + color: var(--light-body-color); + display: inline-flex; + align-items: center; + gap: 6px; +} + +.schema-overview__title { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.schema-overview__title__name { + font-weight: inherit; +} + +.schema-overview__title__meta { + color: var(--light-body-color); + font-size: 12px; +} + +.schema-overview__status { + font-size: 11px; + font-weight: 600; + border-radius: 999px; + padding: 2px 8px; + text-transform: none; + letter-spacing: 0.01em; +} + +.schema-overview__status--selected { + color: #0f5132; + background-color: rgba(25, 135, 84, 0.18); +} + +.schema-overview__status--unselected { + color: #4f4f4f; + background-color: rgba(79, 79, 79, 0.12); +} + +.schema-overview__category { + margin: 24px 0; +} + +.schema-overview__category__title { + font-weight: 700; + font-size: var(--font-size-m); + padding: 0 var(--spacing-l); + color: var(--base-body-color); + margin-bottom: 6px; +} + +.schema-overview__item { + border: 1px solid var(--border-color); + border-radius: 10px; + margin: 6px var(--spacing-l); + background: #fff; + transition: border-color 0.2s ease, background-color 0.2s ease; +} + +.schema-overview__item.conflict { + border-bottom: none; + border-top: none; +} + +.schema-overview__item.conflict:first-child { + border-top: none; +} + +.schema-overview__item.conflict + .conflict { + border-top: none; +} + +.schema-overview__item .conflict__title { + padding: 12px 14px; +} + +.schema-overview__item .conflict__content { + padding: 12px 18px 16px; +} + +.schema-overview__item--selected { + background: rgba(25, 135, 84, 0.08); + border-color: rgba(25, 135, 84, 0.35); +} + +.schema-overview__item--unselected { + background: #fff; +} + +.schema-overview__item--unselected .conflict__title { + color: var(--base-body-color); +} + /* Pretty export overlay styles */ .export-overlay__card { background: #fff; diff --git a/import-export-schema/tsconfig.app.tsbuildinfo b/import-export-schema/tsconfig.app.tsbuildinfo index 268e53b2..744f3037 100644 --- a/import-export-schema/tsconfig.app.tsbuildinfo +++ b/import-export-schema/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/blankslate.tsx","./src/components/exportlandingpanel.tsx","./src/components/exportselectionpanel.tsx","./src/components/field.tsx","./src/components/fieldedgerenderer.tsx","./src/components/graphcanvas.tsx","./src/components/itemtypenoderenderer.tsx","./src/components/largeselectionlayout.tsx","./src/components/pluginnoderenderer.tsx","./src/components/progressoverlay.tsx","./src/components/progressstallnotice.tsx","./src/components/taskoverlaystack.tsx","./src/components/taskprogressoverlay.tsx","./src/components/bezier.ts","./src/entrypoints/config/index.tsx","./src/entrypoints/exporthome/index.tsx","./src/entrypoints/exportpage/dependencyactionspanel.tsx","./src/entrypoints/exportpage/entitiestoexportcontext.ts","./src/entrypoints/exportpage/exportitemtypenoderenderer.tsx","./src/entrypoints/exportpage/exportpluginnoderenderer.tsx","./src/entrypoints/exportpage/exportschema.ts","./src/entrypoints/exportpage/exporttoolbar.tsx","./src/entrypoints/exportpage/inner.tsx","./src/entrypoints/exportpage/largeselectionview.tsx","./src/entrypoints/exportpage/buildexportdoc.ts","./src/entrypoints/exportpage/buildgraphfromschema.ts","./src/entrypoints/exportpage/index.tsx","./src/entrypoints/exportpage/useanimatednodes.tsx","./src/entrypoints/exportpage/useexportgraph.ts","./src/entrypoints/importpage/exportworkflow.tsx","./src/entrypoints/importpage/filedropzone.tsx","./src/entrypoints/importpage/importitemtypenoderenderer.tsx","./src/entrypoints/importpage/importpluginnoderenderer.tsx","./src/entrypoints/importpage/importworkflow.tsx","./src/entrypoints/importpage/inner.tsx","./src/entrypoints/importpage/largeselectionview.tsx","./src/entrypoints/importpage/resolutionsform.tsx","./src/entrypoints/importpage/selectedentitycontext.tsx","./src/entrypoints/importpage/buildgraphfromexportdoc.ts","./src/entrypoints/importpage/buildimportdoc.ts","./src/entrypoints/importpage/importschema.ts","./src/entrypoints/importpage/index.tsx","./src/entrypoints/importpage/userecipeloader.ts","./src/entrypoints/importpage/conflictsmanager/collapsible.tsx","./src/entrypoints/importpage/conflictsmanager/conflictscontext.ts","./src/entrypoints/importpage/conflictsmanager/itemtypeconflict.tsx","./src/entrypoints/importpage/conflictsmanager/pluginconflict.tsx","./src/entrypoints/importpage/conflictsmanager/buildconflicts.ts","./src/entrypoints/importpage/conflictsmanager/index.tsx","./src/shared/constants/graph.ts","./src/shared/hooks/usecmaclient.ts","./src/shared/hooks/useconflictsbuilder.ts","./src/shared/hooks/useexportallhandler.ts","./src/shared/hooks/useexportselection.ts","./src/shared/hooks/useprojectschema.ts","./src/shared/hooks/useschemaexporttask.ts","./src/shared/tasks/uselongtask.ts","./src/types/lodash-es.d.ts","./src/utils/projectschema.ts","./src/utils/createcmaclient.ts","./src/utils/debug.ts","./src/utils/downloadjson.ts","./src/utils/emojiagnosticsorter.ts","./src/utils/icons.tsx","./src/utils/isdefined.ts","./src/utils/render.tsx","./src/utils/types.ts","./src/utils/datocms/appearance.ts","./src/utils/datocms/fieldtypeinfo.ts","./src/utils/datocms/schema.ts","./src/utils/datocms/validators.ts","./src/utils/graph/analysis.ts","./src/utils/graph/buildgraph.ts","./src/utils/graph/buildhierarchynodes.ts","./src/utils/graph/dependencies.ts","./src/utils/graph/edges.ts","./src/utils/graph/index.ts","./src/utils/graph/nodes.ts","./src/utils/graph/rebuildgraphwithpositionsfromhierarchy.ts","./src/utils/graph/sort.ts","./src/utils/graph/types.ts","./src/utils/schema/exportschemasource.ts","./src/utils/schema/ischemasource.ts","./src/utils/schema/projectschemasource.ts"],"version":"5.7.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/blankslate.tsx","./src/components/exportlandingpanel.tsx","./src/components/exportselectionpanel.tsx","./src/components/field.tsx","./src/components/fieldedgerenderer.tsx","./src/components/graphcanvas.tsx","./src/components/itemtypenoderenderer.tsx","./src/components/largeselectionlayout.tsx","./src/components/pluginnoderenderer.tsx","./src/components/progressoverlay.tsx","./src/components/progressstallnotice.tsx","./src/components/taskoverlaystack.tsx","./src/components/taskprogressoverlay.tsx","./src/components/bezier.ts","./src/components/schemaoverview/collapsible.tsx","./src/components/schemaoverview/selectedentitycontext.tsx","./src/entrypoints/config/index.tsx","./src/entrypoints/exporthome/index.tsx","./src/entrypoints/exportpage/dependencyactionspanel.tsx","./src/entrypoints/exportpage/entitiestoexportcontext.ts","./src/entrypoints/exportpage/exportitemtypenoderenderer.tsx","./src/entrypoints/exportpage/exportpluginnoderenderer.tsx","./src/entrypoints/exportpage/exportschema.ts","./src/entrypoints/exportpage/exportschemaoverview.tsx","./src/entrypoints/exportpage/exporttoolbar.tsx","./src/entrypoints/exportpage/inner.tsx","./src/entrypoints/exportpage/largeselectionview.tsx","./src/entrypoints/exportpage/buildexportdoc.ts","./src/entrypoints/exportpage/buildgraphfromschema.ts","./src/entrypoints/exportpage/index.tsx","./src/entrypoints/exportpage/useanimatednodes.tsx","./src/entrypoints/exportpage/useexportgraph.ts","./src/entrypoints/importpage/exportworkflow.tsx","./src/entrypoints/importpage/filedropzone.tsx","./src/entrypoints/importpage/importitemtypenoderenderer.tsx","./src/entrypoints/importpage/importpluginnoderenderer.tsx","./src/entrypoints/importpage/importworkflow.tsx","./src/entrypoints/importpage/inner.tsx","./src/entrypoints/importpage/largeselectionview.tsx","./src/entrypoints/importpage/resolutionsform.tsx","./src/entrypoints/importpage/buildgraphfromexportdoc.ts","./src/entrypoints/importpage/buildimportdoc.ts","./src/entrypoints/importpage/importschema.ts","./src/entrypoints/importpage/index.tsx","./src/entrypoints/importpage/userecipeloader.ts","./src/entrypoints/importpage/conflictsmanager/conflictscontext.ts","./src/entrypoints/importpage/conflictsmanager/itemtypeconflict.tsx","./src/entrypoints/importpage/conflictsmanager/pluginconflict.tsx","./src/entrypoints/importpage/conflictsmanager/buildconflicts.ts","./src/entrypoints/importpage/conflictsmanager/index.tsx","./src/shared/constants/graph.ts","./src/shared/hooks/usecmaclient.ts","./src/shared/hooks/useconflictsbuilder.ts","./src/shared/hooks/useexportallhandler.ts","./src/shared/hooks/useexportselection.ts","./src/shared/hooks/useprojectschema.ts","./src/shared/hooks/useschemaexporttask.ts","./src/shared/tasks/uselongtask.ts","./src/types/lodash-es.d.ts","./src/utils/projectschema.ts","./src/utils/createcmaclient.ts","./src/utils/debug.ts","./src/utils/downloadjson.ts","./src/utils/emojiagnosticsorter.ts","./src/utils/icons.tsx","./src/utils/isdefined.ts","./src/utils/render.tsx","./src/utils/types.ts","./src/utils/datocms/appearance.ts","./src/utils/datocms/fieldtypeinfo.ts","./src/utils/datocms/schema.ts","./src/utils/datocms/validators.ts","./src/utils/graph/analysis.ts","./src/utils/graph/buildgraph.ts","./src/utils/graph/buildhierarchynodes.ts","./src/utils/graph/dependencies.ts","./src/utils/graph/edges.ts","./src/utils/graph/index.ts","./src/utils/graph/nodes.ts","./src/utils/graph/rebuildgraphwithpositionsfromhierarchy.ts","./src/utils/graph/sort.ts","./src/utils/graph/types.ts","./src/utils/schema/exportschemasource.ts","./src/utils/schema/ischemasource.ts","./src/utils/schema/projectschemasource.ts"],"version":"5.7.3"} \ No newline at end of file From 13d847d9dc75644bd19db68d8f967ef248eb7f10 Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Fri, 19 Sep 2025 11:45:49 +0200 Subject: [PATCH 13/36] export finished --- .../ExportPage/ExportSchemaOverview.tsx | 72 ++++++++++++++----- import-export-schema/src/index.css | 7 ++ 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/import-export-schema/src/entrypoints/ExportPage/ExportSchemaOverview.tsx b/import-export-schema/src/entrypoints/ExportPage/ExportSchemaOverview.tsx index 7b3cece3..83f8c8b9 100644 --- a/import-export-schema/src/entrypoints/ExportPage/ExportSchemaOverview.tsx +++ b/import-export-schema/src/entrypoints/ExportPage/ExportSchemaOverview.tsx @@ -1,6 +1,7 @@ import type { SchemaTypes } from '@datocms/cma-client'; -import { Spinner } from 'datocms-react-ui'; -import { useMemo } from 'react'; +import { Spinner, SwitchInput } from 'datocms-react-ui'; +import { useMemo, useState } from 'react'; +import classNames from 'classnames'; import Collapsible from '@/components/SchemaOverview/Collapsible'; import type { ItemTypeNode } from '@/components/ItemTypeNodeRenderer'; import type { PluginNode } from '@/components/PluginNodeRenderer'; @@ -165,9 +166,11 @@ function renderPluginGroup( function SchemaOverviewCategory({ title, groups, + className, }: { title: string; groups: Array; + className?: string; }) { const filteredGroups = groups.filter( (group): group is JSX.Element => Boolean(group), @@ -177,7 +180,7 @@ function SchemaOverviewCategory({ } return ( -
    +
    {title}
    {filteredGroups}
    @@ -189,6 +192,7 @@ export function ExportSchemaOverview({ selectedItemTypeIds, selectedPluginIds, }: Props) { + const [showOnlySelected, setShowOnlySelected] = useState(false); const groupedItemTypes = useMemo(() => { const empty: GroupedItemTypes = { models: { selected: [], unselected: [] }, @@ -303,19 +307,21 @@ export function ExportSchemaOverview({ renderPluginGroup('Plugins', pluginBuckets.selected, 'selected-plugins'), ]; - const unselectedGroups = [ - renderItemTypeGroup( - 'Models', - groupedItemTypes.models.unselected, - 'unselected-models', - ), - renderItemTypeGroup( - 'Block models', - groupedItemTypes.blocks.unselected, - 'unselected-blocks', - ), - renderPluginGroup('Plugins', pluginBuckets.unselected, 'unselected-plugins'), - ]; + const unselectedGroups = showOnlySelected + ? [] + : [ + renderItemTypeGroup( + 'Models', + groupedItemTypes.models.unselected, + 'unselected-models', + ), + renderItemTypeGroup( + 'Block models', + groupedItemTypes.blocks.unselected, + 'unselected-blocks', + ), + renderPluginGroup('Plugins', pluginBuckets.unselected, 'unselected-plugins'), + ]; return (
    @@ -327,6 +333,29 @@ export function ExportSchemaOverview({ {unselectedCount} not selected
    +
    + +
    {selectedCount === 0 && unselectedCount === 0 ? (
    @@ -337,8 +366,15 @@ export function ExportSchemaOverview({
    ) : null} - - + +
    ); diff --git a/import-export-schema/src/index.css b/import-export-schema/src/index.css index cfc50634..05c3d253 100644 --- a/import-export-schema/src/index.css +++ b/import-export-schema/src/index.css @@ -1124,6 +1124,13 @@ button.chip:focus-visible { .schema-overview__category { margin: 24px 0; + padding-top: 12px; +} + +.schema-overview__category--selected { + border-bottom: 1px solid var(--border-color); + padding-bottom: 16px; + margin-bottom: 24px; } .schema-overview__category__title { From def457fd73aacc296603f88e4e97ee8c2b0b8ad5 Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Fri, 19 Sep 2025 16:09:58 +0200 Subject: [PATCH 14/36] change --- import-export-schema/README.md | 91 +++--- .../docs/refactor-baseline.md | 61 ---- .../src/entrypoints/Config/index.tsx | 7 +- .../src/entrypoints/ExportHome/index.tsx | 289 +++++++++++------- import-export-schema/src/main.tsx | 18 +- 5 files changed, 249 insertions(+), 217 deletions(-) delete mode 100644 import-export-schema/docs/refactor-baseline.md diff --git a/import-export-schema/README.md b/import-export-schema/README.md index b8abe82a..725c4fb7 100644 --- a/import-export-schema/README.md +++ b/import-export-schema/README.md @@ -2,20 +2,20 @@ Powerful, safe schema migration for DatoCMS. Export models/blocks and plugins as JSON, then import them into another project with guided conflict resolution. -## Highlights +## What it does -- Export from anywhere: start from a single model’s “Export as JSON…” action, select multiple models/blocks, or export the entire schema. -- Dependency-aware: auto-detects linked models/blocks and plugins; add them with one click (“Select all dependencies”). -- Scales to large projects: graph preview for small selections, fast list view with search and relation counts for big ones. -- Guided imports: detect conflicts, choose to reuse or rename, and confirm sensitive actions with typed confirmation. -- Post-action summaries: clear, filterable summaries after export/import with connections and plugin usage. -- Safe by design: imports are additive; existing models/blocks and plugins are never modified unless you explicitly opt to reuse. +- Builds dependency-aware exports: collects selected models, blocks, fieldsets, fields, and any referenced plugins while trimming validators that point to out-of-scope item types. +- Keeps exports portable: rewrites field appearances to rely only on bundled or built-in editors and downloads a prettified `export.json` when each task completes. +- Imports additively: compares the bundle against the target project, walks you through reuse/rename/skip decisions, and creates new entities without mutating existing ones unless you opt in. +- Restores editors safely: reinstalls plugin editors/addons when present, falling back to core editors so fields always remain valid in the destination project. +- Handles long tasks gracefully: surfaces cancellable overlays with stall notices, honors rate-limit throttling, and stops at the next safe checkpoint if you cancel mid-flight. +- Loads shared recipes: accepts `recipe_url` query parameters to pull an export from a URL so collaborators can hand off ready-to-import snapshots. ## Where To Find It -- Settings > Plugins > Schema Import/Export > Export: start a new export, select multiple models/blocks, or export the entire current environment. -- Settings > Plugins > Schema Import/Export > Import: upload an export file (or paste a recipe URL) and import safely into the current environment. -- From a model/block: in Schema, open a model/block, click the three dots beside the model/block name, and pick “Export as JSON…” to export starting from that entity. +- Configuration > Export: start a new export, select multiple models/blocks, or export the entire current environment. +- Configuration > Import: upload an export file (or paste a recipe URL) and import safely into the current environment. +- From a model/block: in Schema, open a model/block, click the three dots beside the model/block name, and pick “Export as JSON…”—the plugin opens the Export page preloaded with that entity so you can jump straight to the graph. ## Installation @@ -25,65 +25,78 @@ Powerful, safe schema migration for DatoCMS. Export models/blocks and plugins as - Start from a model/block - Open Schema, select a model/block. - - Click the three dots beside the model/block name. - - Choose “Export as JSON…”. - - Preview dependencies and optionally include related models/blocks and plugins. - - Download the generated `export.json`. + - Click the three dots beside the model/block name and choose “Export as JSON…”. + - The Export page opens with that entity locked in the selection; inspect the graph/list, run “Select all dependencies” to pull in linked models/blocks/plugins, and undo it with “Unselect dependencies” if you change your mind. + - Start the export when ready; the long-task overlay shows progress/cancel options, and the prettified `export.json` downloads automatically once the task completes. - Start a new export (Schema > Export) - - Pick one or more starting models/blocks, then refine the selection. - - Use “Select all dependencies” to include all linked models/blocks and any used plugins. - - Search and filter in list view; see inbound/outbound relation counts and “Why included?” explanations. -- For large projects the graph is replaced with a fast list view. + - The landing panel lets you either pick specific starting models/blocks or go straight to “Export entire schema”. + - Use the multi-select to seed the graph; the graph animates selections for small/medium schemas, while large schemas (>60 nodes) fall back to the list view with search, metrics (counts, components, cycles), and “Why included?” explanations. + - “Select all dependencies” adds related models/blocks/plugins in bulk and logs a notice showing how many were added; “Unselect dependencies” removes the auto-added ones. + - Plugin dependencies come from installed plugin lookups; if the CMA call fails you’ll see a warning so you know selections may be incomplete. - Export the entire schema (one click) - - From Schema > Export, choose “Export entire schema” to include all models/blocks and plugins. - - A progress overlay appears with a cancel button and a stall notice if rate limited; the JSON is downloaded when done. + - Confirm the dialog to queue up every model, block, and plugin in the current environment. + - The overlay reports progress, handles cancellable requests, and falls back gracefully if the CMA throttles; the final file downloads as `export.json`. - After export - - A Post‑export summary shows counts (models, blocks, fields, fieldsets, plugins) and, for each model/block, the number of linked models/blocks and used plugins. - - You can re-download the JSON and close back to the export screen. + - You get a success notice (or cancellation/error messaging) plus the downloaded file; the selection stays in place so you can tweak it and run another export without leaving the page. ## Import - Start an import (Schema > Import) - - Drag and drop an exported JSON file, or provide a recipe URL via `?recipe_url=https://…` (optional `recipe_title=…`). - - The plugin prepares a conflicts view by comparing the file against your project’s schema. + - Drag and drop an exported JSON file or use the “Select a JSON export file…” button; invalid JSON triggers an alert so you can retry. + - To hydrate directly from a shared recipe, append `?recipe_url=https://…` (optional `recipe_title=…`)—the plugin fetches it and switches to import mode automatically. - Resolve conflicts safely - - For models/blocks: choose “Reuse existing” or “Rename” (typed confirmation required if you select any renames). + - The plugin builds a conflict summary in the background with progress feedback; you can refresh it if the schema changes while you wait. + - For models/blocks: choose “Reuse existing” or “Rename” with inline validation for name/API key (preset suggestions are provided for fast renames). - For plugins: choose “Reuse existing” or “Skip”. - - The graph switches to a searchable list for large selections; click “Open details” to focus an entity. + - Use “Show only unresolved conflicts” to focus the list; entities marked “reuse” drop out of the graph/list so you stay focused on what will be created. + - The import graph mirrors the export graph for smaller selections and switches to the list view with metrics/search once the node count crosses the same 60-node threshold. - Run the import - - The operation is additive: new models/blocks/plugins/fields/fieldsets are created; existing ones are never changed unless “reuse” is chosen. - - Field appearances are reconstructed safely: built‑in editors are preserved; external plugin editors/addons are mapped when included, otherwise sensible defaults are applied. - - A progress overlay (with cancel) shows what’s happening and warns if progress stalls due to API rate limits. + - Imports are additive: new models/blocks/fields/fieldsets/plugins are created with fresh IDs, and existing assets are touched only when you explicitly reuse them. + - Field validators and appearances are remapped to the target project; missing plugin editors fall back to safe defaults and localized defaults expand to every locale in the target environment. + - The progress overlay includes a cancel affordance with the required warning dialog; if you cancel, the task stops at the next safe checkpoint. - After import - - A Post‑import summary shows what was created, what was reused/skipped, any renames applied, and the connections to other models/blocks and plugins. + - Successful runs raise a notice and clear the loaded export; cancellations leave the file in place so you can try again, and failures keep the conflict form available for fixes. ## Notes & Limits -- Plugin detection: editor/addon plugins used by fields are included when “Select all dependencies” is used. If the list of installed plugins cannot be fetched, the UI warns and detection may be incomplete. +- Plugin detection: editor/addon plugins used by fields are included when “Select all dependencies” is used. If the installed plugin list cannot be fetched you’ll see a one-time banner (per session) so you know detection may be incomplete. +- Graph threshold: when the graph would exceed ~60 nodes the UI switches to the large-selection layout with search, metrics (counts/components/cycles), and “Why included?” reasoning instead of rendering an unreadable canvas. +- Rate limiting & throttling: long operations show a stall notice if progress pauses, and `ProjectSchema` throttles CMA calls by default (override with something like `localStorage.setItem('schemaThrottleMax', '8')`; valid values are 1–15 for local debugging). - Appearance portability: if an editor plugin is not selected, that field falls back to a valid built‑in editor; addons are included only if selected or already installed. -- Rate limiting: long operations show a gentle notice if progress stalls; they usually resume automatically. You can cancel exports/imports at any time. +- Debug logging: run `localStorage.setItem('schemaDebug', '1')` in the iframe console to enable detailed `debugLog` output. ## Development Notes +- Entry points: + - `src/main.tsx` registers plugin pages, schema dropdown shortcuts, and preserves environment-prefixed routing when navigating between Import/Export. + - `src/entrypoints/Config` uses `ctx.navigateTo` so config links jump directly to Schema, Import, or Export without reloading the iframe. - Shared hooks: - - `useProjectSchema` memoizes CMA access per context. - - `useLongTask` drives all long-running progress overlays. - - `useExportGraph`, `useExportAllHandler`, and `useConflictsBuilder` encapsulate schema loading logic. + - `useProjectSchema` memoizes the CMA client, caches item types/plugins/fields, and honors the `schemaThrottleMax` localStorage override. + - `useLongTask` tracks cancellable progress state shared by exports, imports, and conflict analysis. + - `useExportSelection` hydrates item types once and keeps the selection stable when the import page toggles between modes. + - `useExportGraph` assembles the React Flow graph/list data and streams progress updates for the preparation overlay. + - `useExportAllHandler` and `useSchemaExportTask` wrap `buildExportDoc`, download handling, progress overlays, and cancellation. + - `useConflictsBuilder` drives conflict analysis with `useLongTask`; `useRecipeLoader` watches `recipe_url` query params for shared exports. - Shared UI: - - `ProgressOverlay` renders the full-screen overlay with accessible ARIA props and cancel handling. - - `ExportLandingPanel` and `ExportSelectionPanel` handle the two-step export start flow in both ExportHome and ImportPage. -- Graph utilities expose a single entry point (`@/utils/graph`) with `SchemaProgressUpdate` progress typing. + - `TaskOverlayStack` + `TaskProgressOverlay` render cancellable overlays with `ProgressOverlay` stall detection. + - `GraphCanvas`, `LargeSelectionLayout`, and the Schema Overview components keep the export/import visualizations consistent. +- Schema utilities: + - `ProjectSchema` provides cached lookups plus concurrency-limited `getItemTypeFieldsAndFieldsets` calls. + - `buildExportDoc` trims validators/appearances so exports stay self-contained; `buildImportDoc` + `importSchema` orchestrate plugin installs, item type creation, field migrations, and reorder passes. +- Local development: + - `npm run dev` starts Vite, `npm run build` runs `tsc -b` followed by `vite build`, `npm run analyze` builds with bundle analysis, and `npm run format` runs Biome in `--write` mode. ## Export File Format - Version 2 (current): `{ version: '2', rootItemTypeId, entities: […] }` — preserves the explicit root model/block used to seed the export, to re-generate the export graph deterministically. - Version 1 (legacy): `{ version: '1', entities: […] }` — still supported for import; the root is inferred from references. +- Field validators referencing models outside the selection are trimmed, and appearances are rewritten to include only allowed plugin editors/addons so the export remains self-contained. ## Safety @@ -93,3 +106,5 @@ Powerful, safe schema migration for DatoCMS. Export models/blocks and plugins as - “Why did the graph disappear?” For very large selections, the UI switches to a faster list view. - “Fields lost their editor?” If you don’t include a custom editor plugin in the export/import, the plugin selects a safe, built‑in editor so the field remains valid in the target project. +- “Plugin dependencies were skipped?” Check for the banner warning about incomplete plugin detection and rerun “Select all dependencies” after reopening the page once the CMA call succeeds. +- “Cancel didn’t stop immediately?” The import/export pipeline stops at the next safe checkpoint; keep the overlay open until it confirms cancellation. diff --git a/import-export-schema/docs/refactor-baseline.md b/import-export-schema/docs/refactor-baseline.md deleted file mode 100644 index 9f98b4d7..00000000 --- a/import-export-schema/docs/refactor-baseline.md +++ /dev/null @@ -1,61 +0,0 @@ -# Refactor Baseline (September 17, 2025) - -## Current Critical Flows - -### Export: Start From Selection -- Launch Export page with no preselected item type. -- Select one or more models/blocks via multiselect. -- Press `Export Selected` and wait for graph to render. -- Toggle dependency selection; confirm auto-selection adds linked models/plugins. -- Export selection; expect download named `export.json` and success toast. - -### Export: From Schema Dropdown -- From schema dropdown action (`Export as JSON...`) load `ExportPage` with initial item type. -- Confirm overlay progresses through scan/build phases and hides when graph ready. -- Export without modifying selection; ensure download + toast. -- Trigger cancel during export; verify notice and overlay update. - -### Export: Entire Schema -- On Export landing, choose `Export entire schema`. -- Confirm confirmation dialog text. -- Ensure overlay tracks progress and cancel immediately hides overlay while still cancelling. -- Validate success toast when done, or graceful alert if schema empty. - -### Import: File Upload Flow -- Drop valid export file; spinner shows while parsing. -- Conflicts list populates with models/blocks/plugins grouped and sorted. -- Adjust resolutions (reuse/rename) and submit. -- Import progress overlay updates counts and finishes with success toast. - -### Import: Recipe URL Parameters -- Open Import page with `?recipe_url=...` query parameters. -- Verify remote JSON fetch, fallback name assignment, and conflict build once loaded. -- Cancel import via bottom action; ensure confirmation dialog resets state. - -### Import: Cancel During Import -- Start import and trigger cancel; confirm warning dialog and partial state handling. -- Verify overlay message switches to "Stopping" label while waiting. - -### Import: Export Tab Within Import Page -- Switch to Export tab, select models/blocks, run export. -- Ensure shared overlays behave like Export page variant. -- Confirm back navigation keeps selections when returning. - -## Manual QA Checklist -- [x] `npm run build` succeeds (baseline). -- [ ] Export: Start from selection flow works (selection, dependency toggle, download). -- [ ] Export: Schema dropdown entry works (overlay, cancel path). -- [ ] Export: Entire schema exports all models/plugins without crash. -- [ ] Import: Upload flow handles conflicts and completes import (test with sandbox project). -- [ ] Import: Recipe URL auto-load works and sets title. -- [ ] Import: Cancel during import stops gracefully without crashing. -- [ ] Import Page Export tab mirrors main export flow. - -## Observations / Known Debt -- Dependency auto-selection logic in `ExportInner` remains complex; consider extracting into a dedicated hook with tests. -- Graph QA still manual; adding smoke tests for `useExportGraph` and `useConflictsBuilder` would improve confidence. -- Global CSS (`index.css`) still houses most styles; future work could migrate node/toolbar styling to CSS modules. - -## Next Steps -- Execute manual QA checklist before release (export flows, import flows, cancel paths). -- Update component docs/readme snippets if any UX tweaks occur during QA. diff --git a/import-export-schema/src/entrypoints/Config/index.tsx b/import-export-schema/src/entrypoints/Config/index.tsx index b9b4e3f0..0272dff9 100644 --- a/import-export-schema/src/entrypoints/Config/index.tsx +++ b/import-export-schema/src/entrypoints/Config/index.tsx @@ -25,9 +25,10 @@ function Link({ href, children }: { href: string; children: ReactNode }) { /** Configuration screen shown in Settings → Plugins. */ export function Config({ ctx }: Props) { - const schemaUrl = `${ctx.isEnvironmentPrimary ? '' : `/environments/${ctx.environment}`}/schema`; - const importUrl = `${ctx.isEnvironmentPrimary ? '' : `/environments/${ctx.environment}`}/configuration/p/${ctx.plugin.id}/pages/import`; - const exportUrl = `${ctx.isEnvironmentPrimary ? '' : `/environments/${ctx.environment}`}/configuration/p/${ctx.plugin.id}/pages/export`; + const environmentPath = ctx.isEnvironmentPrimary ? '' : `/environments/${ctx.environment}`; + const schemaUrl = `${environmentPath}/schema`; + const importUrl = `${environmentPath}/configuration/p/${ctx.plugin.id}/pages/import`; + const exportUrl = `${environmentPath}/configuration/p/${ctx.plugin.id}/pages/export`; return ( diff --git a/import-export-schema/src/entrypoints/ExportHome/index.tsx b/import-export-schema/src/entrypoints/ExportHome/index.tsx index a9a9c658..d868dd90 100644 --- a/import-export-schema/src/entrypoints/ExportHome/index.tsx +++ b/import-export-schema/src/entrypoints/ExportHome/index.tsx @@ -1,3 +1,10 @@ +/** + * Export navigation flow: + * + * ┌──────────┐ onSelectModels ┌────────────┐ onStart (with selection) ┌──────────┐ + * │ Landing │ ──────────────────▶ │ Selection │ ─────────────────────────────▶│ Graph │ + * └──────────┘ onExportAll runs └────────────┘ onBack ⟲ Landing └──────────┘ + */ import { ReactFlowProvider } from '@xyflow/react'; import type { RenderPageCtx } from 'datocms-plugin-sdk'; import { Canvas } from 'datocms-react-ui'; @@ -9,7 +16,10 @@ import { useExportAllHandler } from '@/shared/hooks/useExportAllHandler'; import { useExportSelection } from '@/shared/hooks/useExportSelection'; import { useProjectSchema } from '@/shared/hooks/useProjectSchema'; import { useSchemaExportTask } from '@/shared/hooks/useSchemaExportTask'; -import { useLongTask } from '@/shared/tasks/useLongTask'; +import { + useLongTask, + type UseLongTaskResult, +} from '@/shared/tasks/useLongTask'; import ExportInner from '../ExportPage/Inner'; type Props = { @@ -18,11 +28,73 @@ type Props = { type ExportView = 'landing' | 'selection' | 'graph'; +type OverlayItems = Parameters[0]['items']; + +type BuildOverlayItemsArgs = { + exportAllTask: UseLongTaskResult; + exportPreparingTask: UseLongTaskResult; + exportSelectionTask: UseLongTaskResult; + exportPreparingPercent: number; +}; + +// Keep overlay wiring centralized so the JSX tree stays readable and we can reuse +// the same overlay definitions if we ever need them elsewhere (e.g. tests). +function buildOverlayItems({ + exportAllTask, + exportPreparingTask, + exportSelectionTask, + exportPreparingPercent, +}: BuildOverlayItemsArgs): OverlayItems { + return [ + { + id: 'export-all', + task: exportAllTask, + title: 'Exporting entire schema', + subtitle: 'Sit tight, we’re gathering models, blocks, and plugins…', + ariaLabel: 'Export in progress', + progressLabel: (progress) => + progress.label ?? 'Loading project schema…', + cancel: () => ({ + label: 'Cancel export', + intent: exportAllTask.state.cancelRequested ? 'muted' : 'negative', + disabled: exportAllTask.state.cancelRequested, + onCancel: () => exportAllTask.controller.requestCancel(), + }), + }, + { + id: 'export-preparing', + task: exportPreparingTask, + title: 'Preparing export', + subtitle: 'Sit tight, we’re setting up your models, blocks, and plugins…', + ariaLabel: 'Preparing export', + progressLabel: (progress) => progress.label ?? 'Preparing export…', + percentOverride: exportPreparingPercent, + }, + { + id: 'export-selection', + task: exportSelectionTask, + title: 'Exporting selection', + subtitle: 'Sit tight, we’re gathering models, blocks, and plugins…', + ariaLabel: 'Export in progress', + progressLabel: (progress) => progress.label ?? 'Preparing export…', + cancel: () => ({ + label: 'Cancel export', + intent: exportSelectionTask.state.cancelRequested ? 'muted' : 'negative', + disabled: exportSelectionTask.state.cancelRequested, + onCancel: () => exportSelectionTask.controller.requestCancel(), + }), + }, + ]; +} + /** * Landing page for the export workflow. Guides the user from the initial action * choice into the detailed graph view while coordinating the long-running tasks. */ export default function ExportHome({ ctx }: Props) { + // ----- Schema + selection state ----- + // Seed the selection autocomplete and schema data used by every subview; we + // establish this once so each panel can rely on the same shared data. const exportInitialSelectId = useId(); const projectSchema = useProjectSchema(ctx); @@ -35,6 +107,9 @@ export default function ExportHome({ ctx }: Props) { const [view, setView] = useState('landing'); + // ----- Long-running tasks ----- + // The export flow manipulates three distinct tasks (export all, prepare graph, + // targeted export). const exportAllTask = useLongTask(); const exportPreparingTask = useLongTask(); const { task: exportSelectionTask, runExport: runSelectionExport } = @@ -52,10 +127,24 @@ export default function ExportHome({ ctx }: Props) { task: exportAllTask.controller, }); + // Determine once whether the user has made a selection and keep the root item + // handy so downstream callbacks stay simple. + const hasSelection = exportInitialItemTypeIds.length > 0; + const rootItemTypeId = hasSelection ? exportInitialItemTypeIds[0] : undefined; + + const handleLandingSelect = () => { + setView('selection'); + }; + + const handleBackToLanding = () => { + setView('landing'); + }; + const handleStartSelection = () => { - if (exportInitialItemTypeIds.length === 0) { + if (!hasSelection) { return; } + exportPreparingTask.controller.start({ label: 'Preparing export…', }); @@ -63,134 +152,116 @@ export default function ExportHome({ ctx }: Props) { setView('graph'); }; - let body: JSX.Element; + const handleGraphPrepared = () => { + setExportPreparingPercent(1); + exportPreparingTask.controller.complete({ + label: 'Graph prepared', + }); + }; - if (view === 'graph') { - body = ( - { - setExportPreparingPercent(1); - exportPreparingTask.controller.complete({ - label: 'Graph prepared', - }); - }} - onPrepareProgress={(progress) => { - if (exportPreparingTask.state.status !== 'running') { - exportPreparingTask.controller.start(progress); - } else { - exportPreparingTask.controller.setProgress(progress); - } - - const hasFixedTotal = (progress.total ?? 0) > 0; - const raw = hasFixedTotal ? progress.done / progress.total : 0; - - if (!hasFixedTotal) { - setExportPreparingPercent((prev) => - Math.min(0.25, Math.max(prev, prev + 0.02)), - ); - } else { - const mapped = 0.25 + raw * 0.75; - setExportPreparingPercent((prev) => - Math.max(prev, Math.min(1, mapped)), - ); - } - }} - onClose={() => { - setView('selection'); - setExportPreparingPercent(0.1); - exportPreparingTask.controller.reset(); - }} - onExport={(itemTypeIds, pluginIds) => - runSelectionExport({ - rootItemTypeId: exportInitialItemTypeIds[0], - itemTypeIds, - pluginIds, - }) - } + const handlePrepareProgress = (progress: { + done: number; + total: number; + label: string; + phase?: 'scan' | 'build'; + }) => { + if (exportPreparingTask.state.status !== 'running') { + exportPreparingTask.controller.start(progress); + } else { + exportPreparingTask.controller.setProgress(progress); + } + + // When the task cannot provide a total, gently advance toward 25% so the + // overlay feels alive; otherwise map the true progress into the remaining + // 75% so the visual bar always pushes forward. This may seem like a hack, + // but it's the best way to keep the overlay feeling alive while the task is running. + const hasFixedTotal = (progress.total ?? 0) > 0; + const raw = hasFixedTotal ? progress.done / progress.total : 0; + + if (!hasFixedTotal) { + setExportPreparingPercent((prev) => + Math.min(0.25, Math.max(prev, prev + 0.02)), + ); + } else { + const mapped = 0.25 + raw * 0.75; + setExportPreparingPercent((prev) => + Math.max(prev, Math.min(1, mapped)), + ); + } + }; + + const handleCloseGraph = () => { + setView('selection'); + setExportPreparingPercent(0.1); + exportPreparingTask.controller.reset(); + }; + + const handleSelectionExport = (itemTypeIds: string[], pluginIds: string[]) => { + if (!rootItemTypeId) { + return; + } + + void runSelectionExport({ + rootItemTypeId, + itemTypeIds, + pluginIds, + }); + }; + + // The view map condenses our conditional rendering into a single lookup and + // keeps each subview’s JSX colocated with the handlers it consumes. + const viewContent: Record = { + landing: ( + - ); - } else if (view === 'selection') { - body = ( + ), + selection: ( setView('landing')} - startDisabled={exportInitialItemTypeIds.length === 0} + onBack={handleBackToLanding} + startDisabled={!hasSelection} /> - ); - } else { - body = ( - setView('selection')} - onExportAll={runExportAll} - exportAllDisabled={exportAllTask.state.status === 'running'} + ), + graph: ( + - ); - } + ), + }; + + // Precompute overlay config outside the JSX so the render tree remains easy to + // scan and future overlays only require extending this list. + const overlayItems = buildOverlayItems({ + exportAllTask, + exportPreparingTask, + exportSelectionTask, + exportPreparingPercent, + }); return (
    -
    {body}
    +
    {viewContent[view]}
    {/* Blocking overlay while exporting all */} - - progress.label ?? 'Loading project schema…', - cancel: () => ({ - label: 'Cancel export', - intent: exportAllTask.state.cancelRequested - ? 'muted' - : 'negative', - disabled: exportAllTask.state.cancelRequested, - onCancel: () => exportAllTask.controller.requestCancel(), - }), - }, - { - id: 'export-preparing', - task: exportPreparingTask, - title: 'Preparing export', - subtitle: - 'Sit tight, we’re setting up your models, blocks, and plugins…', - ariaLabel: 'Preparing export', - progressLabel: (progress) => progress.label ?? 'Preparing export…', - percentOverride: exportPreparingPercent, - }, - { - id: 'export-selection', - task: exportSelectionTask, - title: 'Exporting selection', - subtitle: 'Sit tight, we’re gathering models, blocks, and plugins…', - ariaLabel: 'Export in progress', - progressLabel: (progress) => progress.label ?? 'Preparing export…', - cancel: () => ({ - label: 'Cancel export', - intent: exportSelectionTask.state.cancelRequested - ? 'muted' - : 'negative', - disabled: exportSelectionTask.state.cancelRequested, - onCancel: () => exportSelectionTask.controller.requestCancel(), - }), - }, - ]} - /> +
    ); } diff --git a/import-export-schema/src/main.tsx b/import-export-schema/src/main.tsx index 43515d9c..14764f03 100644 --- a/import-export-schema/src/main.tsx +++ b/import-export-schema/src/main.tsx @@ -6,7 +6,8 @@ import { Spinner } from 'datocms-react-ui'; import { lazy, Suspense } from 'react'; import { render } from '@/utils/render'; -// Lazy-load entrypoints to reduce initial bundle size +// Lazy-load entrypoints so the iframe boots with only the shared shell. +// Each page chunk loads on demand, keeping initial bundles lighter and first render quicker. const LazyConfig = lazy(() => import('./entrypoints/Config').then((m) => ({ default: m.Config })), ); @@ -27,9 +28,11 @@ connect({ ]; }, async executeSchemaItemTypeDropdownAction(_id, itemType, ctx) { - ctx.navigateTo( - `${ctx.isEnvironmentPrimary ? '' : `/environments/${ctx.environment}`}/configuration/p/${ctx.plugin.id}/pages/export?itemTypeId=${itemType.id}`, - ); + const environmentPrefix = ctx.isEnvironmentPrimary ? '' : `/environments/${ctx.environment}`; + const exportPagePath = `/configuration/p/${ctx.plugin.id}/pages/export`; + const navigateUrl = `${environmentPrefix}${exportPagePath}?itemTypeId=${itemType.id}`; + + ctx.navigateTo(navigateUrl); }, settingsAreaSidebarItemGroups() { return [ @@ -51,10 +54,12 @@ connect({ ]; }, renderPage(pageId, ctx) { + // All page renders may include an itemTypeId query param when navigating from a schema dropdown. const params = new URLSearchParams(ctx.location.search); const itemTypeId = params.get('itemTypeId'); if (pageId === 'import') { + // Direct navigation to the import page always boots the import screen in import-only mode. return render( }> @@ -64,13 +69,14 @@ connect({ if (pageId === 'export') { if (itemTypeId) { + // Export triggered from a specific item type skips the landing step and hydrates the export page. return render( }> , ); } - // Export landing with selection flow + // Bare export navigation shows the landing page so the user can choose what to export. return render( }> @@ -78,7 +84,7 @@ connect({ ); } - // Fallback for legacy pageId + // Unknown page IDs fall back to the import screen to preserve legacy deep links. return render( }> From f2fc517afe023de5e6a960d869633cbfd2fd856c Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Fri, 19 Sep 2025 17:12:28 +0200 Subject: [PATCH 15/36] refactor --- .../src/entrypoints/ImportPage/index.tsx | 438 +++++++++++------- import-export-schema/src/index.css | 72 --- import-export-schema/src/main.tsx | 4 +- .../tmp-dist/src/utils/graph/dependencies.js | 49 -- .../tmp-dist/src/utils/graph/types.js | 7 - .../tmp-dist/tmp-test-deps.js | 55 --- 6 files changed, 271 insertions(+), 354 deletions(-) delete mode 100644 import-export-schema/tmp-dist/src/utils/graph/dependencies.js delete mode 100644 import-export-schema/tmp-dist/src/utils/graph/types.js delete mode 100644 import-export-schema/tmp-dist/tmp-test-deps.js diff --git a/import-export-schema/src/entrypoints/ImportPage/index.tsx b/import-export-schema/src/entrypoints/ImportPage/index.tsx index edcb7d1b..b39ab860 100644 --- a/import-export-schema/src/entrypoints/ImportPage/index.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/index.tsx @@ -9,6 +9,7 @@ import { useExportSelection } from '@/shared/hooks/useExportSelection'; import { useProjectSchema } from '@/shared/hooks/useProjectSchema'; import { useSchemaExportTask } from '@/shared/hooks/useSchemaExportTask'; import { useLongTask, type UseLongTaskResult } from '@/shared/tasks/useLongTask'; +import type { ProjectSchema } from '@/utils/ProjectSchema'; import type { ExportDoc } from '@/utils/types'; import { ExportSchema } from '../ExportPage/ExportSchema'; import { ExportWorkflow, type ExportWorkflowPrepareProgress, type ExportWorkflowView } from './ExportWorkflow'; @@ -18,97 +19,41 @@ import type { Resolutions } from './ResolutionsForm'; import { useRecipeLoader } from './useRecipeLoader'; import importSchema from './importSchema'; +type Mode = 'import' | 'export'; + type Props = { ctx: RenderPageCtx; - initialMode?: 'import' | 'export'; - hideModeToggle?: boolean; + initialMode?: Mode; }; -type ModeToggleProps = { - mode: 'import' | 'export'; - onChange: (mode: 'import' | 'export') => void; +type ImportModeState = { + exportSchema: [string, ExportSchema] | undefined; + conflicts: ReturnType['conflicts']; + loadingRecipe: boolean; + handleDrop: (filename: string, doc: ExportDoc) => Promise; + handleImport: (resolutions: Resolutions) => Promise; + importTask: UseLongTaskResult; + conflictsTask: UseLongTaskResult; }; -function ModeToggle({ mode, onChange }: ModeToggleProps) { - return ( -
    -
    -
    - - -
    -
    -
    - ); -} - /** - * Unified Import/Export entrypoint rendered inside the Schema sidebar page. Handles - * file drops, conflict resolution, and the alternate export tab. + * Encapsulates the import tab lifecycle: loading shared recipes, reacting to file drops, + * resolving conflicts, and driving the long-running CMA import task. */ -export function ImportPage({ +function useImportMode({ ctx, - initialMode = 'import', - hideModeToggle = false, -}: Props) { - const exportInitialSelectId = useId(); - const [mode, setMode] = useState<'import' | 'export'>(initialMode); + projectSchema, + setMode, +}: { + ctx: RenderPageCtx; + projectSchema: ProjectSchema; + setMode: (mode: Mode) => void; +}): ImportModeState { + const importTask = useLongTask(); + const conflictsTask = useLongTask(); const [exportSchema, setExportSchema] = useState< [string, ExportSchema] | undefined >(); - const [exportView, setExportView] = useState('landing'); - - const projectSchema = useProjectSchema(ctx); - const client = projectSchema.client; - - const importTask = useLongTask(); - const exportAllTask = useLongTask(); - const exportPreparingTask = useLongTask(); - const { task: exportSelectionTask, runExport: runSelectionExport } = - useSchemaExportTask({ - schema: projectSchema, - ctx, - }); - const conflictsTask = useLongTask(); - - useEffect(() => { - setExportView('landing'); - exportPreparingTask.controller.reset(); - }, [mode, exportPreparingTask.controller]); - - const { - allItemTypes, - selectedIds: exportInitialItemTypeIds, - selectedItemTypes: exportInitialItemTypes, - setSelectedIds: setExportInitialItemTypeIds, - } = useExportSelection({ schema: projectSchema, enabled: mode === 'export' }); const { conflicts, setConflicts } = useConflictsBuilder({ exportSchema: exportSchema?.[1], @@ -116,12 +61,14 @@ export function ImportPage({ task: conflictsTask.controller, }); + const client = projectSchema.client; + const handleRecipeLoaded = useCallback( ({ label, schema }: { label: string; schema: ExportSchema }) => { setExportSchema([label, schema]); setMode('import'); }, - [], + [setMode], ); const handleRecipeError = useCallback( @@ -132,7 +79,7 @@ export function ImportPage({ [ctx], ); - const { loading: loadingRecipeByUrl } = useRecipeLoader( + const { loading: loadingRecipe } = useRecipeLoader( ctx, handleRecipeLoaded, { onError: handleRecipeError }, @@ -149,68 +96,7 @@ export function ImportPage({ ctx.alert(error instanceof Error ? error.message : 'Invalid export file!'); } }, - [ctx], - ); - - const runExportAll = useExportAllHandler({ - ctx, - schema: projectSchema, - task: exportAllTask.controller, - }); - - const handleShowExportSelection = useCallback(() => { - setExportView('selection'); - }, []); - - const handleStartExportSelection = useCallback(() => { - if (exportInitialItemTypeIds.length === 0) { - return; - } - exportPreparingTask.controller.start({ - label: 'Preparing export…', - }); - setExportView('graph'); - }, [exportInitialItemTypeIds, exportPreparingTask.controller]); - - const handleExportGraphPrepared = useCallback(() => { - exportPreparingTask.controller.complete({ - label: 'Graph prepared', - }); - }, [exportPreparingTask.controller]); - - const handleExportPrepareProgress = useCallback( - (progress: ExportWorkflowPrepareProgress) => { - if (exportPreparingTask.state.status !== 'running') { - exportPreparingTask.controller.start(progress); - } else { - exportPreparingTask.controller.setProgress(progress); - } - }, - [exportPreparingTask.controller, exportPreparingTask.state.status], - ); - - const handleExportClose = useCallback(() => { - setExportView('selection'); - exportPreparingTask.controller.reset(); - }, [exportPreparingTask.controller]); - - const handleBackToLanding = useCallback(() => { - setExportView('landing'); - exportPreparingTask.controller.reset(); - }, [exportPreparingTask.controller]); - - const handleExportSelection = useCallback( - (itemTypeIds: string[], pluginIds: string[]) => { - if (exportInitialItemTypeIds.length === 0) { - return; - } - runSelectionExport({ - rootItemTypeId: exportInitialItemTypeIds[0], - itemTypeIds, - pluginIds, - }); - }, - [exportInitialItemTypeIds, runSelectionExport], + [ctx, setMode], ); const handleImport = useCallback( @@ -274,7 +160,7 @@ export function ImportPage({ importTask.controller.reset(); } }, - [client, conflicts, ctx, exportSchema, importTask, setConflicts], + [client, conflicts, ctx, exportSchema, importTask, setConflicts, setExportSchema], ); useEffect(() => { @@ -312,9 +198,182 @@ export function ImportPage({ handleCancelRequest as unknown as EventListener, ); }; - }, [ctx, exportSchema]); + }, [ctx, exportSchema, setExportSchema]); + + return { + exportSchema, + conflicts, + loadingRecipe, + handleDrop, + handleImport, + importTask, + conflictsTask, + }; +} + +type ExportModeState = { + exportInitialSelectId: string; + exportView: ExportWorkflowView; + allItemTypes: ReturnType['allItemTypes']; + exportInitialItemTypeIds: string[]; + exportInitialItemTypes: ReturnType['selectedItemTypes']; + setSelectedIds: (ids: string[]) => void; + runExportAll: () => void; + exportAllTask: UseLongTaskResult; + exportPreparingTask: UseLongTaskResult; + exportSelectionTask: UseLongTaskResult; + handleShowExportSelection: () => void; + handleBackToLanding: () => void; + handleStartExportSelection: () => void; + handleExportGraphPrepared: () => void; + handleExportPrepareProgress: (progress: ExportWorkflowPrepareProgress) => void; + handleExportClose: () => void; + handleExportSelection: (itemTypeIds: string[], pluginIds: string[]) => void; +}; + +/** + * Bundles export-specific state so the main component only forwards data to `ExportWorkflow`. + * This hook manages the selection flow, long tasks, and status transitions required to render + * landing/selection/graph screens. + */ +function useExportMode({ + ctx, + projectSchema, + mode, +}: { + ctx: RenderPageCtx; + projectSchema: ProjectSchema; + mode: Mode; +}): ExportModeState { + const exportInitialSelectId = useId(); + const [exportView, setExportView] = useState('landing'); - const overlayItems = useMemo( + const exportAllTask = useLongTask(); + const exportPreparingTask = useLongTask(); + const { task: exportSelectionTask, runExport: runSelectionExport } = + useSchemaExportTask({ + schema: projectSchema, + ctx, + }); + + const { + allItemTypes, + selectedIds: exportInitialItemTypeIds, + selectedItemTypes: exportInitialItemTypes, + setSelectedIds, + } = useExportSelection({ schema: projectSchema, enabled: mode === 'export' }); + + const runExportAll = useExportAllHandler({ + ctx, + schema: projectSchema, + task: exportAllTask.controller, + }); + + useEffect(() => { + setExportView('landing'); + exportPreparingTask.controller.reset(); + }, [mode, exportPreparingTask.controller]); + + const handleShowExportSelection = useCallback(() => { + setExportView('selection'); + }, []); + + const handleStartExportSelection = useCallback(() => { + if (exportInitialItemTypeIds.length === 0) { + return; + } + exportPreparingTask.controller.start({ + label: 'Preparing export…', + }); + setExportView('graph'); + }, [exportInitialItemTypeIds, exportPreparingTask.controller]); + + const handleExportGraphPrepared = useCallback(() => { + exportPreparingTask.controller.complete({ + label: 'Graph prepared', + }); + }, [exportPreparingTask.controller]); + + const handleExportPrepareProgress = useCallback( + (progress: ExportWorkflowPrepareProgress) => { + if (exportPreparingTask.state.status !== 'running') { + exportPreparingTask.controller.start(progress); + } else { + exportPreparingTask.controller.setProgress(progress); + } + }, + [exportPreparingTask.controller, exportPreparingTask.state.status], + ); + + const handleExportClose = useCallback(() => { + setExportView('selection'); + exportPreparingTask.controller.reset(); + }, [exportPreparingTask.controller]); + + const handleBackToLanding = useCallback(() => { + setExportView('landing'); + exportPreparingTask.controller.reset(); + }, [exportPreparingTask.controller]); + + const handleExportSelection = useCallback( + (itemTypeIds: string[], pluginIds: string[]) => { + if (exportInitialItemTypeIds.length === 0) { + return; + } + runSelectionExport({ + rootItemTypeId: exportInitialItemTypeIds[0], + itemTypeIds, + pluginIds, + }); + }, + [exportInitialItemTypeIds, runSelectionExport], + ); + + return { + exportInitialSelectId, + exportView, + allItemTypes, + exportInitialItemTypeIds, + exportInitialItemTypes, + setSelectedIds, + runExportAll, + exportAllTask, + exportPreparingTask, + exportSelectionTask, + handleShowExportSelection, + handleBackToLanding, + handleStartExportSelection, + handleExportGraphPrepared, + handleExportPrepareProgress, + handleExportClose, + handleExportSelection, + }; +} + +type OverlayItemsArgs = { + ctx: RenderPageCtx; + exportSchema: [string, ExportSchema] | undefined; + importTask: UseLongTaskResult; + exportAllTask: UseLongTaskResult; + conflictsTask: UseLongTaskResult; + exportPreparingTask: UseLongTaskResult; + exportSelectionTask: UseLongTaskResult; +}; + +/** + * Coalesces the overlay stack configuration used by `TaskOverlayStack`. Keeping the builder + * in one place clarifies which long tasks participate in the UI at any moment. + */ +function useOverlayItems({ + ctx, + exportSchema, + importTask, + exportAllTask, + conflictsTask, + exportPreparingTask, + exportSelectionTask, +}: OverlayItemsArgs) { + return useMemo( () => [ buildImportOverlay(ctx, importTask, exportSchema), buildExportAllOverlay(exportAllTask), @@ -332,43 +391,68 @@ export function ImportPage({ exportSelectionTask, ], ); +} + +/** + * Unified Import/Export entrypoint rendered inside the Schema sidebar page. Handles + * file drops, conflict resolution, and still supports the legacy export view when + * the page is instantiated in export mode. + */ +export function ImportPage({ + ctx, + initialMode = 'import', +}: Props) { + const projectSchema = useProjectSchema(ctx); + const [mode, setMode] = useState(initialMode); + + const importMode = useImportMode({ ctx, projectSchema, setMode }); + const exportMode = useExportMode({ ctx, projectSchema, mode }); + + const overlayItems = useOverlayItems({ + ctx, + exportSchema: importMode.exportSchema, + importTask: importMode.importTask, + exportAllTask: exportMode.exportAllTask, + conflictsTask: importMode.conflictsTask, + exportPreparingTask: exportMode.exportPreparingTask, + exportSelectionTask: exportMode.exportSelectionTask, + }); return (
    - {exportSchema || hideModeToggle ? null : ( - - )}
    {mode === 'import' ? ( ) : ( )}
    @@ -382,6 +466,10 @@ export function ImportPage({ type OverlayConfig = Parameters[0]['items'][number]; +/** + * Overlay shown while the CMA import runs. Allows cancelling with a confirmation when an + * export recipe is currently loaded. + */ function buildImportOverlay( ctx: RenderPageCtx, importTask: UseLongTaskResult, @@ -430,6 +518,9 @@ function buildImportOverlay( }; } +/** + * Overlay displayed when the "export everything" action is running. + */ function buildExportAllOverlay(exportAllTask: UseLongTaskResult): OverlayConfig { return { id: 'export-all', @@ -447,6 +538,9 @@ function buildExportAllOverlay(exportAllTask: UseLongTaskResult): OverlayConfig }; } +/** + * Overlay used while conflicts between project and recipe are resolved. + */ function buildConflictsOverlay(conflictsTask: UseLongTaskResult): OverlayConfig { return { id: 'conflicts', @@ -459,6 +553,9 @@ function buildConflictsOverlay(conflictsTask: UseLongTaskResult): OverlayConfig }; } +/** + * Overlay surfaced as the graph view prepares the export content for preview. + */ function buildExportPrepOverlay(exportPreparingTask: UseLongTaskResult): OverlayConfig { return { id: 'export-prep', @@ -470,6 +567,9 @@ function buildExportPrepOverlay(exportPreparingTask: UseLongTaskResult): Overlay }; } +/** + * Overlay tied to the selection-based export task started from the graph view. + */ function buildExportSelectionOverlay( exportSelectionTask: UseLongTaskResult, ): OverlayConfig { diff --git a/import-export-schema/src/index.css b/import-export-schema/src/index.css index 05c3d253..23ef471d 100644 --- a/import-export-schema/src/index.css +++ b/import-export-schema/src/index.css @@ -292,78 +292,6 @@ code { box-shadow: none; } -/* Centered, prominent Import/Export toggle */ -.mode-toggle { - --toggle-inset: 6px; - --blob-ease: cubic-bezier(0.22, 1, 0.36, 1); - - display: inline-flex; - position: relative; - gap: 0; - padding: var(--toggle-inset); - border: 1px solid var(--border-color); - border-radius: 999px; - background: #fff; - align-items: center; - justify-content: center; - box-shadow: 0 3px 12px rgba(0, 0, 0, 0.08); - overflow: hidden; -} - -/* Sliding blob background */ -.mode-toggle::before { - content: ""; - position: absolute; - top: var(--toggle-inset); - left: var(--toggle-inset); - width: calc((100% - (var(--toggle-inset) * 2)) / 2); - height: calc(100% - (var(--toggle-inset) * 2)); - border-radius: 999px; - transform: translateX(0%); - transition: transform 420ms var(--blob-ease); - pointer-events: none; -} - -/* Base blob */ -.mode-toggle::before { - background: var(--accent-color); - box-shadow: - 0 10px 20px rgba(0, 0, 0, 0.08), - 0 2px 6px rgba(0, 0, 0, 0.06) inset; -} - -/* Move blob when Export is active */ -.mode-toggle[data-mode="export"]::before { - transform: translateX(100%); -} - -.mode-toggle__button { - appearance: none; - border: 0; - margin: 0; - background: transparent; - padding: 8px 16px; /* more compact to fit toolbar */ - font: inherit; - font-size: 14px; - color: var(--base-body-color); - cursor: pointer; - border-radius: 999px; - position: relative; - z-index: 2; /* above blob */ - flex: 1 1 0; - text-align: center; - transition: color 200ms ease; -} - -.mode-toggle__button:hover { - color: color-mix(in srgb, var(--base-body-color), black 15%); -} - -.mode-toggle__button.is-active, -.mode-toggle__button[aria-selected="true"] { - color: #fff; -} - .page__toolbar__title { font-weight: bold; font-size: var(--font-size-xl); diff --git a/import-export-schema/src/main.tsx b/import-export-schema/src/main.tsx index 14764f03..bdb4f282 100644 --- a/import-export-schema/src/main.tsx +++ b/import-export-schema/src/main.tsx @@ -62,7 +62,7 @@ connect({ // Direct navigation to the import page always boots the import screen in import-only mode. return render( }> - + , ); } @@ -87,7 +87,7 @@ connect({ // Unknown page IDs fall back to the import screen to preserve legacy deep links. return render( }> - + , ); }, diff --git a/import-export-schema/tmp-dist/src/utils/graph/dependencies.js b/import-export-schema/tmp-dist/src/utils/graph/dependencies.js deleted file mode 100644 index 7a11fee5..00000000 --- a/import-export-schema/tmp-dist/src/utils/graph/dependencies.js +++ /dev/null @@ -1,49 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.expandSelectionWithDependencies = expandSelectionWithDependencies; -const schema_1 = require("@/utils/datocms/schema"); -/** - * Expand the current selection with all linked item types and plugins. - */ -function expandSelectionWithDependencies({ graph, seedItemTypeIds, seedPluginIds, installedPluginIds, }) { - const initialItemIds = Array.from(new Set(seedItemTypeIds)); - const initialPluginIds = Array.from(new Set(seedPluginIds)); - const nextItemTypeIds = new Set(initialItemIds); - const nextPluginIds = new Set(initialPluginIds); - if (!graph) { - return { - itemTypeIds: nextItemTypeIds, - pluginIds: nextPluginIds, - addedItemTypeIds: [], - addedPluginIds: [], - }; - } - const queue = [...initialItemIds]; - while (queue.length > 0) { - const currentId = queue.pop(); - if (!currentId) - continue; - const node = graph.nodes.find((candidate) => candidate.id === `itemType--${currentId}`); - if (!node || node.type !== 'itemType') - continue; - for (const field of node.data.fields) { - for (const linkedId of (0, schema_1.findLinkedItemTypeIds)(field)) { - if (!nextItemTypeIds.has(linkedId)) { - nextItemTypeIds.add(linkedId); - queue.push(linkedId); - } - } - for (const pluginId of (0, schema_1.findLinkedPluginIds)(field, installedPluginIds)) { - nextPluginIds.add(pluginId); - } - } - } - const addedItemTypeIds = Array.from(nextItemTypeIds).filter((id) => !initialItemIds.includes(id)); - const addedPluginIds = Array.from(nextPluginIds).filter((id) => !initialPluginIds.includes(id)); - return { - itemTypeIds: nextItemTypeIds, - pluginIds: nextPluginIds, - addedItemTypeIds, - addedPluginIds, - }; -} diff --git a/import-export-schema/tmp-dist/src/utils/graph/types.js b/import-export-schema/tmp-dist/src/utils/graph/types.js deleted file mode 100644 index 921e857f..00000000 --- a/import-export-schema/tmp-dist/src/utils/graph/types.js +++ /dev/null @@ -1,7 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.edgeTypes = void 0; -const FieldEdgeRenderer_1 = require("@/components/FieldEdgeRenderer"); -exports.edgeTypes = { - field: FieldEdgeRenderer_1.FieldEdgeRenderer, -}; diff --git a/import-export-schema/tmp-dist/tmp-test-deps.js b/import-export-schema/tmp-dist/tmp-test-deps.js deleted file mode 100644 index a32eeabb..00000000 --- a/import-export-schema/tmp-dist/tmp-test-deps.js +++ /dev/null @@ -1,55 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -const dependencies_1 = require("./src/utils/graph/dependencies"); -/* Minimal stubs for SchemaTypes */ -const itemA = { id: 'A', type: 'item_type', attributes: { name: 'A', api_key: 'a', modular_block: false }, relationships: { fieldset: { data: null } } }; -const itemB = { id: 'B', type: 'item_type', attributes: { name: 'B', api_key: 'b', modular_block: false }, relationships: { fieldset: { data: null } } }; -const fieldLink = { - id: 'field1', - type: 'field', - attributes: { - label: 'Field 1', - api_key: 'field_1', - field_type: 'link', - validators: { - item_item_type: { item_types: ['B'] }, - }, - }, - relationships: { - fieldset: { data: null }, - }, -}; -const graph = { - nodes: [ - { - id: 'itemType--A', - type: 'itemType', - position: { x: 0, y: 0 }, - data: { - itemType: itemA, - fields: [fieldLink], - fieldsets: [], - }, - }, - { - id: 'itemType--B', - type: 'itemType', - position: { x: 0, y: 0 }, - data: { - itemType: itemB, - fields: [], - fieldsets: [], - }, - }, - ], - edges: [], -}; -const expansion = (0, dependencies_1.expandSelectionWithDependencies)({ - graph, - seedItemTypeIds: ['A'], - seedPluginIds: [], -}); -console.log({ - itemTypeIds: Array.from(expansion.itemTypeIds), - addedItemTypeIds: expansion.addedItemTypeIds, -}); From e9e3a80b8ffaec11adccf6a9e43247aba8da50d7 Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Mon, 22 Sep 2025 12:57:48 +0200 Subject: [PATCH 16/36] removed header --- import-export-schema/.gitignore | 2 + .../ExportPage/ExportToolbar.module.css | 9 --- .../entrypoints/ExportPage/ExportToolbar.tsx | 23 -------- .../src/entrypoints/ExportPage/Inner.tsx | 2 - import-export-schema/tmp-test-deps.ts | 59 ------------------- 5 files changed, 2 insertions(+), 93 deletions(-) delete mode 100644 import-export-schema/src/entrypoints/ExportPage/ExportToolbar.module.css delete mode 100644 import-export-schema/src/entrypoints/ExportPage/ExportToolbar.tsx delete mode 100644 import-export-schema/tmp-test-deps.ts diff --git a/import-export-schema/.gitignore b/import-export-schema/.gitignore index 009e052b..3c8dfe07 100644 --- a/import-export-schema/.gitignore +++ b/import-export-schema/.gitignore @@ -25,3 +25,5 @@ dist-ssr *.sw? .tmplr-preview + +AGENTS.md diff --git a/import-export-schema/src/entrypoints/ExportPage/ExportToolbar.module.css b/import-export-schema/src/entrypoints/ExportPage/ExportToolbar.module.css deleted file mode 100644 index 7902e697..00000000 --- a/import-export-schema/src/entrypoints/ExportPage/ExportToolbar.module.css +++ /dev/null @@ -1,9 +0,0 @@ -.toolbar { - padding: 8px var(--spacing-l); - display: flex; - align-items: center; -} - -.spacer { - flex: 1; -} diff --git a/import-export-schema/src/entrypoints/ExportPage/ExportToolbar.tsx b/import-export-schema/src/entrypoints/ExportPage/ExportToolbar.tsx deleted file mode 100644 index 93e1ad82..00000000 --- a/import-export-schema/src/entrypoints/ExportPage/ExportToolbar.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { SchemaTypes } from '@datocms/cma-client'; -import styles from './ExportToolbar.module.css'; - -type Props = { - initialItemTypes: SchemaTypes.ItemType[]; -}; - -/** - * Header bar for the export flow, displaying the active title and close action. - */ -export function ExportToolbar({ initialItemTypes }: Props) { - const title = - initialItemTypes.length === 1 - ? `Export ${initialItemTypes[0].attributes.name}` - : 'Export selection'; - - return ( -
    -
    {title}
    -
    -
    - ); -} diff --git a/import-export-schema/src/entrypoints/ExportPage/Inner.tsx b/import-export-schema/src/entrypoints/ExportPage/Inner.tsx index 285ddcab..30ae667e 100644 --- a/import-export-schema/src/entrypoints/ExportPage/Inner.tsx +++ b/import-export-schema/src/entrypoints/ExportPage/Inner.tsx @@ -19,7 +19,6 @@ import { EntitiesToExportContext } from './EntitiesToExportContext'; import { ExportItemTypeNodeRenderer } from './ExportItemTypeNodeRenderer'; import { ExportPluginNodeRenderer } from './ExportPluginNodeRenderer'; import { ExportSchemaOverview } from './ExportSchemaOverview'; -import { ExportToolbar } from './ExportToolbar'; import LargeSelectionView from './LargeSelectionView'; import { useAnimatedNodes } from './useAnimatedNodes'; import { useExportGraph } from './useExportGraph'; @@ -328,7 +327,6 @@ export default function Inner({ return (
    -
    Date: Mon, 22 Sep 2025 15:20:27 +0200 Subject: [PATCH 17/36] removed unused code --- .../entrypoints/ImportPage/ExportWorkflow.tsx | 94 ------ .../src/entrypoints/ImportPage/index.tsx | 290 +----------------- import-export-schema/src/main.tsx | 4 +- .../src/utils/datocms/validators.ts | 36 --- import-export-schema/src/utils/debug.ts | 4 - import-export-schema/src/utils/graph/index.ts | 10 - import-export-schema/tsconfig.app.tsbuildinfo | 2 +- 7 files changed, 20 insertions(+), 420 deletions(-) delete mode 100644 import-export-schema/src/entrypoints/ImportPage/ExportWorkflow.tsx delete mode 100644 import-export-schema/src/utils/datocms/validators.ts delete mode 100644 import-export-schema/src/utils/graph/index.ts diff --git a/import-export-schema/src/entrypoints/ImportPage/ExportWorkflow.tsx b/import-export-schema/src/entrypoints/ImportPage/ExportWorkflow.tsx deleted file mode 100644 index 99918db2..00000000 --- a/import-export-schema/src/entrypoints/ImportPage/ExportWorkflow.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import type { ComponentProps } from 'react'; -import type { SchemaTypes } from '@datocms/cma-client'; -import { ExportLandingPanel } from '@/components/ExportLandingPanel'; -import { ExportSelectionPanel } from '@/components/ExportSelectionPanel'; -import ExportInner from '../ExportPage/Inner'; -import type { ProjectSchema } from '@/utils/ProjectSchema'; - -export type ExportWorkflowPrepareProgress = Parameters< - NonNullable['onPrepareProgress']> ->[0]; - -export type ExportWorkflowView = 'landing' | 'selection' | 'graph'; - -type Props = { - projectSchema: ProjectSchema; - view: ExportWorkflowView; - exportInitialSelectId: string; - allItemTypes?: SchemaTypes.ItemType[]; - exportInitialItemTypeIds: string[]; - exportInitialItemTypes: SchemaTypes.ItemType[]; - setSelectedIds: (ids: string[]) => void; - onShowSelection: () => void; - onBackToLanding: () => void; - onStartSelection: () => void; - onExportAll: () => void; - exportAllDisabled: boolean; - onGraphPrepared: () => void; - onPrepareProgress: (update: ExportWorkflowPrepareProgress) => void; - onClose: () => void; - onExportSelection: (itemTypeIds: string[], pluginIds: string[]) => void; -}; - -/** - * Renders the export tab experience inside the unified Import page. - */ -export function ExportWorkflow({ - projectSchema, - view, - exportInitialSelectId, - allItemTypes, - exportInitialItemTypeIds, - exportInitialItemTypes, - setSelectedIds, - onShowSelection, - onBackToLanding, - onStartSelection, - onExportAll, - exportAllDisabled, - onGraphPrepared, - onPrepareProgress, - onClose, - onExportSelection, -}: Props) { - if (view === 'graph') { - return ( -
    - -
    - ); - } - - if (view === 'selection') { - return ( -
    - -
    - ); - } - - return ( -
    - -
    - ); -} diff --git a/import-export-schema/src/entrypoints/ImportPage/index.tsx b/import-export-schema/src/entrypoints/ImportPage/index.tsx index b39ab860..74a645ef 100644 --- a/import-export-schema/src/entrypoints/ImportPage/index.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/index.tsx @@ -1,29 +1,22 @@ import { ReactFlowProvider } from '@xyflow/react'; import type { RenderPageCtx } from 'datocms-plugin-sdk'; import { Canvas } from 'datocms-react-ui'; -import { useCallback, useEffect, useId, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { TaskOverlayStack } from '@/components/TaskOverlayStack'; import { useConflictsBuilder } from '@/shared/hooks/useConflictsBuilder'; -import { useExportAllHandler } from '@/shared/hooks/useExportAllHandler'; -import { useExportSelection } from '@/shared/hooks/useExportSelection'; import { useProjectSchema } from '@/shared/hooks/useProjectSchema'; -import { useSchemaExportTask } from '@/shared/hooks/useSchemaExportTask'; import { useLongTask, type UseLongTaskResult } from '@/shared/tasks/useLongTask'; import type { ProjectSchema } from '@/utils/ProjectSchema'; import type { ExportDoc } from '@/utils/types'; import { ExportSchema } from '../ExportPage/ExportSchema'; -import { ExportWorkflow, type ExportWorkflowPrepareProgress, type ExportWorkflowView } from './ExportWorkflow'; import { ImportWorkflow } from './ImportWorkflow'; import { buildImportDoc } from './buildImportDoc'; import type { Resolutions } from './ResolutionsForm'; import { useRecipeLoader } from './useRecipeLoader'; import importSchema from './importSchema'; -type Mode = 'import' | 'export'; - type Props = { ctx: RenderPageCtx; - initialMode?: Mode; }; type ImportModeState = { @@ -43,11 +36,9 @@ type ImportModeState = { function useImportMode({ ctx, projectSchema, - setMode, }: { ctx: RenderPageCtx; projectSchema: ProjectSchema; - setMode: (mode: Mode) => void; }): ImportModeState { const importTask = useLongTask(); const conflictsTask = useLongTask(); @@ -66,9 +57,8 @@ function useImportMode({ const handleRecipeLoaded = useCallback( ({ label, schema }: { label: string; schema: ExportSchema }) => { setExportSchema([label, schema]); - setMode('import'); }, - [setMode], + [], ); const handleRecipeError = useCallback( @@ -90,13 +80,12 @@ function useImportMode({ try { const schema = new ExportSchema(doc); setExportSchema([filename, schema]); - setMode('import'); } catch (error) { console.error(error); ctx.alert(error instanceof Error ? error.message : 'Invalid export file!'); } }, - [ctx, setMode], + [ctx], ); const handleImport = useCallback( @@ -211,153 +200,11 @@ function useImportMode({ }; } -type ExportModeState = { - exportInitialSelectId: string; - exportView: ExportWorkflowView; - allItemTypes: ReturnType['allItemTypes']; - exportInitialItemTypeIds: string[]; - exportInitialItemTypes: ReturnType['selectedItemTypes']; - setSelectedIds: (ids: string[]) => void; - runExportAll: () => void; - exportAllTask: UseLongTaskResult; - exportPreparingTask: UseLongTaskResult; - exportSelectionTask: UseLongTaskResult; - handleShowExportSelection: () => void; - handleBackToLanding: () => void; - handleStartExportSelection: () => void; - handleExportGraphPrepared: () => void; - handleExportPrepareProgress: (progress: ExportWorkflowPrepareProgress) => void; - handleExportClose: () => void; - handleExportSelection: (itemTypeIds: string[], pluginIds: string[]) => void; -}; - -/** - * Bundles export-specific state so the main component only forwards data to `ExportWorkflow`. - * This hook manages the selection flow, long tasks, and status transitions required to render - * landing/selection/graph screens. - */ -function useExportMode({ - ctx, - projectSchema, - mode, -}: { - ctx: RenderPageCtx; - projectSchema: ProjectSchema; - mode: Mode; -}): ExportModeState { - const exportInitialSelectId = useId(); - const [exportView, setExportView] = useState('landing'); - - const exportAllTask = useLongTask(); - const exportPreparingTask = useLongTask(); - const { task: exportSelectionTask, runExport: runSelectionExport } = - useSchemaExportTask({ - schema: projectSchema, - ctx, - }); - - const { - allItemTypes, - selectedIds: exportInitialItemTypeIds, - selectedItemTypes: exportInitialItemTypes, - setSelectedIds, - } = useExportSelection({ schema: projectSchema, enabled: mode === 'export' }); - - const runExportAll = useExportAllHandler({ - ctx, - schema: projectSchema, - task: exportAllTask.controller, - }); - - useEffect(() => { - setExportView('landing'); - exportPreparingTask.controller.reset(); - }, [mode, exportPreparingTask.controller]); - - const handleShowExportSelection = useCallback(() => { - setExportView('selection'); - }, []); - - const handleStartExportSelection = useCallback(() => { - if (exportInitialItemTypeIds.length === 0) { - return; - } - exportPreparingTask.controller.start({ - label: 'Preparing export…', - }); - setExportView('graph'); - }, [exportInitialItemTypeIds, exportPreparingTask.controller]); - - const handleExportGraphPrepared = useCallback(() => { - exportPreparingTask.controller.complete({ - label: 'Graph prepared', - }); - }, [exportPreparingTask.controller]); - - const handleExportPrepareProgress = useCallback( - (progress: ExportWorkflowPrepareProgress) => { - if (exportPreparingTask.state.status !== 'running') { - exportPreparingTask.controller.start(progress); - } else { - exportPreparingTask.controller.setProgress(progress); - } - }, - [exportPreparingTask.controller, exportPreparingTask.state.status], - ); - - const handleExportClose = useCallback(() => { - setExportView('selection'); - exportPreparingTask.controller.reset(); - }, [exportPreparingTask.controller]); - - const handleBackToLanding = useCallback(() => { - setExportView('landing'); - exportPreparingTask.controller.reset(); - }, [exportPreparingTask.controller]); - - const handleExportSelection = useCallback( - (itemTypeIds: string[], pluginIds: string[]) => { - if (exportInitialItemTypeIds.length === 0) { - return; - } - runSelectionExport({ - rootItemTypeId: exportInitialItemTypeIds[0], - itemTypeIds, - pluginIds, - }); - }, - [exportInitialItemTypeIds, runSelectionExport], - ); - - return { - exportInitialSelectId, - exportView, - allItemTypes, - exportInitialItemTypeIds, - exportInitialItemTypes, - setSelectedIds, - runExportAll, - exportAllTask, - exportPreparingTask, - exportSelectionTask, - handleShowExportSelection, - handleBackToLanding, - handleStartExportSelection, - handleExportGraphPrepared, - handleExportPrepareProgress, - handleExportClose, - handleExportSelection, - }; -} - type OverlayItemsArgs = { ctx: RenderPageCtx; exportSchema: [string, ExportSchema] | undefined; importTask: UseLongTaskResult; - exportAllTask: UseLongTaskResult; conflictsTask: UseLongTaskResult; - exportPreparingTask: UseLongTaskResult; - exportSelectionTask: UseLongTaskResult; }; /** @@ -368,54 +215,30 @@ function useOverlayItems({ ctx, exportSchema, importTask, - exportAllTask, conflictsTask, - exportPreparingTask, - exportSelectionTask, }: OverlayItemsArgs) { return useMemo( () => [ buildImportOverlay(ctx, importTask, exportSchema), - buildExportAllOverlay(exportAllTask), buildConflictsOverlay(conflictsTask), - buildExportPrepOverlay(exportPreparingTask), - buildExportSelectionOverlay(exportSelectionTask), - ], - [ - ctx, - exportSchema, - importTask, - exportAllTask, - conflictsTask, - exportPreparingTask, - exportSelectionTask, ], + [ctx, exportSchema, importTask, conflictsTask], ); } /** - * Unified Import/Export entrypoint rendered inside the Schema sidebar page. Handles - * file drops, conflict resolution, and still supports the legacy export view when - * the page is instantiated in export mode. + * Unified import entrypoint rendered inside the Schema sidebar page. Handles file drops + * and conflict resolution for schema imports. */ -export function ImportPage({ - ctx, - initialMode = 'import', -}: Props) { +export function ImportPage({ ctx }: Props) { const projectSchema = useProjectSchema(ctx); - const [mode, setMode] = useState(initialMode); - - const importMode = useImportMode({ ctx, projectSchema, setMode }); - const exportMode = useExportMode({ ctx, projectSchema, mode }); + const importMode = useImportMode({ ctx, projectSchema }); const overlayItems = useOverlayItems({ ctx, exportSchema: importMode.exportSchema, importTask: importMode.importTask, - exportAllTask: exportMode.exportAllTask, conflictsTask: importMode.conflictsTask, - exportPreparingTask: exportMode.exportPreparingTask, - exportSelectionTask: exportMode.exportSelectionTask, }); return ( @@ -423,38 +246,15 @@ export function ImportPage({
    - {mode === 'import' ? ( - - ) : ( - - )} +
    @@ -518,26 +318,6 @@ function buildImportOverlay( }; } -/** - * Overlay displayed when the "export everything" action is running. - */ -function buildExportAllOverlay(exportAllTask: UseLongTaskResult): OverlayConfig { - return { - id: 'export-all', - task: exportAllTask, - title: 'Exporting entire schema', - subtitle: 'Sit tight, we’re gathering models, blocks, and plugins…', - ariaLabel: 'Export in progress', - progressLabel: (progress) => progress.label ?? 'Loading project schema…', - cancel: () => ({ - label: 'Cancel export', - intent: exportAllTask.state.cancelRequested ? 'muted' : 'negative', - disabled: exportAllTask.state.cancelRequested, - onCancel: () => exportAllTask.controller.requestCancel(), - }), - }; -} - /** * Overlay used while conflicts between project and recipe are resolved. */ @@ -552,39 +332,3 @@ function buildConflictsOverlay(conflictsTask: UseLongTaskResult): OverlayConfig overlayZIndex: 9998, }; } - -/** - * Overlay surfaced as the graph view prepares the export content for preview. - */ -function buildExportPrepOverlay(exportPreparingTask: UseLongTaskResult): OverlayConfig { - return { - id: 'export-prep', - task: exportPreparingTask, - title: 'Preparing export', - subtitle: 'Sit tight, we’re setting up your models, blocks, and plugins…', - ariaLabel: 'Preparing export', - progressLabel: (progress) => progress.label ?? 'Preparing export…', - }; -} - -/** - * Overlay tied to the selection-based export task started from the graph view. - */ -function buildExportSelectionOverlay( - exportSelectionTask: UseLongTaskResult, -): OverlayConfig { - return { - id: 'export-selection', - task: exportSelectionTask, - title: 'Exporting selection', - subtitle: 'Sit tight, we’re gathering models, blocks, and plugins…', - ariaLabel: 'Export in progress', - progressLabel: (progress) => progress.label ?? 'Preparing export…', - cancel: () => ({ - label: 'Cancel export', - intent: exportSelectionTask.state.cancelRequested ? 'muted' : 'negative', - disabled: exportSelectionTask.state.cancelRequested, - onCancel: () => exportSelectionTask.controller.requestCancel(), - }), - }; -} diff --git a/import-export-schema/src/main.tsx b/import-export-schema/src/main.tsx index bdb4f282..87a173fa 100644 --- a/import-export-schema/src/main.tsx +++ b/import-export-schema/src/main.tsx @@ -62,7 +62,7 @@ connect({ // Direct navigation to the import page always boots the import screen in import-only mode. return render( }> - + , ); } @@ -87,7 +87,7 @@ connect({ // Unknown page IDs fall back to the import screen to preserve legacy deep links. return render( }> - + , ); }, diff --git a/import-export-schema/src/utils/datocms/validators.ts b/import-export-schema/src/utils/datocms/validators.ts deleted file mode 100644 index 3f8194ab..00000000 --- a/import-export-schema/src/utils/datocms/validators.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { SchemaTypes } from '@datocms/cma-client'; -import { get, set } from 'lodash-es'; -import { - validatorsContainingBlocks, - validatorsContainingLinks, -} from '@/utils/datocms/schema'; - -/** Map helper functions for trimming validator references during export/import. */ -export function collectLinkValidatorPaths( - fieldType: SchemaTypes.Field['attributes']['field_type'], -): string[] { - return [ - ...validatorsContainingLinks.filter((i) => i.field_type === fieldType), - ...validatorsContainingBlocks.filter((i) => i.field_type === fieldType), - ].map((i) => i.validator); -} - -// Clone a field's validators while keeping only allowed item type references. -export function filterValidatorIds( - field: SchemaTypes.Field, - allowedItemTypeIds: string[], -): NonNullable { - const validators = (field.attributes.validators ?? {}) as Record< - string, - unknown - >; - const paths = collectLinkValidatorPaths(field.attributes.field_type); - for (const path of paths) { - const ids = (get(validators, path) as string[]) || []; - const filtered = ids.filter((id) => allowedItemTypeIds.includes(id)); - set(validators, path, filtered); - } - return validators as NonNullable< - SchemaTypes.Field['attributes']['validators'] - >; -} diff --git a/import-export-schema/src/utils/debug.ts b/import-export-schema/src/utils/debug.ts index f1d2a634..de15b4c3 100644 --- a/import-export-schema/src/utils/debug.ts +++ b/import-export-schema/src/utils/debug.ts @@ -11,10 +11,6 @@ function readFlag(flag: string): boolean { } } -export function isDebugFlagEnabled(flag: string = DEFAULT_FLAG): boolean { - return readFlag(flag); -} - export function debugLog( message: string, payload?: unknown, diff --git a/import-export-schema/src/utils/graph/index.ts b/import-export-schema/src/utils/graph/index.ts deleted file mode 100644 index d9d3153a..00000000 --- a/import-export-schema/src/utils/graph/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Re-export graph helpers for convenient multi-file imports. -export * from './analysis'; -export * from './buildGraph'; -export * from './buildHierarchyNodes'; -export * from './dependencies'; -export * from './edges'; -export * from './nodes'; -export * from './rebuildGraphWithPositionsFromHierarchy'; -export * from './sort'; -export * from './types'; diff --git a/import-export-schema/tsconfig.app.tsbuildinfo b/import-export-schema/tsconfig.app.tsbuildinfo index 744f3037..9902203f 100644 --- a/import-export-schema/tsconfig.app.tsbuildinfo +++ b/import-export-schema/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/blankslate.tsx","./src/components/exportlandingpanel.tsx","./src/components/exportselectionpanel.tsx","./src/components/field.tsx","./src/components/fieldedgerenderer.tsx","./src/components/graphcanvas.tsx","./src/components/itemtypenoderenderer.tsx","./src/components/largeselectionlayout.tsx","./src/components/pluginnoderenderer.tsx","./src/components/progressoverlay.tsx","./src/components/progressstallnotice.tsx","./src/components/taskoverlaystack.tsx","./src/components/taskprogressoverlay.tsx","./src/components/bezier.ts","./src/components/schemaoverview/collapsible.tsx","./src/components/schemaoverview/selectedentitycontext.tsx","./src/entrypoints/config/index.tsx","./src/entrypoints/exporthome/index.tsx","./src/entrypoints/exportpage/dependencyactionspanel.tsx","./src/entrypoints/exportpage/entitiestoexportcontext.ts","./src/entrypoints/exportpage/exportitemtypenoderenderer.tsx","./src/entrypoints/exportpage/exportpluginnoderenderer.tsx","./src/entrypoints/exportpage/exportschema.ts","./src/entrypoints/exportpage/exportschemaoverview.tsx","./src/entrypoints/exportpage/exporttoolbar.tsx","./src/entrypoints/exportpage/inner.tsx","./src/entrypoints/exportpage/largeselectionview.tsx","./src/entrypoints/exportpage/buildexportdoc.ts","./src/entrypoints/exportpage/buildgraphfromschema.ts","./src/entrypoints/exportpage/index.tsx","./src/entrypoints/exportpage/useanimatednodes.tsx","./src/entrypoints/exportpage/useexportgraph.ts","./src/entrypoints/importpage/exportworkflow.tsx","./src/entrypoints/importpage/filedropzone.tsx","./src/entrypoints/importpage/importitemtypenoderenderer.tsx","./src/entrypoints/importpage/importpluginnoderenderer.tsx","./src/entrypoints/importpage/importworkflow.tsx","./src/entrypoints/importpage/inner.tsx","./src/entrypoints/importpage/largeselectionview.tsx","./src/entrypoints/importpage/resolutionsform.tsx","./src/entrypoints/importpage/buildgraphfromexportdoc.ts","./src/entrypoints/importpage/buildimportdoc.ts","./src/entrypoints/importpage/importschema.ts","./src/entrypoints/importpage/index.tsx","./src/entrypoints/importpage/userecipeloader.ts","./src/entrypoints/importpage/conflictsmanager/conflictscontext.ts","./src/entrypoints/importpage/conflictsmanager/itemtypeconflict.tsx","./src/entrypoints/importpage/conflictsmanager/pluginconflict.tsx","./src/entrypoints/importpage/conflictsmanager/buildconflicts.ts","./src/entrypoints/importpage/conflictsmanager/index.tsx","./src/shared/constants/graph.ts","./src/shared/hooks/usecmaclient.ts","./src/shared/hooks/useconflictsbuilder.ts","./src/shared/hooks/useexportallhandler.ts","./src/shared/hooks/useexportselection.ts","./src/shared/hooks/useprojectschema.ts","./src/shared/hooks/useschemaexporttask.ts","./src/shared/tasks/uselongtask.ts","./src/types/lodash-es.d.ts","./src/utils/projectschema.ts","./src/utils/createcmaclient.ts","./src/utils/debug.ts","./src/utils/downloadjson.ts","./src/utils/emojiagnosticsorter.ts","./src/utils/icons.tsx","./src/utils/isdefined.ts","./src/utils/render.tsx","./src/utils/types.ts","./src/utils/datocms/appearance.ts","./src/utils/datocms/fieldtypeinfo.ts","./src/utils/datocms/schema.ts","./src/utils/datocms/validators.ts","./src/utils/graph/analysis.ts","./src/utils/graph/buildgraph.ts","./src/utils/graph/buildhierarchynodes.ts","./src/utils/graph/dependencies.ts","./src/utils/graph/edges.ts","./src/utils/graph/index.ts","./src/utils/graph/nodes.ts","./src/utils/graph/rebuildgraphwithpositionsfromhierarchy.ts","./src/utils/graph/sort.ts","./src/utils/graph/types.ts","./src/utils/schema/exportschemasource.ts","./src/utils/schema/ischemasource.ts","./src/utils/schema/projectschemasource.ts"],"version":"5.7.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/blankslate.tsx","./src/components/exportlandingpanel.tsx","./src/components/exportselectionpanel.tsx","./src/components/field.tsx","./src/components/fieldedgerenderer.tsx","./src/components/graphcanvas.tsx","./src/components/itemtypenoderenderer.tsx","./src/components/largeselectionlayout.tsx","./src/components/pluginnoderenderer.tsx","./src/components/progressoverlay.tsx","./src/components/progressstallnotice.tsx","./src/components/taskoverlaystack.tsx","./src/components/taskprogressoverlay.tsx","./src/components/bezier.ts","./src/components/schemaoverview/collapsible.tsx","./src/components/schemaoverview/selectedentitycontext.tsx","./src/entrypoints/config/index.tsx","./src/entrypoints/exporthome/index.tsx","./src/entrypoints/exportpage/dependencyactionspanel.tsx","./src/entrypoints/exportpage/entitiestoexportcontext.ts","./src/entrypoints/exportpage/exportitemtypenoderenderer.tsx","./src/entrypoints/exportpage/exportpluginnoderenderer.tsx","./src/entrypoints/exportpage/exportschema.ts","./src/entrypoints/exportpage/exportschemaoverview.tsx","./src/entrypoints/exportpage/inner.tsx","./src/entrypoints/exportpage/largeselectionview.tsx","./src/entrypoints/exportpage/buildexportdoc.ts","./src/entrypoints/exportpage/buildgraphfromschema.ts","./src/entrypoints/exportpage/index.tsx","./src/entrypoints/exportpage/useanimatednodes.tsx","./src/entrypoints/exportpage/useexportgraph.ts","./src/entrypoints/importpage/filedropzone.tsx","./src/entrypoints/importpage/importitemtypenoderenderer.tsx","./src/entrypoints/importpage/importpluginnoderenderer.tsx","./src/entrypoints/importpage/importworkflow.tsx","./src/entrypoints/importpage/inner.tsx","./src/entrypoints/importpage/largeselectionview.tsx","./src/entrypoints/importpage/resolutionsform.tsx","./src/entrypoints/importpage/buildgraphfromexportdoc.ts","./src/entrypoints/importpage/buildimportdoc.ts","./src/entrypoints/importpage/importschema.ts","./src/entrypoints/importpage/index.tsx","./src/entrypoints/importpage/userecipeloader.ts","./src/entrypoints/importpage/conflictsmanager/conflictscontext.ts","./src/entrypoints/importpage/conflictsmanager/itemtypeconflict.tsx","./src/entrypoints/importpage/conflictsmanager/pluginconflict.tsx","./src/entrypoints/importpage/conflictsmanager/buildconflicts.ts","./src/entrypoints/importpage/conflictsmanager/index.tsx","./src/shared/constants/graph.ts","./src/shared/hooks/usecmaclient.ts","./src/shared/hooks/useconflictsbuilder.ts","./src/shared/hooks/useexportallhandler.ts","./src/shared/hooks/useexportselection.ts","./src/shared/hooks/useprojectschema.ts","./src/shared/hooks/useschemaexporttask.ts","./src/shared/tasks/uselongtask.ts","./src/types/lodash-es.d.ts","./src/utils/projectschema.ts","./src/utils/createcmaclient.ts","./src/utils/debug.ts","./src/utils/downloadjson.ts","./src/utils/emojiagnosticsorter.ts","./src/utils/icons.tsx","./src/utils/isdefined.ts","./src/utils/render.tsx","./src/utils/types.ts","./src/utils/datocms/appearance.ts","./src/utils/datocms/fieldtypeinfo.ts","./src/utils/datocms/schema.ts","./src/utils/graph/analysis.ts","./src/utils/graph/buildgraph.ts","./src/utils/graph/buildhierarchynodes.ts","./src/utils/graph/dependencies.ts","./src/utils/graph/edges.ts","./src/utils/graph/nodes.ts","./src/utils/graph/rebuildgraphwithpositionsfromhierarchy.ts","./src/utils/graph/sort.ts","./src/utils/graph/types.ts","./src/utils/schema/exportschemasource.ts","./src/utils/schema/ischemasource.ts","./src/utils/schema/projectschemasource.ts"],"version":"5.7.3"} \ No newline at end of file From f3ba36bdb35c5ebf1e81e09c5506dffeda7a45ac Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Mon, 22 Sep 2025 17:37:01 +0200 Subject: [PATCH 18/36] refactor --- .../LargeSelectionColumns.module.css | 55 ++++ .../src/components/LargeSelectionColumns.tsx | 69 +++++ .../ExportPage/LargeSelectionView.module.css | 56 ---- .../ExportPage/LargeSelectionView.tsx | 287 ++++++++---------- .../ImportPage/LargeSelectionView.module.css | 62 ---- .../ImportPage/LargeSelectionView.tsx | 224 ++++++-------- 6 files changed, 352 insertions(+), 401 deletions(-) create mode 100644 import-export-schema/src/components/LargeSelectionColumns.module.css create mode 100644 import-export-schema/src/components/LargeSelectionColumns.tsx diff --git a/import-export-schema/src/components/LargeSelectionColumns.module.css b/import-export-schema/src/components/LargeSelectionColumns.module.css new file mode 100644 index 00000000..c0bcfc42 --- /dev/null +++ b/import-export-schema/src/components/LargeSelectionColumns.module.css @@ -0,0 +1,55 @@ +.column { + padding: 16px; + overflow: auto; + min-height: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.modelsColumn { + flex: 2; +} + +.pluginsColumn { + flex: 1; + border-left: 1px solid var(--border-color); +} + +.list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0; +} + +.listItem { + border-bottom: 1px solid var(--border-color); + padding: 10px 4px; +} + +.apikey { + color: var(--light-body-color); +} + +.badge { + font-size: 11px; + color: #3b82f6; + background: rgba(59, 130, 246, 0.08); + border: 1px solid rgba(59, 130, 246, 0.25); + border-radius: 4px; + padding: 1px 6px; +} + +.relationships { + color: var(--light-body-color); + font-size: 12px; + margin-top: 4px; +} + +.sectionTitle { + font-weight: 700; + letter-spacing: -0.2px; +} diff --git a/import-export-schema/src/components/LargeSelectionColumns.tsx b/import-export-schema/src/components/LargeSelectionColumns.tsx new file mode 100644 index 00000000..ee768c1f --- /dev/null +++ b/import-export-schema/src/components/LargeSelectionColumns.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from 'react'; +import { findInboundEdges, findOutboundEdges } from '@/utils/graph/analysis'; +import type { ItemTypeNode } from '@/components/ItemTypeNodeRenderer'; +import type { PluginNode } from '@/components/PluginNodeRenderer'; +import type { Graph } from '@/utils/graph/types'; +import sharedStyles from './LargeSelectionColumns.module.css'; + +export type ItemTypeRowRenderArgs = { + node: ItemTypeNode; + inboundEdges: Graph['edges']; + outboundEdges: Graph['edges']; +}; + +export type PluginRowRenderArgs = { + node: PluginNode; + inboundEdges: Graph['edges']; +}; + +export type LargeSelectionColumnsProps = { + graph: Graph; + filteredItemTypeNodes: ItemTypeNode[]; + filteredPluginNodes: PluginNode[]; + renderItemTypeRow: (args: ItemTypeRowRenderArgs) => ReactNode; + renderPluginRow: (args: PluginRowRenderArgs) => ReactNode; +}; + +/** + * Shared renderer for the list-based large-selection views. It gathers relationship + * metrics and column scaffolding so individual variants only provide row content. + */ +export function LargeSelectionColumns({ + graph, + filteredItemTypeNodes, + filteredPluginNodes, + renderItemTypeRow, + renderPluginRow, +}: LargeSelectionColumnsProps) { + return ( + <> +
    +
    Models
    +
      + {filteredItemTypeNodes.map((node) => { + const itemType = node.data.itemType; + const inboundEdges = findInboundEdges( + graph, + `itemType--${itemType.id}`, + ); + const outboundEdges = findOutboundEdges( + graph, + `itemType--${itemType.id}`, + ); + return renderItemTypeRow({ node, inboundEdges, outboundEdges }); + })} +
    +
    +
    +
    Plugins
    +
      + {filteredPluginNodes.map((node) => { + const plugin = node.data.plugin; + const inboundEdges = findInboundEdges(graph, `plugin--${plugin.id}`); + return renderPluginRow({ node, inboundEdges }); + })} +
    +
    + + ); +} diff --git a/import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.module.css b/import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.module.css index 0fa43940..a5d34ba1 100644 --- a/import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.module.css +++ b/import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.module.css @@ -1,35 +1,3 @@ -.column { - padding: 16px; - overflow: auto; - min-height: 0; - display: flex; - flex-direction: column; - gap: 12px; -} - -.modelsColumn { - flex: 2; -} - -.pluginsColumn { - flex: 1; - border-left: 1px solid var(--border-color); -} - -.list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0; -} - -.listItem { - border-bottom: 1px solid var(--border-color); - padding: 10px 4px; -} - .modelRow, .pluginRow { display: grid; @@ -60,25 +28,6 @@ gap: 4px; } -.apikey { - color: var(--light-body-color); -} - -.badge { - font-size: 11px; - color: #3b82f6; - background: rgba(59, 130, 246, 0.08); - border: 1px solid rgba(59, 130, 246, 0.25); - border-radius: 4px; - padding: 1px 6px; -} - -.relationships { - color: var(--light-body-color); - font-size: 12px; - margin-top: 4px; -} - .actions { display: flex; align-items: center; @@ -122,8 +71,3 @@ .footerSpacer { flex: 1; } - -.sectionTitle { - font-weight: 700; - letter-spacing: -0.2px; -} diff --git a/import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.tsx b/import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.tsx index ad5a4e8d..8519b5b7 100644 --- a/import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.tsx +++ b/import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.tsx @@ -1,9 +1,11 @@ import type { SchemaTypes } from '@datocms/cma-client'; import { Button, Spinner } from 'datocms-react-ui'; import { useMemo, useState } from 'react'; -import { findInboundEdges, findOutboundEdges } from '@/utils/graph/analysis'; +import { findInboundEdges } from '@/utils/graph/analysis'; import { LargeSelectionLayout } from '@/components/LargeSelectionLayout'; import type { Graph } from '@/utils/graph/types'; +import { LargeSelectionColumns } from '@/components/LargeSelectionColumns'; +import sharedStyles from '@/components/LargeSelectionColumns.module.css'; import styles from './LargeSelectionView.module.css'; type Props = { @@ -83,164 +85,137 @@ export default function LargeSelectionView({ searchLabel="Search" searchPlaceholder="Filter models and plugins" renderContent={({ filteredItemTypeNodes, filteredPluginNodes }) => ( - <> -
    - Models -
      - {filteredItemTypeNodes.map((node) => { - const itemType = node.data.itemType; - const locked = initialItemTypeIdSet.has(itemType.id); - const checked = selectedItemTypeIds.includes(itemType.id); - const inbound = findInboundEdges( - graph, - `itemType--${itemType.id}`, - ); - const outbound = findOutboundEdges( - graph, - `itemType--${itemType.id}`, - ); - const isExpanded = expandedWhy.has(itemType.id); - const reasons = findInboundEdges( - graph, - `itemType--${itemType.id}`, - selectedSourceSet, - ); + { + const itemType = node.data.itemType; + const locked = initialItemTypeIdSet.has(itemType.id); + const checked = selectedItemTypeIds.includes(itemType.id); + const isExpanded = expandedWhy.has(itemType.id); + const reasons = findInboundEdges( + graph, + `itemType--${itemType.id}`, + selectedSourceSet, + ); - return ( -
    • -
      - toggleItemType(itemType.id)} - className={styles.checkbox} - /> -
      -
      - {itemType.attributes.name}{' '} - - ({itemType.attributes.api_key}) - {' '} - - {itemType.attributes.modular_block - ? 'Block' - : 'Model'} - -
      -
      - - ← {inbound.length} inbound - {' '} - •{' '} - - → {outbound.length} outbound - -
      -
      -
      - {reasons.length > 0 ? ( - - ) : null} -
      + return ( +
    • +
      + toggleItemType(itemType.id)} + className={styles.checkbox} + /> +
      +
      + {itemType.attributes.name}{' '} + + ({itemType.attributes.api_key}) + {' '} + + {itemType.attributes.modular_block ? 'Block' : 'Model'} +
      - {isExpanded && reasons.length > 0 ? ( -
      -
      Included because:
      -
        - {reasons.map((edge) => { - const sourceNode = graph.nodes.find( - (nd) => nd.id === edge.source, - ); - if (!sourceNode) return null; - const sourceItemType = - sourceNode.type === 'itemType' - ? sourceNode.data.itemType - : undefined; - return ( -
      • - {sourceItemType ? ( - <> - Selected model{' '} - - {sourceItemType.attributes.name} - {' '} - references it via fields:{' '} - - - ) : ( - <> - Referenced in fields:{' '} - - - )} -
      • - ); - })} -
      -
      +
      + + ← {inboundEdges.length} inbound + {' '} + •{' '} + + → {outboundEdges.length} outbound + +
      +
      +
      + {reasons.length > 0 ? ( + ) : null} -
    • - ); - })} -
    -
    -
    - Plugins -
      - {filteredPluginNodes.map((node) => { - const plugin = node.data.plugin; - const checked = selectedPluginIds.includes(plugin.id); - const inbound = findInboundEdges( - graph, - `plugin--${plugin.id}`, - ); +
    +
    + {isExpanded && reasons.length > 0 ? ( +
    +
    Included because:
    +
      + {reasons.map((edge) => { + const sourceNode = graph.nodes.find( + (nd) => nd.id === edge.source, + ); + if (!sourceNode) return null; + const sourceItemType = + sourceNode.type === 'itemType' + ? sourceNode.data.itemType + : undefined; + return ( +
    • + {sourceItemType ? ( + <> + Selected model{' '} + + {sourceItemType.attributes.name} + {' '} + references it via fields:{' '} + + + ) : ( + <> + Referenced in fields:{' '} + + + )} +
    • + ); + })} +
    +
    + ) : null} + + ); + }} + renderPluginRow={({ node, inboundEdges }) => { + const plugin = node.data.plugin; + const checked = selectedPluginIds.includes(plugin.id); - return ( -
  • -
    - togglePlugin(plugin.id)} - className={styles.checkbox} - /> -
    -
    - {plugin.attributes.name} -
    -
    - ← {inbound.length} inbound from models -
    -
    + return ( +
  • +
    + togglePlugin(plugin.id)} + className={styles.checkbox} + /> +
    +
    {plugin.attributes.name}
    +
    + ← {inboundEdges.length} inbound from models
    -
  • - ); - })} - -
    - +
    +
    + + ); + }} + /> )} renderFooter={() => (
    @@ -276,10 +251,6 @@ export default function LargeSelectionView({ ); } -function SectionTitle({ children }: { children: React.ReactNode }) { - return
    {children}
    ; -} - function FieldsList({ fields }: { fields: SchemaTypes.Field[] }) { if (!fields || fields.length === 0) return unknown fields; return ( diff --git a/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.module.css b/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.module.css index 31396244..9d6dc9ea 100644 --- a/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.module.css +++ b/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.module.css @@ -1,41 +1,3 @@ -.columns { - display: flex; - flex: 1; - min-height: 0; -} - -.column { - padding: 16px; - overflow: auto; - min-height: 0; - display: flex; - flex-direction: column; - gap: 12px; -} - -.modelsColumn { - flex: 2; -} - -.pluginsColumn { - flex: 1; - border-left: 1px solid var(--border-color); -} - -.list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0; -} - -.listItem { - border-bottom: 1px solid var(--border-color); - padding: 10px 4px; -} - .rowButton, .rowButtonActive { width: 100%; @@ -72,27 +34,3 @@ flex-wrap: wrap; gap: 4px; } - -.apikey { - color: var(--light-body-color); -} - -.badge { - font-size: 11px; - color: #3b82f6; - background: rgba(59, 130, 246, 0.08); - border: 1px solid rgba(59, 130, 246, 0.25); - border-radius: 4px; - padding: 1px 6px; -} - -.relationships { - color: var(--light-body-color); - font-size: 12px; - margin-top: 4px; -} - -.sectionTitle { - font-weight: 700; - letter-spacing: -0.2px; -} diff --git a/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.tsx b/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.tsx index 01878617..8addeec2 100644 --- a/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.tsx @@ -1,10 +1,11 @@ import type { SchemaTypes } from '@datocms/cma-client'; import { Button } from 'datocms-react-ui'; import { useContext } from 'react'; -import { findInboundEdges, findOutboundEdges } from '@/utils/graph/analysis'; import { LargeSelectionLayout } from '@/components/LargeSelectionLayout'; import type { Graph } from '@/utils/graph/types'; import { SelectedEntityContext } from '@/components/SchemaOverview/SelectedEntityContext'; +import { LargeSelectionColumns } from '@/components/LargeSelectionColumns'; +import sharedStyles from '@/components/LargeSelectionColumns.module.css'; import styles from './LargeSelectionView.module.css'; type Props = { @@ -39,135 +40,108 @@ export default function LargeSelectionView({ graph, onSelect }: Props) { searchPlaceholder="Filter models and plugins" headerNotice="Graph view is hidden due to size." renderContent={({ filteredItemTypeNodes, filteredPluginNodes }) => ( -
    -
    - Models -
      - {filteredItemTypeNodes.map((node) => { - const itemType = node.data.itemType; - const inbound = findInboundEdges( - graph, - `itemType--${itemType.id}`, - ); - const outbound = findOutboundEdges( - graph, - `itemType--${itemType.id}`, - ); - const selectedRow = isItemTypeSelected(itemType.id); + { + const itemType = node.data.itemType; + const selectedRow = isItemTypeSelected(itemType.id); - return ( -
    • - -
    -
    - - ← {inbound.length} inbound - {' '} - •{' '} - - → {outbound.length} outbound - -
    + return ( +
  • + -
  • - ); - })} - - -
    - Plugins -
      - {filteredPluginNodes.map((node) => { - const plugin = node.data.plugin; - const inbound = findInboundEdges( - graph, - `plugin--${plugin.id}`, - ); - const selectedRow = isPluginSelected(plugin.id); + +
    +
    + + ← {inboundEdges.length} inbound + {' '} + •{' '} + + → {outboundEdges.length} outbound + +
    +
    + + + ); + }} + renderPluginRow={({ node, inboundEdges }) => { + const plugin = node.data.plugin; + const selectedRow = isPluginSelected(plugin.id); - return ( -
  • - -
  • -
    - ← {inbound.length} inbound from models -
    + return ( +
  • + -
  • - ); - })} - - -
    + +
    +
    + ← {inboundEdges.length} inbound from models +
    +
    + + + ); + }} + /> )} /> ); } - -function SectionTitle({ children }: { children: React.ReactNode }) { - return ( -
    {children}
    - ); -} From a0ea958308ce41f4f8dc5b712bbd716779b30067 Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Mon, 22 Sep 2025 17:56:23 +0200 Subject: [PATCH 19/36] better close --- .../ImportPage/ConflictsManager/index.tsx | 12 ------------ .../src/entrypoints/ImportPage/Inner.tsx | 18 ++++++++++++++++++ import-export-schema/src/index.css | 1 + 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx index 5ea8c5c0..073a18d3 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx @@ -344,18 +344,6 @@ export default function ConflictsManager({ {(() => { return null; })()} - {(() => { const proceedDisabled = submitting || !valid || validating; return ( diff --git a/import-export-schema/src/entrypoints/ImportPage/Inner.tsx b/import-export-schema/src/entrypoints/ImportPage/Inner.tsx index 69038b69..c89a70bb 100644 --- a/import-export-schema/src/entrypoints/ImportPage/Inner.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/Inner.tsx @@ -6,6 +6,9 @@ import { ReactFlow, useReactFlow, } from '@xyflow/react'; +import { Button } from 'datocms-react-ui'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faXmark } from '@fortawesome/free-solid-svg-icons'; import { useCallback, useEffect, useState } from 'react'; import { GRAPH_NODE_THRESHOLD } from '@/shared/constants/graph'; import { type AppNode, edgeTypes, type Graph } from '@/utils/graph/types'; @@ -99,6 +102,10 @@ export function Inner({ exportSchema, schema, ctx: _ctx }: Props) { } } + const requestCancel = useCallback(() => { + window.dispatchEvent(new CustomEvent('import:request-cancel')); + }, []); + const totalPotentialNodes = exportSchema.itemTypes.length + exportSchema.plugins.length; @@ -125,6 +132,17 @@ export function Inner({ exportSchema, schema, ctx: _ctx }: Props) { className="import__graph" style={{ position: 'relative', height: '100%' }} > +
    + +
    {graph && showGraph && ( Date: Mon, 22 Sep 2025 18:25:49 +0200 Subject: [PATCH 20/36] sidebar --- .../ConflictsManager/PluginConflict.tsx | 6 ------ import-export-schema/src/index.css | 15 ++++++++++----- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx index 1d6e9581..35d90959 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx @@ -1,5 +1,4 @@ import type { SchemaTypes } from '@datocms/cma-client'; -import { useReactFlow } from '@xyflow/react'; import { SelectField } from 'datocms-react-ui'; import { useId } from 'react'; import { Field } from 'react-final-form'; @@ -30,11 +29,6 @@ export function PluginConflict({ exportPlugin, projectPlugin }: Props) { const selectId = useId(); const fieldPrefix = `plugin-${exportPlugin.id}`; const resolution = useResolutionStatusForPlugin(exportPlugin.id); - const node = useReactFlow().getNode(`plugin--${exportPlugin.id}`); - - if (!node) { - return null; - } const strategy = resolution?.values?.strategy; const hasValidResolution = Boolean( diff --git a/import-export-schema/src/index.css b/import-export-schema/src/index.css index 773f2364..7dc269c1 100644 --- a/import-export-schema/src/index.css +++ b/import-export-schema/src/index.css @@ -916,6 +916,16 @@ button.chip:focus-visible { /* Slightly tighter padding for redesigned conflicts UI */ } +.import__details .page__content { + padding: 0 var(--spacing-l) var(--spacing-xl); + box-sizing: border-box; +} + +.import__details .conflicts-manager__group__content { + display: grid; + gap: var(--spacing-m); +} + .import__graph-close, .export__graph-close { position: absolute; @@ -942,7 +952,6 @@ button.chip:focus-visible { border: 1px solid rgba(217, 48, 37, 0.22); border-radius: 8px; background: rgba(217, 48, 37, 0.04); - margin: 2px 0; } .conflict--has-conflict.conflict--selected { @@ -955,10 +964,6 @@ button.chip:focus-visible { border-top: 1px solid rgba(217, 48, 37, 0.22); } -.conflict--has-conflict + .conflict { - border-top: 1px solid var(--border-color); -} - .conflict__title { font-weight: 700; appearance: none; From e0284073291ce319ff6e7e705b1d846c526a8b83 Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Mon, 22 Sep 2025 18:46:37 +0200 Subject: [PATCH 21/36] final design --- import-export-schema/src/index.css | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/import-export-schema/src/index.css b/import-export-schema/src/index.css index 7dc269c1..0ca3e25d 100644 --- a/import-export-schema/src/index.css +++ b/import-export-schema/src/index.css @@ -797,7 +797,7 @@ button.chip:focus-visible { } /* Export page: prevent outer content scrolling; only inner panes scroll */ -.page--export .page__content { +.page--export > .page__content { overflow: hidden; overflow-y: hidden; min-height: 0; @@ -1083,16 +1083,16 @@ button.chip:focus-visible { transition: border-color 0.2s ease, background-color 0.2s ease; } -.schema-overview__item.conflict { +.import__details .schema-overview__item.conflict { border-bottom: none; border-top: none; } -.schema-overview__item.conflict:first-child { +.import__details .schema-overview__item.conflict:first-child { border-top: none; } -.schema-overview__item.conflict + .conflict { +.import__details .schema-overview__item.conflict + .conflict { border-top: none; } @@ -1117,6 +1117,18 @@ button.chip:focus-visible { color: var(--base-body-color); } +.export__details .schema-overview__item { + border: 1px solid var(--border-color); +} + +.export__details .schema-overview__item.schema-overview__item--selected { + border-color: rgba(25, 135, 84, 0.35); +} + +.export__details .schema-overview__item.schema-overview__item--unselected { + border-color: var(--border-color); +} + /* Pretty export overlay styles */ .export-overlay__card { background: #fff; From 8d083ec0a1933ec4133476b6b6b22055a4ad63f2 Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Mon, 22 Sep 2025 20:10:23 +0200 Subject: [PATCH 22/36] refactor --- import-export-schema/src/components/Field.tsx | 6 +----- .../src/components/FieldEdgeRenderer.tsx | 6 +----- .../src/components/LargeSelectionLayout.tsx | 9 ++++++++- .../src/components/PluginNodeRenderer.tsx | 6 +----- .../SchemaOverview/SelectedEntityContext.tsx | 18 ------------------ .../src/components/TaskOverlayStack.tsx | 5 +++++ .../src/components/TaskProgressOverlay.tsx | 14 ++++++++++++-- import-export-schema/src/components/bezier.ts | 17 +---------------- .../ExportPage/EntitiesToExportContext.ts | 3 --- 9 files changed, 29 insertions(+), 55 deletions(-) delete mode 100644 import-export-schema/src/components/SchemaOverview/SelectedEntityContext.tsx diff --git a/import-export-schema/src/components/Field.tsx b/import-export-schema/src/components/Field.tsx index 326b139b..c17db0cd 100644 --- a/import-export-schema/src/components/Field.tsx +++ b/import-export-schema/src/components/Field.tsx @@ -1,19 +1,15 @@ -import type { SchemaTypes } from '@datocms/cma-client'; import { fieldGroupColors, fieldTypeDescriptions, fieldTypeGroups, } from '@/utils/datocms/schema'; +import type { SchemaTypes } from '@datocms/cma-client'; -/** - * Displays a field summary with consistent iconography and type information. - */ export function Field({ field }: { field: SchemaTypes.Field }) { const group = fieldTypeGroups.find((g) => g.types.includes(field.attributes.field_type), ); - // Fallback to the generic JSON icon/color when the field type has no group match. const { IconComponent, bgColor, fgColor } = fieldGroupColors[group ? group.name : 'json']; diff --git a/import-export-schema/src/components/FieldEdgeRenderer.tsx b/import-export-schema/src/components/FieldEdgeRenderer.tsx index b853f1aa..e0b6d616 100644 --- a/import-export-schema/src/components/FieldEdgeRenderer.tsx +++ b/import-export-schema/src/components/FieldEdgeRenderer.tsx @@ -10,9 +10,6 @@ import { getBezierPath, getSelfPath } from './bezier'; export type FieldEdge = Edge<{ fields: SchemaTypes.Field[] }, 'field'>; -/** - * Custom React Flow edge that renders a tooltip listing the fields linking two nodes. - */ export function FieldEdgeRenderer({ id, source, @@ -31,8 +28,7 @@ export function FieldEdgeRenderer({ const [edgePath, labelX, labelY] = source === target - ? // Self-references loop back to the node so the label has space to render. - getSelfPath({ + ? getSelfPath({ sourceX, sourceY, sourcePosition, diff --git a/import-export-schema/src/components/LargeSelectionLayout.tsx b/import-export-schema/src/components/LargeSelectionLayout.tsx index 432b6048..58477988 100644 --- a/import-export-schema/src/components/LargeSelectionLayout.tsx +++ b/import-export-schema/src/components/LargeSelectionLayout.tsx @@ -10,6 +10,9 @@ import type { PluginNode } from '@/components/PluginNodeRenderer'; import type { Graph } from '@/utils/graph/types'; import styles from './LargeSelectionLayout.module.css'; +// Centralized UI shell for the "large selection" experiences so both import and export +// pages can share the same graph overview, search affordances, and layout framing. + export type LargeSelectionLayoutRenderArgs = { itemTypeNodes: ItemTypeNode[]; pluginNodes: PluginNode[]; @@ -35,7 +38,7 @@ type Props = { /** * Shared scaffold for the large-selection fallback used by both import and export flows. - * Handles metrics, search filtering, and layout so the variants only worry about row UI. + * Packages graph analytics, filtering, and chrome so each flow only supplies row renderers. */ export function LargeSelectionLayout({ graph, @@ -48,11 +51,15 @@ export function LargeSelectionLayout({ const searchInputId = useId(); const [query, setQuery] = useState(''); + // Split the graph into the node buckets each flow expects to render while keeping the + // original graph intact for metrics and dependency calculations. const { itemTypeNodes, pluginNodes } = useMemo( () => splitNodesByType(graph), [graph], ); + // Precompute graph-wide metrics so both import/export screens surface the same context + // about model/plugin counts, connectivity, and potential cycle issues. const metrics = useMemo(() => { const components = getConnectedComponents(graph).length; const cycles = countCycles(graph); diff --git a/import-export-schema/src/components/PluginNodeRenderer.tsx b/import-export-schema/src/components/PluginNodeRenderer.tsx index 66165960..c34c5e90 100644 --- a/import-export-schema/src/components/PluginNodeRenderer.tsx +++ b/import-export-schema/src/components/PluginNodeRenderer.tsx @@ -1,3 +1,4 @@ +import { Schema } from '@/utils/icons'; import type { SchemaTypes } from '@datocms/cma-client'; import { Handle, @@ -8,7 +9,6 @@ import { useStore, } from '@xyflow/react'; import classNames from 'classnames'; -import { Schema } from '@/utils/icons'; export type PluginNode = Node< { @@ -17,12 +17,8 @@ export type PluginNode = Node< 'plugin' >; -// Only reveal meta information when zoomed in far enough. const zoomSelector = (s: ReactFlowState) => s.transform[2] >= 0.8; -/** - * React Flow node renderer used to visualize installed plugins within dependency graphs. - */ export function PluginNodeRenderer({ data: { plugin }, className, diff --git a/import-export-schema/src/components/SchemaOverview/SelectedEntityContext.tsx b/import-export-schema/src/components/SchemaOverview/SelectedEntityContext.tsx deleted file mode 100644 index a9103b53..00000000 --- a/import-export-schema/src/components/SchemaOverview/SelectedEntityContext.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { SchemaTypes } from '@datocms/cma-client'; -import { createContext } from 'react'; - -type Context = { - entity: undefined | SchemaTypes.ItemType | SchemaTypes.Plugin; - set: ( - newEntity: undefined | SchemaTypes.ItemType | SchemaTypes.Plugin, - zoomIn?: boolean, - ) => void; -}; - -/** - * Shares the currently highlighted entity between the graph and the detail sidebar. - */ -export const SelectedEntityContext = createContext({ - entity: undefined, - set: () => {}, -}); diff --git a/import-export-schema/src/components/TaskOverlayStack.tsx b/import-export-schema/src/components/TaskOverlayStack.tsx index e9f919c5..2301b5b9 100644 --- a/import-export-schema/src/components/TaskOverlayStack.tsx +++ b/import-export-schema/src/components/TaskOverlayStack.tsx @@ -3,6 +3,9 @@ import { type TaskProgressOverlayProps, } from '@/components/TaskProgressOverlay'; +// Small wrapper that lets import/export flows list every long-running task overlay they +// need (single export, mass export, import) without repeating overlay wiring at each call. + type OverlayConfig = TaskProgressOverlayProps & { id?: string | number }; type Props = { @@ -11,6 +14,8 @@ type Props = { /** * Render a list of long-task overlays while keeping individual config definitions concise. + * Centralizes the overlay stack so the entrypoints simply hand us the configs for any + * concurrent export/import tasks instead of juggling multiple ``s. */ export function TaskOverlayStack({ items }: Props) { return ( diff --git a/import-export-schema/src/components/TaskProgressOverlay.tsx b/import-export-schema/src/components/TaskProgressOverlay.tsx index e7da4c09..9060c854 100644 --- a/import-export-schema/src/components/TaskProgressOverlay.tsx +++ b/import-export-schema/src/components/TaskProgressOverlay.tsx @@ -5,6 +5,9 @@ import type { UseLongTaskResult, } from '@/shared/tasks/useLongTask'; +// Adapter that presents a running `useLongTask` instance inside the shared modal overlay UI +// used throughout the import/export flows. + type CancelOptions = { label: string; onCancel: () => void | Promise; @@ -24,8 +27,9 @@ export type TaskProgressOverlayProps = { }; /** - * Convenience wrapper over `ProgressOverlay` that wires up a `useLongTask` instance and - * allows callers to customize messaging via small callbacks. + * Convenience wrapper over the shared modal overlay component so long-running imports and + * exports surface consistent progress UI. Callers pass their `useLongTask` handle plus + * lightweight callbacks for dynamic subtitles, labels, or cancel behavior. */ export function TaskProgressOverlay({ task, @@ -38,11 +42,15 @@ export function TaskProgressOverlay({ cancel, }: TaskProgressOverlayProps) { if (task.state.status !== 'running') { + // The overlay only renders while the task is active; once it resolves the modal + // disappears so the page can show completion state instead. return null; } const state = task.state; const progress = state.progress; + // Subtitle/label hooks let the overlay speak to the current step (eg "Fetching records") + // without duplicating formatting logic where the task is started. const resolvedSubtitle = typeof subtitle === 'function' ? subtitle(state) : subtitle; const label = progressLabel ? progressLabel(progress, state) : progress.label; @@ -50,6 +58,8 @@ export function TaskProgressOverlay({ return ( = 0) { return 0.5 * distance; @@ -140,7 +131,6 @@ type GetControlWithCurvatureParams = { c: number; }; -/** Deduce control points based on the port position and desired curvature. */ function getControlWithCurvature({ pos, x1, @@ -161,7 +151,6 @@ function getControlWithCurvature({ } } -/** Numerically approximate the arc length for a cubic Bézier segment. */ function calculateBezierLength( sourceX: number, sourceY: number, @@ -204,7 +193,7 @@ function calculateBezierLength( } /** - * Find the `t` value where the curve length reaches `targetLength` using bisection. + * Find the t value for which the arc length of the cubic Bézier curve equals the target length */ function getTForLength( sourceX: number, @@ -250,9 +239,6 @@ function getTForLength( return t; } -/** - * Generate a cubic Bézier path between two nodes and return coordinates suitable for React Flow. - */ export function getBezierPath({ sourceX, sourceY, @@ -280,7 +266,6 @@ export function getBezierPath({ c: curvature, }); - // Place the edge label ~40px along the line so multi-field tooltips stay close to the source. const labelPercent = getTForLength( targetX, targetY, diff --git a/import-export-schema/src/entrypoints/ExportPage/EntitiesToExportContext.ts b/import-export-schema/src/entrypoints/ExportPage/EntitiesToExportContext.ts index 69ac5ec5..f94ba24b 100644 --- a/import-export-schema/src/entrypoints/ExportPage/EntitiesToExportContext.ts +++ b/import-export-schema/src/entrypoints/ExportPage/EntitiesToExportContext.ts @@ -1,8 +1,5 @@ import { createContext } from 'react'; -/** - * Provides the currently selected export entities so node renderers can mark excluded items. - */ export const EntitiesToExportContext = createContext< undefined | { itemTypeIds: string[]; pluginIds: string[] } >(undefined); From 674ecfe87893885d37ca5589126f28baa28de7ac Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Tue, 23 Sep 2025 12:57:52 +0200 Subject: [PATCH 23/36] new large view --- import-export-schema/README.md | 4 +- .../LargeSelectionColumns.module.css | 55 ---- .../src/components/LargeSelectionColumns.tsx | 69 ----- .../LargeSelectionLayout.module.css | 60 ---- .../src/components/LargeSelectionLayout.tsx | 146 ---------- .../SchemaOverview/SelectedEntityContext.tsx | 15 + .../src/entrypoints/ExportPage/Inner.tsx | 146 ++++++---- .../ExportPage/LargeSelectionView.module.css | 73 ----- .../ExportPage/LargeSelectionView.tsx | 263 ------------------ .../ConflictsManager/ItemTypeConflict.tsx | 9 +- .../ConflictsManager/PluginConflict.tsx | 9 +- .../ImportPage/GraphEntitiesContext.tsx | 11 + .../src/entrypoints/ImportPage/Inner.tsx | 211 +++++++++----- .../ImportPage/LargeSelectionView.module.css | 36 --- .../ImportPage/LargeSelectionView.tsx | 147 ---------- .../ImportPage/ResolutionsForm.tsx | 18 +- import-export-schema/tsconfig.app.tsbuildinfo | 2 +- 17 files changed, 274 insertions(+), 1000 deletions(-) delete mode 100644 import-export-schema/src/components/LargeSelectionColumns.module.css delete mode 100644 import-export-schema/src/components/LargeSelectionColumns.tsx delete mode 100644 import-export-schema/src/components/LargeSelectionLayout.module.css delete mode 100644 import-export-schema/src/components/LargeSelectionLayout.tsx create mode 100644 import-export-schema/src/components/SchemaOverview/SelectedEntityContext.tsx delete mode 100644 import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.module.css delete mode 100644 import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.tsx create mode 100644 import-export-schema/src/entrypoints/ImportPage/GraphEntitiesContext.tsx delete mode 100644 import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.module.css delete mode 100644 import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.tsx diff --git a/import-export-schema/README.md b/import-export-schema/README.md index 725c4fb7..82676d3e 100644 --- a/import-export-schema/README.md +++ b/import-export-schema/README.md @@ -85,7 +85,7 @@ Powerful, safe schema migration for DatoCMS. Export models/blocks and plugins as - `useConflictsBuilder` drives conflict analysis with `useLongTask`; `useRecipeLoader` watches `recipe_url` query params for shared exports. - Shared UI: - `TaskOverlayStack` + `TaskProgressOverlay` render cancellable overlays with `ProgressOverlay` stall detection. - - `GraphCanvas`, `LargeSelectionLayout`, and the Schema Overview components keep the export/import visualizations consistent. + - `GraphCanvas` and the Schema Overview components keep the export/import visualizations consistent, with large graph warnings gating heavy renders. - Schema utilities: - `ProjectSchema` provides cached lookups plus concurrency-limited `getItemTypeFieldsAndFieldsets` calls. - `buildExportDoc` trims validators/appearances so exports stay self-contained; `buildImportDoc` + `importSchema` orchestrate plugin installs, item type creation, field migrations, and reorder passes. @@ -104,7 +104,7 @@ Powerful, safe schema migration for DatoCMS. Export models/blocks and plugins as ## Troubleshooting -- “Why did the graph disappear?” For very large selections, the UI switches to a faster list view. +- “Why did the graph disappear?” Large selections now show a warning instead of auto-rendering; click “Render it anyway” to view the full graph. - “Fields lost their editor?” If you don’t include a custom editor plugin in the export/import, the plugin selects a safe, built‑in editor so the field remains valid in the target project. - “Plugin dependencies were skipped?” Check for the banner warning about incomplete plugin detection and rerun “Select all dependencies” after reopening the page once the CMA call succeeds. - “Cancel didn’t stop immediately?” The import/export pipeline stops at the next safe checkpoint; keep the overlay open until it confirms cancellation. diff --git a/import-export-schema/src/components/LargeSelectionColumns.module.css b/import-export-schema/src/components/LargeSelectionColumns.module.css deleted file mode 100644 index c0bcfc42..00000000 --- a/import-export-schema/src/components/LargeSelectionColumns.module.css +++ /dev/null @@ -1,55 +0,0 @@ -.column { - padding: 16px; - overflow: auto; - min-height: 0; - display: flex; - flex-direction: column; - gap: 12px; -} - -.modelsColumn { - flex: 2; -} - -.pluginsColumn { - flex: 1; - border-left: 1px solid var(--border-color); -} - -.list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0; -} - -.listItem { - border-bottom: 1px solid var(--border-color); - padding: 10px 4px; -} - -.apikey { - color: var(--light-body-color); -} - -.badge { - font-size: 11px; - color: #3b82f6; - background: rgba(59, 130, 246, 0.08); - border: 1px solid rgba(59, 130, 246, 0.25); - border-radius: 4px; - padding: 1px 6px; -} - -.relationships { - color: var(--light-body-color); - font-size: 12px; - margin-top: 4px; -} - -.sectionTitle { - font-weight: 700; - letter-spacing: -0.2px; -} diff --git a/import-export-schema/src/components/LargeSelectionColumns.tsx b/import-export-schema/src/components/LargeSelectionColumns.tsx deleted file mode 100644 index ee768c1f..00000000 --- a/import-export-schema/src/components/LargeSelectionColumns.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import type { ReactNode } from 'react'; -import { findInboundEdges, findOutboundEdges } from '@/utils/graph/analysis'; -import type { ItemTypeNode } from '@/components/ItemTypeNodeRenderer'; -import type { PluginNode } from '@/components/PluginNodeRenderer'; -import type { Graph } from '@/utils/graph/types'; -import sharedStyles from './LargeSelectionColumns.module.css'; - -export type ItemTypeRowRenderArgs = { - node: ItemTypeNode; - inboundEdges: Graph['edges']; - outboundEdges: Graph['edges']; -}; - -export type PluginRowRenderArgs = { - node: PluginNode; - inboundEdges: Graph['edges']; -}; - -export type LargeSelectionColumnsProps = { - graph: Graph; - filteredItemTypeNodes: ItemTypeNode[]; - filteredPluginNodes: PluginNode[]; - renderItemTypeRow: (args: ItemTypeRowRenderArgs) => ReactNode; - renderPluginRow: (args: PluginRowRenderArgs) => ReactNode; -}; - -/** - * Shared renderer for the list-based large-selection views. It gathers relationship - * metrics and column scaffolding so individual variants only provide row content. - */ -export function LargeSelectionColumns({ - graph, - filteredItemTypeNodes, - filteredPluginNodes, - renderItemTypeRow, - renderPluginRow, -}: LargeSelectionColumnsProps) { - return ( - <> -
    -
    Models
    -
      - {filteredItemTypeNodes.map((node) => { - const itemType = node.data.itemType; - const inboundEdges = findInboundEdges( - graph, - `itemType--${itemType.id}`, - ); - const outboundEdges = findOutboundEdges( - graph, - `itemType--${itemType.id}`, - ); - return renderItemTypeRow({ node, inboundEdges, outboundEdges }); - })} -
    -
    -
    -
    Plugins
    -
      - {filteredPluginNodes.map((node) => { - const plugin = node.data.plugin; - const inboundEdges = findInboundEdges(graph, `plugin--${plugin.id}`); - return renderPluginRow({ node, inboundEdges }); - })} -
    -
    - - ); -} diff --git a/import-export-schema/src/components/LargeSelectionLayout.module.css b/import-export-schema/src/components/LargeSelectionLayout.module.css deleted file mode 100644 index 558ec9a1..00000000 --- a/import-export-schema/src/components/LargeSelectionLayout.module.css +++ /dev/null @@ -1,60 +0,0 @@ -.container { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - min-height: 0; - overflow: hidden; -} - -.header { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 16px; - padding: 12px 16px; - border-bottom: 1px solid var(--border-color); -} - -.metrics { - font-weight: 600; -} - -.meta { - color: var(--light-body-color); - font-size: 13px; -} - -.notice { - width: 100%; - font-size: 12px; - color: var(--light-body-color); - border-bottom: 1px solid var(--border-color); - padding: 0 0 8px 0; -} - -.search { - margin-left: auto; - min-width: 260px; - max-width: 360px; - flex: 1 1 260px; -} - -.body { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.columns { - flex: 1; - min-height: 0; - overflow: hidden; - display: flex; -} - -.footer { - border-top: 1px solid var(--border-color); -} diff --git a/import-export-schema/src/components/LargeSelectionLayout.tsx b/import-export-schema/src/components/LargeSelectionLayout.tsx deleted file mode 100644 index 58477988..00000000 --- a/import-export-schema/src/components/LargeSelectionLayout.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { useId, useMemo, useState } from 'react'; -import { TextField } from 'datocms-react-ui'; -import { - countCycles, - getConnectedComponents, - splitNodesByType, -} from '@/utils/graph/analysis'; -import type { ItemTypeNode } from '@/components/ItemTypeNodeRenderer'; -import type { PluginNode } from '@/components/PluginNodeRenderer'; -import type { Graph } from '@/utils/graph/types'; -import styles from './LargeSelectionLayout.module.css'; - -// Centralized UI shell for the "large selection" experiences so both import and export -// pages can share the same graph overview, search affordances, and layout framing. - -export type LargeSelectionLayoutRenderArgs = { - itemTypeNodes: ItemTypeNode[]; - pluginNodes: PluginNode[]; - filteredItemTypeNodes: ItemTypeNode[]; - filteredPluginNodes: PluginNode[]; - metrics: { - itemTypeCount: number; - pluginCount: number; - edgeCount: number; - components: number; - cycles: number; - }; -}; - -type Props = { - graph: Graph; - searchLabel: string; - searchPlaceholder: string; - headerNotice?: React.ReactNode; - renderContent: (args: LargeSelectionLayoutRenderArgs) => React.ReactNode; - renderFooter?: (args: LargeSelectionLayoutRenderArgs) => React.ReactNode; -}; - -/** - * Shared scaffold for the large-selection fallback used by both import and export flows. - * Packages graph analytics, filtering, and chrome so each flow only supplies row renderers. - */ -export function LargeSelectionLayout({ - graph, - searchLabel, - searchPlaceholder, - headerNotice, - renderContent, - renderFooter, -}: Props) { - const searchInputId = useId(); - const [query, setQuery] = useState(''); - - // Split the graph into the node buckets each flow expects to render while keeping the - // original graph intact for metrics and dependency calculations. - const { itemTypeNodes, pluginNodes } = useMemo( - () => splitNodesByType(graph), - [graph], - ); - - // Precompute graph-wide metrics so both import/export screens surface the same context - // about model/plugin counts, connectivity, and potential cycle issues. - const metrics = useMemo(() => { - const components = getConnectedComponents(graph).length; - const cycles = countCycles(graph); - return { - itemTypeCount: itemTypeNodes.length, - pluginCount: pluginNodes.length, - edgeCount: graph.edges.length, - components, - cycles, - }; - }, [graph, itemTypeNodes.length, pluginNodes.length]); - - const filteredItemTypeNodes = useMemo(() => { - if (!query) return itemTypeNodes; - const lower = query.toLowerCase(); - return itemTypeNodes.filter((node) => { - const { itemType } = node.data; - return ( - itemType.attributes.name.toLowerCase().includes(lower) || - itemType.attributes.api_key.toLowerCase().includes(lower) - ); - }); - }, [itemTypeNodes, query]); - - const filteredPluginNodes = useMemo(() => { - if (!query) return pluginNodes; - const lower = query.toLowerCase(); - return pluginNodes.filter((node) => - node.data.plugin.attributes.name.toLowerCase().includes(lower), - ); - }, [pluginNodes, query]); - - return ( -
    -
    -
    - {metrics.itemTypeCount} models • {metrics.pluginCount} plugins •{' '} - {metrics.edgeCount} relations -
    -
    - Components: {metrics.components} • Cycles: {metrics.cycles} -
    - {headerNotice ? ( -
    {headerNotice}
    - ) : null} -
    - setQuery(val)} - textInputProps={{ autoComplete: 'off' }} - /> -
    -
    -
    -
    - {renderContent({ - itemTypeNodes, - pluginNodes, - filteredItemTypeNodes, - filteredPluginNodes, - metrics, - })} -
    - {renderFooter - ? ( -
    - {renderFooter({ - itemTypeNodes, - pluginNodes, - filteredItemTypeNodes, - filteredPluginNodes, - metrics, - })} -
    - ) - : null} -
    -
    - ); -} diff --git a/import-export-schema/src/components/SchemaOverview/SelectedEntityContext.tsx b/import-export-schema/src/components/SchemaOverview/SelectedEntityContext.tsx new file mode 100644 index 00000000..372263b6 --- /dev/null +++ b/import-export-schema/src/components/SchemaOverview/SelectedEntityContext.tsx @@ -0,0 +1,15 @@ +import type { SchemaTypes } from '@datocms/cma-client'; +import { createContext } from 'react'; + +type SelectedEntityContextValue = { + entity?: SchemaTypes.ItemType | SchemaTypes.Plugin; + set: ( + entity: SchemaTypes.ItemType | SchemaTypes.Plugin | undefined, + zoomIn?: boolean, + ) => void; +}; + +export const SelectedEntityContext = createContext({ + entity: undefined, + set: () => {}, +}); diff --git a/import-export-schema/src/entrypoints/ExportPage/Inner.tsx b/import-export-schema/src/entrypoints/ExportPage/Inner.tsx index 30ae667e..86127a36 100644 --- a/import-export-schema/src/entrypoints/ExportPage/Inner.tsx +++ b/import-export-schema/src/entrypoints/ExportPage/Inner.tsx @@ -12,14 +12,13 @@ import { GraphCanvas } from '@/components/GraphCanvas'; import { GRAPH_NODE_THRESHOLD } from '@/shared/constants/graph'; import { debugLog } from '@/utils/debug'; import { expandSelectionWithDependencies } from '@/utils/graph/dependencies'; -import { type AppNode, edgeTypes, type Graph } from '@/utils/graph/types'; +import { type AppNode, edgeTypes } from '@/utils/graph/types'; import { SelectedEntityContext } from '@/components/SchemaOverview/SelectedEntityContext'; import { DependencyActionsPanel } from './DependencyActionsPanel'; import { EntitiesToExportContext } from './EntitiesToExportContext'; import { ExportItemTypeNodeRenderer } from './ExportItemTypeNodeRenderer'; import { ExportPluginNodeRenderer } from './ExportPluginNodeRenderer'; import { ExportSchemaOverview } from './ExportSchemaOverview'; -import LargeSelectionView from './LargeSelectionView'; import { useAnimatedNodes } from './useAnimatedNodes'; import { useExportGraph } from './useExportGraph'; @@ -47,8 +46,8 @@ type Props = { /** * Presents the export graph, wiring selection state, dependency resolution, and - * export call-outs for both graph and list views. - */ + * export call-outs. For large selections it warns before rendering the full canvas. +*/ export default function Inner({ initialItemTypes, schema, @@ -76,6 +75,10 @@ export default function Inner({ const [focusedEntity, setFocusedEntity] = useState< SchemaTypes.ItemType | SchemaTypes.Plugin | undefined >(undefined); + const [forceRenderGraph, setForceRenderGraph] = useState(false); + const [pendingZoomEntity, setPendingZoomEntity] = useState< + SchemaTypes.ItemType | SchemaTypes.Plugin | null | undefined + >(undefined); const { graph, error, refresh } = useExportGraph({ initialItemTypes, @@ -109,44 +112,6 @@ export default function Inner({ ); }, [ctx, onClose]); - const handleSelectEntity = useCallback( - ( - newEntity: SchemaTypes.ItemType | SchemaTypes.Plugin | undefined, - zoomIn = false, - ) => { - setFocusedEntity(newEntity); - - if (!zoomIn) { - return; - } - - if (!graph) { - return; - } - - if (!newEntity) { - fitView({ duration: 800 }); - return; - } - - const node = graph.nodes.find((node) => - newEntity.type === 'plugin' - ? node.type === 'plugin' && node.data.plugin.id === newEntity.id - : node.type === 'itemType' && node.data.itemType.id === newEntity.id, - ); - - if (!node) { - return; - } - - fitBounds( - { x: node.position.x, y: node.position.y, width: 200, height: 200 }, - { duration: 800, padding: 1 }, - ); - }, - [fitBounds, fitView, graph], - ); - // Overlay is controlled by parent; we signal prepared after each build // Keep selection in sync if the parent changes the initial set of item types @@ -164,11 +129,63 @@ export default function Inner({ .join('-'), ]); - // React Flow becomes cluttered past this many nodes, so we fall back to a list. - const showGraph = !!graph && graph.nodes.length <= GRAPH_NODE_THRESHOLD; + const graphTooLarge = !!graph && graph.nodes.length > GRAPH_NODE_THRESHOLD; + useEffect(() => { + if (!graphTooLarge && forceRenderGraph) { + setForceRenderGraph(false); + } + }, [graphTooLarge, forceRenderGraph]); + + const showGraph = !!graph && (!graphTooLarge || forceRenderGraph); + + useEffect(() => { + if (!showGraph || pendingZoomEntity === undefined || !graph) { + return; + } + + if (pendingZoomEntity === null) { + fitView({ duration: 800 }); + setPendingZoomEntity(undefined); + return; + } + + const node = graph.nodes.find((node) => + pendingZoomEntity.type === 'plugin' + ? node.type === 'plugin' && node.data.plugin.id === pendingZoomEntity.id + : node.type === 'itemType' && + node.data.itemType.id === pendingZoomEntity.id, + ); + + if (!node) { + setPendingZoomEntity(undefined); + return; + } + + fitBounds( + { x: node.position.x, y: node.position.y, width: 200, height: 200 }, + { duration: 800, padding: 1 }, + ); + setPendingZoomEntity(undefined); + }, [fitBounds, fitView, graph, pendingZoomEntity, showGraph]); const animatedNodes = useAnimatedNodes(showGraph && graph ? graph.nodes : []); + const handleSelectEntity = useCallback( + ( + newEntity: SchemaTypes.ItemType | SchemaTypes.Plugin | undefined, + zoomIn = false, + ) => { + setFocusedEntity(newEntity); + + if (!zoomIn) { + return; + } + + setPendingZoomEntity(newEntity ?? null); + }, + [graphTooLarge], + ); + const onNodeClick: NodeMouseHandler = useCallback( (_, node) => { if (node.type === 'itemType') { @@ -432,21 +449,32 @@ export default function Inner({ } /> - ) : ( - - )} + ) : graph ? ( +
    +
    + This graph has {graph.nodes.length} nodes. Trying to render + it may slow down your browser. +
    + +
    + ) : null}
    diff --git a/import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.module.css b/import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.module.css deleted file mode 100644 index a5d34ba1..00000000 --- a/import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.module.css +++ /dev/null @@ -1,73 +0,0 @@ -.modelRow, -.pluginRow { - display: grid; - grid-template-columns: auto 1fr auto; - gap: 12px; - align-items: center; -} - -.pluginRow { - grid-template-columns: auto 1fr; -} - -.checkbox { - width: 16px; - height: 16px; -} - -.modelInfo, -.pluginInfo { - overflow: hidden; -} - -.modelTitle { - font-weight: 600; - display: inline-flex; - align-items: center; - flex-wrap: wrap; - gap: 4px; -} - -.actions { - display: flex; - align-items: center; - gap: 8px; -} - -.whyPanel { - margin: 8px 0 6px 28px; - background: #fff; - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 8px; -} - -.whyTitle { - font-weight: 600; - margin-bottom: 6px; -} - -.whyList { - margin: 0; - padding-left: 18px; -} - -.whyListItem { - margin-bottom: 6px; -} - -.footerRow { - padding: 12px; - display: flex; - align-items: center; - gap: 8px; -} - -.footerNotice { - color: var(--light-body-color); - font-size: 12px; -} - -.footerSpacer { - flex: 1; -} diff --git a/import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.tsx b/import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.tsx deleted file mode 100644 index 8519b5b7..00000000 --- a/import-export-schema/src/entrypoints/ExportPage/LargeSelectionView.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import type { SchemaTypes } from '@datocms/cma-client'; -import { Button, Spinner } from 'datocms-react-ui'; -import { useMemo, useState } from 'react'; -import { findInboundEdges } from '@/utils/graph/analysis'; -import { LargeSelectionLayout } from '@/components/LargeSelectionLayout'; -import type { Graph } from '@/utils/graph/types'; -import { LargeSelectionColumns } from '@/components/LargeSelectionColumns'; -import sharedStyles from '@/components/LargeSelectionColumns.module.css'; -import styles from './LargeSelectionView.module.css'; - -type Props = { - initialItemTypes: SchemaTypes.ItemType[]; - graph: Graph; - selectedItemTypeIds: string[]; - setSelectedItemTypeIds: (next: string[]) => void; - selectedPluginIds: string[]; - setSelectedPluginIds: (next: string[]) => void; - onExport: (itemTypeIds: string[], pluginIds: string[]) => void; - onSelectAllDependencies: () => Promise | void; - onUnselectAllDependencies: () => Promise | void; - areAllDependenciesSelected: boolean; - selectingDependencies: boolean; -}; - -/** - * List-based fallback for very large graphs. Provides search, metrics, and dependency - * context so the user retains insight when the canvas is hidden. - */ -export default function LargeSelectionView({ - initialItemTypes, - graph, - selectedItemTypeIds, - setSelectedItemTypeIds, - selectedPluginIds, - setSelectedPluginIds, - onExport, - onSelectAllDependencies, - onUnselectAllDependencies, - areAllDependenciesSelected, - selectingDependencies, -}: Props) { - const [expandedWhy, setExpandedWhy] = useState>(new Set()); - - const initialItemTypeIdSet = useMemo( - () => new Set(initialItemTypes.map((it) => it.id)), - [initialItemTypes], - ); - - const selectedSourceSet = useMemo( - () => new Set(selectedItemTypeIds.map((id) => `itemType--${id}`)), - [selectedItemTypeIds], - ); - - function toggleItemType(id: string) { - setSelectedItemTypeIds( - selectedItemTypeIds.includes(id) - ? selectedItemTypeIds.filter((x) => x !== id) - : [...selectedItemTypeIds, id], - ); - } - - function togglePlugin(id: string) { - setSelectedPluginIds( - selectedPluginIds.includes(id) - ? selectedPluginIds.filter((x) => x !== id) - : [...selectedPluginIds, id], - ); - } - - function toggleWhy(id: string) { - setExpandedWhy((prev) => { - const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - return next; - }); - } - - return ( - ( - { - const itemType = node.data.itemType; - const locked = initialItemTypeIdSet.has(itemType.id); - const checked = selectedItemTypeIds.includes(itemType.id); - const isExpanded = expandedWhy.has(itemType.id); - const reasons = findInboundEdges( - graph, - `itemType--${itemType.id}`, - selectedSourceSet, - ); - - return ( -
  • -
    - toggleItemType(itemType.id)} - className={styles.checkbox} - /> -
    -
    - {itemType.attributes.name}{' '} - - ({itemType.attributes.api_key}) - {' '} - - {itemType.attributes.modular_block ? 'Block' : 'Model'} - -
    -
    - - ← {inboundEdges.length} inbound - {' '} - •{' '} - - → {outboundEdges.length} outbound - -
    -
    -
    - {reasons.length > 0 ? ( - - ) : null} -
    -
    - {isExpanded && reasons.length > 0 ? ( -
    -
    Included because:
    -
      - {reasons.map((edge) => { - const sourceNode = graph.nodes.find( - (nd) => nd.id === edge.source, - ); - if (!sourceNode) return null; - const sourceItemType = - sourceNode.type === 'itemType' - ? sourceNode.data.itemType - : undefined; - return ( -
    • - {sourceItemType ? ( - <> - Selected model{' '} - - {sourceItemType.attributes.name} - {' '} - references it via fields:{' '} - - - ) : ( - <> - Referenced in fields:{' '} - - - )} -
    • - ); - })} -
    -
    - ) : null} -
  • - ); - }} - renderPluginRow={({ node, inboundEdges }) => { - const plugin = node.data.plugin; - const checked = selectedPluginIds.includes(plugin.id); - - return ( -
  • -
    - togglePlugin(plugin.id)} - className={styles.checkbox} - /> -
    -
    {plugin.attributes.name}
    -
    - ← {inboundEdges.length} inbound from models -
    -
    -
    -
  • - ); - }} - /> - )} - renderFooter={() => ( -
    -
    Graph view hidden due to size.
    - - {selectingDependencies ? : null} -
    - -
    - )} - /> - ); -} - -function FieldsList({ fields }: { fields: SchemaTypes.Field[] }) { - if (!fields || fields.length === 0) return unknown fields; - return ( - <> - {fields - .map((field) => `${field.attributes.label} (${field.attributes.api_key})`) - .join(', ')} - - ); -} diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx index fa743be4..6245aee4 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx @@ -1,10 +1,10 @@ import type { SchemaTypes } from '@datocms/cma-client'; -import { useReactFlow } from '@xyflow/react'; import { SelectField, TextField } from 'datocms-react-ui'; -import { useId } from 'react'; +import { useContext, useId } from 'react'; import { Field } from 'react-final-form'; import { useResolutionStatusForItemType } from '../ResolutionsForm'; import Collapsible from '@/components/SchemaOverview/Collapsible'; +import { GraphEntitiesContext } from '../GraphEntitiesContext'; type Option = { label: string; value: string }; type SelectGroup = { @@ -26,7 +26,8 @@ export function ItemTypeConflict({ exportItemType, projectItemType }: Props) { const apiKeyId = useId(); const fieldPrefix = `itemType-${exportItemType.id}`; const resolution = useResolutionStatusForItemType(exportItemType.id); - const node = useReactFlow().getNode(`itemType--${exportItemType.id}`); + const { hasItemTypeNode } = useContext(GraphEntitiesContext); + const nodeExists = hasItemTypeNode(exportItemType.id); const exportType = exportItemType.attributes.modular_block ? 'block' @@ -73,7 +74,7 @@ export function ItemTypeConflict({ exportItemType, projectItemType }: Props) { } } - if (!node) { + if (!nodeExists) { return null; } diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx index 35d90959..7abac2c6 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx @@ -1,9 +1,10 @@ import type { SchemaTypes } from '@datocms/cma-client'; import { SelectField } from 'datocms-react-ui'; -import { useId } from 'react'; +import { useContext, useId } from 'react'; import { Field } from 'react-final-form'; import { useResolutionStatusForPlugin } from '../ResolutionsForm'; import Collapsible from '@/components/SchemaOverview/Collapsible'; +import { GraphEntitiesContext } from '../GraphEntitiesContext'; type Option = { label: string; value: string }; type SelectGroup = { @@ -29,6 +30,8 @@ export function PluginConflict({ exportPlugin, projectPlugin }: Props) { const selectId = useId(); const fieldPrefix = `plugin-${exportPlugin.id}`; const resolution = useResolutionStatusForPlugin(exportPlugin.id); + const { hasPluginNode } = useContext(GraphEntitiesContext); + const nodeExists = hasPluginNode(exportPlugin.id); const strategy = resolution?.values?.strategy; const hasValidResolution = Boolean( @@ -38,6 +41,10 @@ export function PluginConflict({ exportPlugin, projectPlugin }: Props) { const hasConflict = Boolean(projectPlugin) && !hasValidResolution; + if (!nodeExists) { + return null; + } + return ( boolean; + hasPluginNode: (id: string) => boolean; +}; + +export const GraphEntitiesContext = createContext({ + hasItemTypeNode: () => false, + hasPluginNode: () => false, +}); diff --git a/import-export-schema/src/entrypoints/ImportPage/Inner.tsx b/import-export-schema/src/entrypoints/ImportPage/Inner.tsx index c89a70bb..7c4b7d27 100644 --- a/import-export-schema/src/entrypoints/ImportPage/Inner.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/Inner.tsx @@ -9,7 +9,7 @@ import { import { Button } from 'datocms-react-ui'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faXmark } from '@fortawesome/free-solid-svg-icons'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { GRAPH_NODE_THRESHOLD } from '@/shared/constants/graph'; import { type AppNode, edgeTypes, type Graph } from '@/utils/graph/types'; import type { ProjectSchema } from '@/utils/ProjectSchema'; @@ -18,9 +18,9 @@ import { buildGraphFromExportDoc } from './buildGraphFromExportDoc'; import ConflictsManager from './ConflictsManager'; import { ImportItemTypeNodeRenderer } from './ImportItemTypeNodeRenderer'; import { ImportPluginNodeRenderer } from './ImportPluginNodeRenderer'; -import LargeSelectionView from './LargeSelectionView'; import { useSkippedItemsAndPluginIds } from './ResolutionsForm'; import { SelectedEntityContext } from '@/components/SchemaOverview/SelectedEntityContext'; +import { GraphEntitiesContext } from './GraphEntitiesContext'; // Map React Flow node types to the dedicated renderers for import graphs. const nodeTypes: NodeTypes = { @@ -39,16 +39,15 @@ type Props = { * the selection in sync with the conflict resolution form. */ export function Inner({ exportSchema, schema, ctx: _ctx }: Props) { - const { fitBounds, fitView } = useReactFlow(); + const { fitBounds, fitView, setNodes, setEdges } = useReactFlow(); const { skippedItemTypeIds, skippedPluginIds } = useSkippedItemsAndPluginIds(); - // Zoom the viewport to the full graph once React Flow has mounted. - useEffect(() => { - setTimeout(() => fitView(), 100); - }, []); - const [graph, setGraph] = useState(); + const [forceRenderGraph, setForceRenderGraph] = useState(false); + const [pendingZoomEntity, setPendingZoomEntity] = useState< + SchemaTypes.ItemType | SchemaTypes.Plugin | null | undefined + >(undefined); // Rebuild the graph when the export document or skip lists change. useEffect(() => { @@ -75,33 +74,6 @@ export function Inner({ exportSchema, schema, ctx: _ctx }: Props) { ); }, []); - // Allow external panels to highlight a specific entity while animating the view. - function handleSelectEntity( - newEntity: undefined | SchemaTypes.ItemType | SchemaTypes.Plugin, - zoomIn?: boolean, - ) { - setSelectedEntity(newEntity); - - if (zoomIn && graph) { - if (newEntity) { - const node = graph.nodes.find((node) => - newEntity.type === 'plugin' - ? node.type === 'plugin' && node.data.plugin.id === newEntity.id - : node.type === 'itemType' && - node.data.itemType.id === newEntity.id, - ); - if (!node) return; - - fitBounds( - { x: node.position.x, y: node.position.y, width: 200, height: 200 }, - { duration: 800, padding: 1 }, - ); - } else { - fitView({ duration: 800 }); - } - } - } - const requestCancel = useCallback(() => { window.dispatchEvent(new CustomEvent('import:request-cancel')); }, []); @@ -109,16 +81,116 @@ export function Inner({ exportSchema, schema, ctx: _ctx }: Props) { const totalPotentialNodes = exportSchema.itemTypes.length + exportSchema.plugins.length; - // Prefer the interactive graph for small/medium selections; fall back otherwise. - const showGraph = + const graphTooLarge = !!graph && - graph.nodes.length <= GRAPH_NODE_THRESHOLD && - totalPotentialNodes <= GRAPH_NODE_THRESHOLD; + (graph.nodes.length > GRAPH_NODE_THRESHOLD || + totalPotentialNodes > GRAPH_NODE_THRESHOLD); + + useEffect(() => { + if (!graphTooLarge && forceRenderGraph) { + setForceRenderGraph(false); + } + }, [graphTooLarge, forceRenderGraph]); + + // Prefer the interactive graph for small/medium selections; allow manual override otherwise. + const showGraph = !!graph && (!graphTooLarge || forceRenderGraph); + + useEffect(() => { + if (!graph) { + setNodes([]); + setEdges([]); + return; + } + + if (!showGraph) { + setNodes(graph.nodes); + setEdges(graph.edges); + } + }, [graph, showGraph, setNodes, setEdges]); + + const graphEntitySets = useMemo(() => { + const itemTypeIds = new Set(); + const pluginIds = new Set(); + + if (graph) { + for (const node of graph.nodes) { + if (node.type === 'itemType') { + itemTypeIds.add(node.data.itemType.id); + } + if (node.type === 'plugin') { + pluginIds.add(node.data.plugin.id); + } + } + } + + return { itemTypeIds, pluginIds }; + }, [graph]); + + useEffect(() => { + if (!showGraph || pendingZoomEntity === undefined || !graph) { + return; + } + + if (pendingZoomEntity === null) { + fitView({ duration: 800 }); + setPendingZoomEntity(undefined); + return; + } + + const node = graph.nodes.find((node) => + pendingZoomEntity.type === 'plugin' + ? node.type === 'plugin' && node.data.plugin.id === pendingZoomEntity.id + : node.type === 'itemType' && + node.data.itemType.id === pendingZoomEntity.id, + ); + + if (!node) { + setPendingZoomEntity(undefined); + return; + } + + fitBounds( + { x: node.position.x, y: node.position.y, width: 200, height: 200 }, + { duration: 800, padding: 1 }, + ); + setPendingZoomEntity(undefined); + }, [fitBounds, fitView, graph, pendingZoomEntity, showGraph]); + + // Zoom the viewport to the full graph once React Flow has mounted and the graph is visible. + useEffect(() => { + if (!showGraph) { + return; + } + const timeout = window.setTimeout(() => fitView(), 100); + return () => window.clearTimeout(timeout); + }, [fitView, showGraph, graph?.nodes.length]); + + const handleSelectEntity = useCallback( + ( + newEntity: undefined | SchemaTypes.ItemType | SchemaTypes.Plugin, + zoomIn?: boolean, + ) => { + setSelectedEntity(newEntity); + + if (!zoomIn) { + return; + } + + setPendingZoomEntity(newEntity ?? null); + }, + [graphTooLarge], + ); return ( - graphEntitySets.itemTypeIds.has(id), + hasPluginNode: (id) => graphEntitySets.pluginIds.has(id), + }} > +
    )} {graph && !showGraph && ( - <> - {/* List view for large selections */} - handleSelectEntity(entity, true)} - /> - {/* Hidden ReactFlow to keep nodes available for Conflicts UI */} -
    - +
    +
    + This graph has {graph.nodes.length} nodes. Trying to render it may slow + down your browser.
    - + +
    )}
    @@ -209,6 +275,7 @@ export function Inner({ exportSchema, schema, ctx: _ctx }: Props) {
    - + + ); } diff --git a/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.module.css b/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.module.css deleted file mode 100644 index 9d6dc9ea..00000000 --- a/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.module.css +++ /dev/null @@ -1,36 +0,0 @@ -.rowButton, -.rowButtonActive { - width: 100%; - background: transparent; - border: 0; - text-align: left; - cursor: pointer; - padding: 8px; - border-radius: 6px; - display: block; -} - -.rowButtonActive { - background: rgba(51, 94, 234, 0.08); -} - -.rowLayout { - display: flex; - flex-direction: column; - gap: 8px; -} - -.rowTop { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.entityName { - font-weight: 600; - display: inline-flex; - align-items: center; - flex-wrap: wrap; - gap: 4px; -} diff --git a/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.tsx b/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.tsx deleted file mode 100644 index 8addeec2..00000000 --- a/import-export-schema/src/entrypoints/ImportPage/LargeSelectionView.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import type { SchemaTypes } from '@datocms/cma-client'; -import { Button } from 'datocms-react-ui'; -import { useContext } from 'react'; -import { LargeSelectionLayout } from '@/components/LargeSelectionLayout'; -import type { Graph } from '@/utils/graph/types'; -import { SelectedEntityContext } from '@/components/SchemaOverview/SelectedEntityContext'; -import { LargeSelectionColumns } from '@/components/LargeSelectionColumns'; -import sharedStyles from '@/components/LargeSelectionColumns.module.css'; -import styles from './LargeSelectionView.module.css'; - -type Props = { - graph: Graph; - onSelect: (entity: SchemaTypes.ItemType | SchemaTypes.Plugin) => void; -}; - -/** - * Read-only overview used when the import graph is too dense to render. Mirrors the - * export-side list but drives the detail sidebar for conflicts. - */ -export default function LargeSelectionView({ graph, onSelect }: Props) { - const selected = useContext(SelectedEntityContext).entity; - const handleSelectItemType = (itemType: SchemaTypes.ItemType) => { - onSelect(itemType); - }; - - const handleSelectPlugin = (plugin: SchemaTypes.Plugin) => { - onSelect(plugin); - }; - - const isItemTypeSelected = (id: string) => - selected?.type === 'item_type' && selected.id === id; - - const isPluginSelected = (id: string) => - selected?.type === 'plugin' && selected.id === id; - - return ( - ( - { - const itemType = node.data.itemType; - const selectedRow = isItemTypeSelected(itemType.id); - - return ( -
  • - -
  • -
    - - ← {inboundEdges.length} inbound - {' '} - •{' '} - - → {outboundEdges.length} outbound - -
    -
    - - - ); - }} - renderPluginRow={({ node, inboundEdges }) => { - const plugin = node.data.plugin; - const selectedRow = isPluginSelected(plugin.id); - - return ( -
  • - -
  • -
    - ← {inboundEdges.length} inbound from models -
    -
    - - - ); - }} - /> - )} - /> - ); -} diff --git a/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx b/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx index 356eab5e..3f5840f1 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx @@ -1,9 +1,9 @@ -import { useNodes, useReactFlow } from '@xyflow/react'; import { get, keyBy, set } from 'lodash-es'; import { type ReactNode, useContext, useMemo } from 'react'; import { Form as FormHandler, useFormState } from 'react-final-form'; import type { ProjectSchema } from '@/utils/ProjectSchema'; import { ConflictsContext } from './ConflictsManager/ConflictsContext'; +import { GraphEntitiesContext } from './GraphEntitiesContext'; export type ItemTypeConflictResolutionRename = { strategy: 'rename'; @@ -70,12 +70,7 @@ function isValidApiKey(apiKey: string) { */ export default function ResolutionsForm({ schema, children, onSubmit }: Props) { const conflicts = useContext(ConflictsContext); - - const { getNode } = useReactFlow(); - - // we need this to re-render this component everytime the nodes change, and - // revalidate the form! - useNodes(); + const graphEntities = useContext(GraphEntitiesContext); const initialValues = useMemo( () => @@ -113,7 +108,7 @@ export default function ResolutionsForm({ schema, children, onSubmit }: Props) { } for (const pluginId of Object.keys(conflicts.plugins)) { - if (!getNode(`plugin--${pluginId}`)) { + if (!graphEntities.hasPluginNode(pluginId)) { continue; } @@ -126,8 +121,7 @@ export default function ResolutionsForm({ schema, children, onSubmit }: Props) { } for (const itemTypeId of Object.keys(conflicts.itemTypes)) { - const node = getNode(`itemType--${itemTypeId}`); - if (!node) { + if (!graphEntities.hasItemTypeNode(itemTypeId)) { continue; } @@ -167,7 +161,7 @@ export default function ResolutionsForm({ schema, children, onSubmit }: Props) { const itemTypesByApiKey = keyBy(projectItemTypes, 'attributes.api_key'); for (const pluginId of Object.keys(conflicts.plugins)) { - if (!getNode(`plugin--${pluginId}`)) { + if (!graphEntities.hasPluginNode(pluginId)) { continue; } @@ -178,7 +172,7 @@ export default function ResolutionsForm({ schema, children, onSubmit }: Props) { } for (const itemTypeId of Object.keys(conflicts.itemTypes)) { - if (!getNode(`itemType--${itemTypeId}`)) { + if (!graphEntities.hasItemTypeNode(itemTypeId)) { continue; } diff --git a/import-export-schema/tsconfig.app.tsbuildinfo b/import-export-schema/tsconfig.app.tsbuildinfo index 9902203f..f5a2205e 100644 --- a/import-export-schema/tsconfig.app.tsbuildinfo +++ b/import-export-schema/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/blankslate.tsx","./src/components/exportlandingpanel.tsx","./src/components/exportselectionpanel.tsx","./src/components/field.tsx","./src/components/fieldedgerenderer.tsx","./src/components/graphcanvas.tsx","./src/components/itemtypenoderenderer.tsx","./src/components/largeselectionlayout.tsx","./src/components/pluginnoderenderer.tsx","./src/components/progressoverlay.tsx","./src/components/progressstallnotice.tsx","./src/components/taskoverlaystack.tsx","./src/components/taskprogressoverlay.tsx","./src/components/bezier.ts","./src/components/schemaoverview/collapsible.tsx","./src/components/schemaoverview/selectedentitycontext.tsx","./src/entrypoints/config/index.tsx","./src/entrypoints/exporthome/index.tsx","./src/entrypoints/exportpage/dependencyactionspanel.tsx","./src/entrypoints/exportpage/entitiestoexportcontext.ts","./src/entrypoints/exportpage/exportitemtypenoderenderer.tsx","./src/entrypoints/exportpage/exportpluginnoderenderer.tsx","./src/entrypoints/exportpage/exportschema.ts","./src/entrypoints/exportpage/exportschemaoverview.tsx","./src/entrypoints/exportpage/inner.tsx","./src/entrypoints/exportpage/largeselectionview.tsx","./src/entrypoints/exportpage/buildexportdoc.ts","./src/entrypoints/exportpage/buildgraphfromschema.ts","./src/entrypoints/exportpage/index.tsx","./src/entrypoints/exportpage/useanimatednodes.tsx","./src/entrypoints/exportpage/useexportgraph.ts","./src/entrypoints/importpage/filedropzone.tsx","./src/entrypoints/importpage/importitemtypenoderenderer.tsx","./src/entrypoints/importpage/importpluginnoderenderer.tsx","./src/entrypoints/importpage/importworkflow.tsx","./src/entrypoints/importpage/inner.tsx","./src/entrypoints/importpage/largeselectionview.tsx","./src/entrypoints/importpage/resolutionsform.tsx","./src/entrypoints/importpage/buildgraphfromexportdoc.ts","./src/entrypoints/importpage/buildimportdoc.ts","./src/entrypoints/importpage/importschema.ts","./src/entrypoints/importpage/index.tsx","./src/entrypoints/importpage/userecipeloader.ts","./src/entrypoints/importpage/conflictsmanager/conflictscontext.ts","./src/entrypoints/importpage/conflictsmanager/itemtypeconflict.tsx","./src/entrypoints/importpage/conflictsmanager/pluginconflict.tsx","./src/entrypoints/importpage/conflictsmanager/buildconflicts.ts","./src/entrypoints/importpage/conflictsmanager/index.tsx","./src/shared/constants/graph.ts","./src/shared/hooks/usecmaclient.ts","./src/shared/hooks/useconflictsbuilder.ts","./src/shared/hooks/useexportallhandler.ts","./src/shared/hooks/useexportselection.ts","./src/shared/hooks/useprojectschema.ts","./src/shared/hooks/useschemaexporttask.ts","./src/shared/tasks/uselongtask.ts","./src/types/lodash-es.d.ts","./src/utils/projectschema.ts","./src/utils/createcmaclient.ts","./src/utils/debug.ts","./src/utils/downloadjson.ts","./src/utils/emojiagnosticsorter.ts","./src/utils/icons.tsx","./src/utils/isdefined.ts","./src/utils/render.tsx","./src/utils/types.ts","./src/utils/datocms/appearance.ts","./src/utils/datocms/fieldtypeinfo.ts","./src/utils/datocms/schema.ts","./src/utils/graph/analysis.ts","./src/utils/graph/buildgraph.ts","./src/utils/graph/buildhierarchynodes.ts","./src/utils/graph/dependencies.ts","./src/utils/graph/edges.ts","./src/utils/graph/nodes.ts","./src/utils/graph/rebuildgraphwithpositionsfromhierarchy.ts","./src/utils/graph/sort.ts","./src/utils/graph/types.ts","./src/utils/schema/exportschemasource.ts","./src/utils/schema/ischemasource.ts","./src/utils/schema/projectschemasource.ts"],"version":"5.7.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/blankslate.tsx","./src/components/exportlandingpanel.tsx","./src/components/exportselectionpanel.tsx","./src/components/field.tsx","./src/components/fieldedgerenderer.tsx","./src/components/graphcanvas.tsx","./src/components/itemtypenoderenderer.tsx","./src/components/pluginnoderenderer.tsx","./src/components/progressoverlay.tsx","./src/components/progressstallnotice.tsx","./src/components/taskoverlaystack.tsx","./src/components/taskprogressoverlay.tsx","./src/components/bezier.ts","./src/components/schemaoverview/collapsible.tsx","./src/components/schemaoverview/selectedentitycontext.tsx","./src/entrypoints/config/index.tsx","./src/entrypoints/exporthome/index.tsx","./src/entrypoints/exportpage/dependencyactionspanel.tsx","./src/entrypoints/exportpage/entitiestoexportcontext.ts","./src/entrypoints/exportpage/exportitemtypenoderenderer.tsx","./src/entrypoints/exportpage/exportpluginnoderenderer.tsx","./src/entrypoints/exportpage/exportschema.ts","./src/entrypoints/exportpage/exportschemaoverview.tsx","./src/entrypoints/exportpage/inner.tsx","./src/entrypoints/exportpage/buildexportdoc.ts","./src/entrypoints/exportpage/buildgraphfromschema.ts","./src/entrypoints/exportpage/index.tsx","./src/entrypoints/exportpage/useanimatednodes.tsx","./src/entrypoints/exportpage/useexportgraph.ts","./src/entrypoints/importpage/filedropzone.tsx","./src/entrypoints/importpage/graphentitiescontext.tsx","./src/entrypoints/importpage/importitemtypenoderenderer.tsx","./src/entrypoints/importpage/importpluginnoderenderer.tsx","./src/entrypoints/importpage/importworkflow.tsx","./src/entrypoints/importpage/inner.tsx","./src/entrypoints/importpage/resolutionsform.tsx","./src/entrypoints/importpage/buildgraphfromexportdoc.ts","./src/entrypoints/importpage/buildimportdoc.ts","./src/entrypoints/importpage/importschema.ts","./src/entrypoints/importpage/index.tsx","./src/entrypoints/importpage/userecipeloader.ts","./src/entrypoints/importpage/conflictsmanager/conflictscontext.ts","./src/entrypoints/importpage/conflictsmanager/itemtypeconflict.tsx","./src/entrypoints/importpage/conflictsmanager/pluginconflict.tsx","./src/entrypoints/importpage/conflictsmanager/buildconflicts.ts","./src/entrypoints/importpage/conflictsmanager/index.tsx","./src/shared/constants/graph.ts","./src/shared/hooks/usecmaclient.ts","./src/shared/hooks/useconflictsbuilder.ts","./src/shared/hooks/useexportallhandler.ts","./src/shared/hooks/useexportselection.ts","./src/shared/hooks/useprojectschema.ts","./src/shared/hooks/useschemaexporttask.ts","./src/shared/tasks/uselongtask.ts","./src/types/lodash-es.d.ts","./src/utils/projectschema.ts","./src/utils/createcmaclient.ts","./src/utils/debug.ts","./src/utils/downloadjson.ts","./src/utils/emojiagnosticsorter.ts","./src/utils/icons.tsx","./src/utils/isdefined.ts","./src/utils/render.tsx","./src/utils/types.ts","./src/utils/datocms/appearance.ts","./src/utils/datocms/fieldtypeinfo.ts","./src/utils/datocms/schema.ts","./src/utils/graph/analysis.ts","./src/utils/graph/buildgraph.ts","./src/utils/graph/buildhierarchynodes.ts","./src/utils/graph/dependencies.ts","./src/utils/graph/edges.ts","./src/utils/graph/nodes.ts","./src/utils/graph/rebuildgraphwithpositionsfromhierarchy.ts","./src/utils/graph/sort.ts","./src/utils/graph/types.ts","./src/utils/schema/exportschemasource.ts","./src/utils/schema/ischemasource.ts","./src/utils/schema/projectschemasource.ts"],"version":"5.7.3"} \ No newline at end of file From ef48345928c478c3cbceac7cd1a93c2ea90419a7 Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Tue, 23 Sep 2025 14:43:03 +0200 Subject: [PATCH 24/36] refactor --- .../src/components/ExportLandingPanel.tsx | 6 +- .../src/components/ExportSelectionPanel.tsx | 13 +- import-export-schema/src/components/Field.tsx | 2 +- .../src/components/PluginNodeRenderer.tsx | 2 +- .../components/SchemaOverview/Collapsible.tsx | 2 +- .../src/entrypoints/Config/index.tsx | 4 +- .../src/entrypoints/ExportHome/index.tsx | 20 +- .../entrypoints/ExportPage/ExportSchema.ts | 6 + .../ExportPage/ExportSchemaOverview.tsx | 50 +++- .../src/entrypoints/ExportPage/Inner.tsx | 45 ++- .../src/entrypoints/ExportPage/index.tsx | 257 +++++++++++------- .../ExportPage/useAnimatedNodes.tsx | 8 - .../ConflictsManager/ConflictsContext.ts | 3 - .../ConflictsManager/ItemTypeConflict.tsx | 8 +- .../ConflictsManager/PluginConflict.tsx | 2 +- .../ImportPage/ConflictsManager/index.tsx | 167 ++++++------ .../ImportPage/ImportItemTypeNodeRenderer.tsx | 2 +- .../ImportPage/ImportPluginNodeRenderer.tsx | 2 +- .../entrypoints/ImportPage/ImportWorkflow.tsx | 2 +- .../src/entrypoints/ImportPage/Inner.tsx | 162 +++++------ .../entrypoints/ImportPage/importSchema.ts | 14 +- .../src/entrypoints/ImportPage/index.tsx | 39 ++- .../entrypoints/ImportPage/useRecipeLoader.ts | 2 +- import-export-schema/src/index.css | 6 +- import-export-schema/src/main.tsx | 4 +- .../src/utils/emojiAgnosticSorter.ts | 4 - .../src/utils/graph/buildHierarchyNodes.ts | 4 +- import-export-schema/src/utils/icons.tsx | 1 - import-export-schema/src/utils/isDefined.ts | 1 - import-export-schema/src/utils/render.tsx | 1 - import-export-schema/src/utils/types.ts | 1 - 31 files changed, 476 insertions(+), 364 deletions(-) delete mode 100644 import-export-schema/src/entrypoints/ExportPage/useAnimatedNodes.tsx diff --git a/import-export-schema/src/components/ExportLandingPanel.tsx b/import-export-schema/src/components/ExportLandingPanel.tsx index 3a75f6fe..4a0aa3bf 100644 --- a/import-export-schema/src/components/ExportLandingPanel.tsx +++ b/import-export-schema/src/components/ExportLandingPanel.tsx @@ -29,11 +29,7 @@ export function ExportLandingPanel({

    {description}

    - -
    diff --git a/import-export-schema/src/components/Field.tsx b/import-export-schema/src/components/Field.tsx index c17db0cd..3b9dcbdb 100644 --- a/import-export-schema/src/components/Field.tsx +++ b/import-export-schema/src/components/Field.tsx @@ -1,9 +1,9 @@ +import type { SchemaTypes } from '@datocms/cma-client'; import { fieldGroupColors, fieldTypeDescriptions, fieldTypeGroups, } from '@/utils/datocms/schema'; -import type { SchemaTypes } from '@datocms/cma-client'; export function Field({ field }: { field: SchemaTypes.Field }) { const group = fieldTypeGroups.find((g) => diff --git a/import-export-schema/src/components/PluginNodeRenderer.tsx b/import-export-schema/src/components/PluginNodeRenderer.tsx index c34c5e90..fe327e72 100644 --- a/import-export-schema/src/components/PluginNodeRenderer.tsx +++ b/import-export-schema/src/components/PluginNodeRenderer.tsx @@ -1,4 +1,3 @@ -import { Schema } from '@/utils/icons'; import type { SchemaTypes } from '@datocms/cma-client'; import { Handle, @@ -9,6 +8,7 @@ import { useStore, } from '@xyflow/react'; import classNames from 'classnames'; +import { Schema } from '@/utils/icons'; export type PluginNode = Node< { diff --git a/import-export-schema/src/components/SchemaOverview/Collapsible.tsx b/import-export-schema/src/components/SchemaOverview/Collapsible.tsx index 896e2b34..23d5c4ea 100644 --- a/import-export-schema/src/components/SchemaOverview/Collapsible.tsx +++ b/import-export-schema/src/components/SchemaOverview/Collapsible.tsx @@ -1,8 +1,8 @@ import type { SchemaTypes } from '@datocms/cma-client'; import { + faCircleExclamation, faCaretRight as faCollapsed, faCaretDown as faExpanded, - faCircleExclamation, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; diff --git a/import-export-schema/src/entrypoints/Config/index.tsx b/import-export-schema/src/entrypoints/Config/index.tsx index 0272dff9..99170a59 100644 --- a/import-export-schema/src/entrypoints/Config/index.tsx +++ b/import-export-schema/src/entrypoints/Config/index.tsx @@ -25,7 +25,9 @@ function Link({ href, children }: { href: string; children: ReactNode }) { /** Configuration screen shown in Settings → Plugins. */ export function Config({ ctx }: Props) { - const environmentPath = ctx.isEnvironmentPrimary ? '' : `/environments/${ctx.environment}`; + const environmentPath = ctx.isEnvironmentPrimary + ? '' + : `/environments/${ctx.environment}`; const schemaUrl = `${environmentPath}/schema`; const importUrl = `${environmentPath}/configuration/p/${ctx.plugin.id}/pages/import`; const exportUrl = `${environmentPath}/configuration/p/${ctx.plugin.id}/pages/export`; diff --git a/import-export-schema/src/entrypoints/ExportHome/index.tsx b/import-export-schema/src/entrypoints/ExportHome/index.tsx index d868dd90..8d93db28 100644 --- a/import-export-schema/src/entrypoints/ExportHome/index.tsx +++ b/import-export-schema/src/entrypoints/ExportHome/index.tsx @@ -17,8 +17,8 @@ import { useExportSelection } from '@/shared/hooks/useExportSelection'; import { useProjectSchema } from '@/shared/hooks/useProjectSchema'; import { useSchemaExportTask } from '@/shared/hooks/useSchemaExportTask'; import { - useLongTask, type UseLongTaskResult, + useLongTask, } from '@/shared/tasks/useLongTask'; import ExportInner from '../ExportPage/Inner'; @@ -52,8 +52,7 @@ function buildOverlayItems({ title: 'Exporting entire schema', subtitle: 'Sit tight, we’re gathering models, blocks, and plugins…', ariaLabel: 'Export in progress', - progressLabel: (progress) => - progress.label ?? 'Loading project schema…', + progressLabel: (progress) => progress.label ?? 'Loading project schema…', cancel: () => ({ label: 'Cancel export', intent: exportAllTask.state.cancelRequested ? 'muted' : 'negative', @@ -79,7 +78,9 @@ function buildOverlayItems({ progressLabel: (progress) => progress.label ?? 'Preparing export…', cancel: () => ({ label: 'Cancel export', - intent: exportSelectionTask.state.cancelRequested ? 'muted' : 'negative', + intent: exportSelectionTask.state.cancelRequested + ? 'muted' + : 'negative', disabled: exportSelectionTask.state.cancelRequested, onCancel: () => exportSelectionTask.controller.requestCancel(), }), @@ -109,7 +110,7 @@ export default function ExportHome({ ctx }: Props) { // ----- Long-running tasks ----- // The export flow manipulates three distinct tasks (export all, prepare graph, - // targeted export). + // targeted export). const exportAllTask = useLongTask(); const exportPreparingTask = useLongTask(); const { task: exportSelectionTask, runExport: runSelectionExport } = @@ -184,9 +185,7 @@ export default function ExportHome({ ctx }: Props) { ); } else { const mapped = 0.25 + raw * 0.75; - setExportPreparingPercent((prev) => - Math.max(prev, Math.min(1, mapped)), - ); + setExportPreparingPercent((prev) => Math.max(prev, Math.min(1, mapped))); } }; @@ -196,7 +195,10 @@ export default function ExportHome({ ctx }: Props) { exportPreparingTask.controller.reset(); }; - const handleSelectionExport = (itemTypeIds: string[], pluginIds: string[]) => { + const handleSelectionExport = ( + itemTypeIds: string[], + pluginIds: string[], + ) => { if (!rootItemTypeId) { return; } diff --git a/import-export-schema/src/entrypoints/ExportPage/ExportSchema.ts b/import-export-schema/src/entrypoints/ExportPage/ExportSchema.ts index 2738091a..1791d102 100644 --- a/import-export-schema/src/entrypoints/ExportPage/ExportSchema.ts +++ b/import-export-schema/src/entrypoints/ExportPage/ExportSchema.ts @@ -1,3 +1,9 @@ +/** + * 'bulk exports' widened this helper so importer/graph code could reuse + * the same normalized view of a schema export. v2 exports optionally declare multiple root + * item types, but v1 files assumed a single implicit root; the coalescing logic keeps + * both behaviours working so older exports still import cleanly. + */ import type { SchemaTypes } from '@datocms/cma-client'; import { get } from 'lodash-es'; import { findLinkedItemTypeIds } from '@/utils/datocms/schema'; diff --git a/import-export-schema/src/entrypoints/ExportPage/ExportSchemaOverview.tsx b/import-export-schema/src/entrypoints/ExportPage/ExportSchemaOverview.tsx index 83f8c8b9..b9c9966a 100644 --- a/import-export-schema/src/entrypoints/ExportPage/ExportSchemaOverview.tsx +++ b/import-export-schema/src/entrypoints/ExportPage/ExportSchemaOverview.tsx @@ -1,10 +1,16 @@ +/** + * Renders the export-side right sidebar schema overview, grouping selected/unselected + * models and plugins from the dependency graph so editors can confirm what the + * bulk export will include. Also drives the "only selected" toggle and per-entity + * summaries reused across import/export review flows. + */ import type { SchemaTypes } from '@datocms/cma-client'; +import classNames from 'classnames'; import { Spinner, SwitchInput } from 'datocms-react-ui'; import { useMemo, useState } from 'react'; -import classNames from 'classnames'; -import Collapsible from '@/components/SchemaOverview/Collapsible'; import type { ItemTypeNode } from '@/components/ItemTypeNodeRenderer'; import type { PluginNode } from '@/components/PluginNodeRenderer'; +import Collapsible from '@/components/SchemaOverview/Collapsible'; import { getTextWithoutRepresentativeEmojiAndPadding } from '@/utils/emojiAgnosticSorter'; import type { Graph } from '@/utils/graph/types'; @@ -44,7 +50,10 @@ type Props = { selectedPluginIds: string[]; }; -function sortEntriesByDisplayName(entries: T[], getName: (entry: T) => string) { +function sortEntriesByDisplayName( + entries: T[], + getName: (entry: T) => string, +) { return [...entries].sort((a, b) => localeAwareCollator.compare(getName(a), getName(b)), ); @@ -83,7 +92,7 @@ function renderItemTypeEntry(entry: ItemTypeEntry) {

    {entry.selected ? 'The exported schema JSON will include this ' - : 'Select it '} + : 'Select it '} {entry.selected ? '.' : ' from the graph to include it.'}

    @@ -109,8 +118,8 @@ function renderPluginEntry(entry: PluginEntry) { className={className} >

    - {name}{' '} - is {entry.selected ? 'selected for export.' : 'not selected yet.'} + {name} is{' '} + {entry.selected ? 'selected for export.' : 'not selected yet.'}

    {entry.selected @@ -172,8 +181,8 @@ function SchemaOverviewCategory({ groups: Array; className?: string; }) { - const filteredGroups = groups.filter( - (group): group is JSX.Element => Boolean(group), + const filteredGroups = groups.filter((group): group is JSX.Element => + Boolean(group), ); if (filteredGroups.length === 0) { return null; @@ -286,10 +295,19 @@ export function ExportSchemaOverview({ return (

    -
    Schema overview
    +
    + Schema overview +
    -
    +
    @@ -298,7 +316,11 @@ export function ExportSchemaOverview({ } const selectedGroups = [ - renderItemTypeGroup('Models', groupedItemTypes.models.selected, 'selected-models'), + renderItemTypeGroup( + 'Models', + groupedItemTypes.models.selected, + 'selected-models', + ), renderItemTypeGroup( 'Block models', groupedItemTypes.blocks.selected, @@ -320,7 +342,11 @@ export function ExportSchemaOverview({ groupedItemTypes.blocks.unselected, 'unselected-blocks', ), - renderPluginGroup('Plugins', pluginBuckets.unselected, 'unselected-plugins'), + renderPluginGroup( + 'Plugins', + pluginBuckets.unselected, + 'unselected-plugins', + ), ]; return ( diff --git a/import-export-schema/src/entrypoints/ExportPage/Inner.tsx b/import-export-schema/src/entrypoints/ExportPage/Inner.tsx index 86127a36..18d1bc7b 100644 --- a/import-export-schema/src/entrypoints/ExportPage/Inner.tsx +++ b/import-export-schema/src/entrypoints/ExportPage/Inner.tsx @@ -1,7 +1,11 @@ import type { SchemaTypes } from '@datocms/cma-client'; -import { useReactFlow, type NodeMouseHandler, type NodeTypes } from '@xyflow/react'; import { faXmark } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + type NodeMouseHandler, + type NodeTypes, + useReactFlow, +} from '@xyflow/react'; import type { ProjectSchema } from '@/utils/ProjectSchema'; import '@xyflow/react/dist/style.css'; import type { RenderPageCtx } from 'datocms-plugin-sdk'; @@ -9,17 +13,16 @@ import { Button, Spinner, useCtx } from 'datocms-react-ui'; import { without } from 'lodash-es'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { GraphCanvas } from '@/components/GraphCanvas'; +import { SelectedEntityContext } from '@/components/SchemaOverview/SelectedEntityContext'; import { GRAPH_NODE_THRESHOLD } from '@/shared/constants/graph'; import { debugLog } from '@/utils/debug'; import { expandSelectionWithDependencies } from '@/utils/graph/dependencies'; import { type AppNode, edgeTypes } from '@/utils/graph/types'; -import { SelectedEntityContext } from '@/components/SchemaOverview/SelectedEntityContext'; import { DependencyActionsPanel } from './DependencyActionsPanel'; import { EntitiesToExportContext } from './EntitiesToExportContext'; import { ExportItemTypeNodeRenderer } from './ExportItemTypeNodeRenderer'; import { ExportPluginNodeRenderer } from './ExportPluginNodeRenderer'; import { ExportSchemaOverview } from './ExportSchemaOverview'; -import { useAnimatedNodes } from './useAnimatedNodes'; import { useExportGraph } from './useExportGraph'; // Map React Flow node types to their respective renderer components. @@ -47,7 +50,7 @@ type Props = { /** * Presents the export graph, wiring selection state, dependency resolution, and * export call-outs. For large selections it warns before rendering the full canvas. -*/ + */ export default function Inner({ initialItemTypes, schema, @@ -168,7 +171,12 @@ export default function Inner({ setPendingZoomEntity(undefined); }, [fitBounds, fitView, graph, pendingZoomEntity, showGraph]); - const animatedNodes = useAnimatedNodes(showGraph && graph ? graph.nodes : []); + const graphNodes = useMemo(() => { + if (!showGraph || !graph) { + return []; + } + return graph.nodes; + }, [graph, showGraph]); const handleSelectEntity = useCallback( ( @@ -362,7 +370,9 @@ export default function Inner({ gap: 12, }} > -
    Could not load export graph
    +
    + Could not load export graph +
    @@ -408,7 +418,10 @@ export default function Inner({ }} aria-label="Export graph panel" > -
    +
    - {visibleBlocks.map( - ({ exportItemType, projectItemType }) => ( - - ), - )} + {visibleBlocks.map(({ exportItemType, projectItemType }) => ( + + ))}
    )} @@ -340,34 +345,20 @@ export default function ConflictsManager({ )}
    - {/** Precompute disabled state to attach tooltip when needed */} - {(() => { - return null; - })()} - {(() => { - const proceedDisabled = submitting || !valid || validating; - return ( -
    - -
    - ); - })()} + {/* Left slot intentionally empty for layout parity with Export flow */} +
    +
    + +

    The import will never alter any existing elements in the schema.

    diff --git a/import-export-schema/src/entrypoints/ImportPage/ImportItemTypeNodeRenderer.tsx b/import-export-schema/src/entrypoints/ImportPage/ImportItemTypeNodeRenderer.tsx index 307f4258..50390323 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ImportItemTypeNodeRenderer.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ImportItemTypeNodeRenderer.tsx @@ -5,9 +5,9 @@ import { type ItemTypeNode, ItemTypeNodeRenderer, } from '@/components/ItemTypeNodeRenderer'; +import { SelectedEntityContext } from '@/components/SchemaOverview/SelectedEntityContext'; import { ConflictsContext } from '@/entrypoints/ImportPage/ConflictsManager/ConflictsContext'; import { useResolutionStatusForItemType } from '@/entrypoints/ImportPage/ResolutionsForm'; -import { SelectedEntityContext } from '@/components/SchemaOverview/SelectedEntityContext'; /** * Renders import graph item-type nodes, overlaying conflict and resolution state styling. diff --git a/import-export-schema/src/entrypoints/ImportPage/ImportPluginNodeRenderer.tsx b/import-export-schema/src/entrypoints/ImportPage/ImportPluginNodeRenderer.tsx index 64fcee88..e6d3dab7 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ImportPluginNodeRenderer.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ImportPluginNodeRenderer.tsx @@ -5,8 +5,8 @@ import { type PluginNode, PluginNodeRenderer, } from '@/components/PluginNodeRenderer'; -import { ConflictsContext } from '@/entrypoints/ImportPage/ConflictsManager/ConflictsContext'; import { SelectedEntityContext } from '@/components/SchemaOverview/SelectedEntityContext'; +import { ConflictsContext } from '@/entrypoints/ImportPage/ConflictsManager/ConflictsContext'; import { useResolutionStatusForPlugin } from './ResolutionsForm'; export function ImportPluginNodeRenderer(props: NodeProps) { diff --git a/import-export-schema/src/entrypoints/ImportPage/ImportWorkflow.tsx b/import-export-schema/src/entrypoints/ImportPage/ImportWorkflow.tsx index c5c0c78c..5b6d4b1d 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ImportWorkflow.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ImportWorkflow.tsx @@ -4,10 +4,10 @@ import { BlankSlate } from '@/components/BlankSlate'; import type { ProjectSchema } from '@/utils/ProjectSchema'; import type { ExportDoc } from '@/utils/types'; import type { ExportSchema } from '../ExportPage/ExportSchema'; -import { Inner } from './Inner'; import type { Conflicts } from './ConflictsManager/buildConflicts'; import { ConflictsContext } from './ConflictsManager/ConflictsContext'; import FileDropZone from './FileDropZone'; +import { Inner } from './Inner'; import ResolutionsForm, { type Resolutions } from './ResolutionsForm'; type Props = { diff --git a/import-export-schema/src/entrypoints/ImportPage/Inner.tsx b/import-export-schema/src/entrypoints/ImportPage/Inner.tsx index 7c4b7d27..496cceac 100644 --- a/import-export-schema/src/entrypoints/ImportPage/Inner.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/Inner.tsx @@ -1,4 +1,6 @@ import type { SchemaTypes } from '@datocms/cma-client'; +import { faXmark } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Background, type NodeMouseHandler, @@ -7,20 +9,18 @@ import { useReactFlow, } from '@xyflow/react'; import { Button } from 'datocms-react-ui'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faXmark } from '@fortawesome/free-solid-svg-icons'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { SelectedEntityContext } from '@/components/SchemaOverview/SelectedEntityContext'; import { GRAPH_NODE_THRESHOLD } from '@/shared/constants/graph'; import { type AppNode, edgeTypes, type Graph } from '@/utils/graph/types'; import type { ProjectSchema } from '@/utils/ProjectSchema'; import type { ExportSchema } from '../ExportPage/ExportSchema'; import { buildGraphFromExportDoc } from './buildGraphFromExportDoc'; import ConflictsManager from './ConflictsManager'; +import { GraphEntitiesContext } from './GraphEntitiesContext'; import { ImportItemTypeNodeRenderer } from './ImportItemTypeNodeRenderer'; import { ImportPluginNodeRenderer } from './ImportPluginNodeRenderer'; import { useSkippedItemsAndPluginIds } from './ResolutionsForm'; -import { SelectedEntityContext } from '@/components/SchemaOverview/SelectedEntityContext'; -import { GraphEntitiesContext } from './GraphEntitiesContext'; // Map React Flow node types to the dedicated renderers for import graphs. const nodeTypes: NodeTypes = { @@ -191,90 +191,90 @@ export function Inner({ exportSchema, schema, ctx: _ctx }: Props) { -
    -
    -
    +
    -
    - -
    - {graph && showGraph && ( - setSelectedEntity(undefined)} - onNodeClick={onNodeClick} - > - - - )} - {graph && !showGraph && ( -
    -
    - This graph has {graph.nodes.length} nodes. Trying to render it may slow - down your browser. -
    +
    +
    - )} -
    -
    -
    -
    - -
    -
    -
    + {graph && showGraph && ( + setSelectedEntity(undefined)} + onNodeClick={onNodeClick} + > + + + )} + {graph && !showGraph && ( +
    +
    + This graph has {graph.nodes.length} nodes. Trying to render + it may slow down your browser. +
    + +
    + )} +
    + +
    +
    + +
    +
    +
    ); diff --git a/import-export-schema/src/entrypoints/ImportPage/importSchema.ts b/import-export-schema/src/entrypoints/ImportPage/importSchema.ts index 2b6185e8..71048164 100644 --- a/import-export-schema/src/entrypoints/ImportPage/importSchema.ts +++ b/import-export-schema/src/entrypoints/ImportPage/importSchema.ts @@ -274,7 +274,9 @@ async function createItemTypesPhase( try { debugLog('Creating item type', data); - const { data: created } = await client.itemTypes.rawCreate({ data }); + const { data: created } = await client.itemTypes.rawCreate({ + data, + }); debugLog('Created item type', created); return created; } catch (error) { @@ -291,9 +293,7 @@ async function createItemTypesPhase( /** * Create fieldsets and fields for each item type, respecting dependencies and validators. */ -async function createFieldsetsAndFieldsPhase( - context: ImportContext, -) { +async function createFieldsetsAndFieldsPhase(context: ImportContext) { const { client, tracker, @@ -494,10 +494,8 @@ async function finalizeItemTypesPhase( ) ) { debugLog('Finalizing item type', data); - const { data: updatedItemType } = await client.itemTypes.rawUpdate( - id, - { data }, - ); + const { data: updatedItemType } = + await client.itemTypes.rawUpdate(id, { data }); debugLog('Finalized item type', updatedItemType); } } catch (error) { diff --git a/import-export-schema/src/entrypoints/ImportPage/index.tsx b/import-export-schema/src/entrypoints/ImportPage/index.tsx index 74a645ef..593d4593 100644 --- a/import-export-schema/src/entrypoints/ImportPage/index.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/index.tsx @@ -5,15 +5,18 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { TaskOverlayStack } from '@/components/TaskOverlayStack'; import { useConflictsBuilder } from '@/shared/hooks/useConflictsBuilder'; import { useProjectSchema } from '@/shared/hooks/useProjectSchema'; -import { useLongTask, type UseLongTaskResult } from '@/shared/tasks/useLongTask'; +import { + type UseLongTaskResult, + useLongTask, +} from '@/shared/tasks/useLongTask'; import type { ProjectSchema } from '@/utils/ProjectSchema'; import type { ExportDoc } from '@/utils/types'; import { ExportSchema } from '../ExportPage/ExportSchema'; -import { ImportWorkflow } from './ImportWorkflow'; import { buildImportDoc } from './buildImportDoc'; +import { ImportWorkflow } from './ImportWorkflow'; +import importSchema from './importSchema'; import type { Resolutions } from './ResolutionsForm'; import { useRecipeLoader } from './useRecipeLoader'; -import importSchema from './importSchema'; type Props = { ctx: RenderPageCtx; @@ -69,11 +72,9 @@ function useImportMode({ [ctx], ); - const { loading: loadingRecipe } = useRecipeLoader( - ctx, - handleRecipeLoaded, - { onError: handleRecipeError }, - ); + const { loading: loadingRecipe } = useRecipeLoader(ctx, handleRecipeLoaded, { + onError: handleRecipeError, + }); const handleDrop = useCallback( async (filename: string, doc: ExportDoc) => { @@ -82,7 +83,9 @@ function useImportMode({ setExportSchema([filename, schema]); } catch (error) { console.error(error); - ctx.alert(error instanceof Error ? error.message : 'Invalid export file!'); + ctx.alert( + error instanceof Error ? error.message : 'Invalid export file!', + ); } }, [ctx], @@ -149,7 +152,15 @@ function useImportMode({ importTask.controller.reset(); } }, - [client, conflicts, ctx, exportSchema, importTask, setConflicts, setExportSchema], + [ + client, + conflicts, + ctx, + exportSchema, + importTask, + setConflicts, + setExportSchema, + ], ); useEffect(() => { @@ -285,7 +296,9 @@ function buildImportOverlay( : 'Sit tight, we’re applying models, fields, and plugins…', ariaLabel: 'Import in progress', progressLabel: (progress, state) => - state.cancelRequested ? 'Stopping at next safe point…' : progress.label ?? '', + state.cancelRequested + ? 'Stopping at next safe point…' + : (progress.label ?? ''), cancel: () => ({ label: 'Cancel import', intent: importTask.state.cancelRequested ? 'muted' : 'negative', @@ -321,7 +334,9 @@ function buildImportOverlay( /** * Overlay used while conflicts between project and recipe are resolved. */ -function buildConflictsOverlay(conflictsTask: UseLongTaskResult): OverlayConfig { +function buildConflictsOverlay( + conflictsTask: UseLongTaskResult, +): OverlayConfig { return { id: 'conflicts', task: conflictsTask, diff --git a/import-export-schema/src/entrypoints/ImportPage/useRecipeLoader.ts b/import-export-schema/src/entrypoints/ImportPage/useRecipeLoader.ts index 1393f40c..ae710ad6 100644 --- a/import-export-schema/src/entrypoints/ImportPage/useRecipeLoader.ts +++ b/import-export-schema/src/entrypoints/ImportPage/useRecipeLoader.ts @@ -1,5 +1,5 @@ -import { useEffect, useState } from 'react'; import type { RenderPageCtx } from 'datocms-plugin-sdk'; +import { useEffect, useState } from 'react'; import type { ExportDoc } from '@/utils/types'; import { ExportSchema } from '../ExportPage/ExportSchema'; diff --git a/import-export-schema/src/index.css b/import-export-schema/src/index.css index 0ca3e25d..a584615a 100644 --- a/import-export-schema/src/index.css +++ b/import-export-schema/src/index.css @@ -895,7 +895,6 @@ button.chip:focus-visible { width: 100%; } - .import__graph, .import__details, .export__graph, @@ -934,7 +933,6 @@ button.chip:focus-visible { z-index: 5; } - .conflict { border-bottom: 1px solid var(--border-color); @@ -1080,7 +1078,9 @@ button.chip:focus-visible { border-radius: 10px; margin: 6px var(--spacing-l); background: #fff; - transition: border-color 0.2s ease, background-color 0.2s ease; + transition: + border-color 0.2s ease, + background-color 0.2s ease; } .import__details .schema-overview__item.conflict { diff --git a/import-export-schema/src/main.tsx b/import-export-schema/src/main.tsx index 87a173fa..42042a9a 100644 --- a/import-export-schema/src/main.tsx +++ b/import-export-schema/src/main.tsx @@ -28,7 +28,9 @@ connect({ ]; }, async executeSchemaItemTypeDropdownAction(_id, itemType, ctx) { - const environmentPrefix = ctx.isEnvironmentPrimary ? '' : `/environments/${ctx.environment}`; + const environmentPrefix = ctx.isEnvironmentPrimary + ? '' + : `/environments/${ctx.environment}`; const exportPagePath = `/configuration/p/${ctx.plugin.id}/pages/export`; const navigateUrl = `${environmentPrefix}${exportPagePath}?itemTypeId=${itemType.id}`; diff --git a/import-export-schema/src/utils/emojiAgnosticSorter.ts b/import-export-schema/src/utils/emojiAgnosticSorter.ts index 706e6f16..b0945144 100644 --- a/import-export-schema/src/utils/emojiAgnosticSorter.ts +++ b/import-export-schema/src/utils/emojiAgnosticSorter.ts @@ -1,11 +1,7 @@ import emojiRegexText from 'emoji-regex'; -/** - * Helpers for ordering text labels that may start with representative emojis. - */ const emojiRegexp = emojiRegexText(); -// Capture an optional leading emoji and strip padding spaces. const formatRegexp = new RegExp( `^(${emojiRegexp.source})\\s*(.*)$`, emojiRegexp.flags.replace('g', ''), diff --git a/import-export-schema/src/utils/graph/buildHierarchyNodes.ts b/import-export-schema/src/utils/graph/buildHierarchyNodes.ts index 40a34806..ec70f5cd 100644 --- a/import-export-schema/src/utils/graph/buildHierarchyNodes.ts +++ b/import-export-schema/src/utils/graph/buildHierarchyNodes.ts @@ -66,7 +66,9 @@ export function buildHierarchyNodes( : []; const candidates = - edgesPointingToNode.length > 0 ? edgesPointingToNode : fallbackCandidates; + edgesPointingToNode.length > 0 + ? edgesPointingToNode + : fallbackCandidates; if (candidates.length === 0) { return candidates[0]?.source; diff --git a/import-export-schema/src/utils/icons.tsx b/import-export-schema/src/utils/icons.tsx index fb3e330d..30900689 100644 --- a/import-export-schema/src/utils/icons.tsx +++ b/import-export-schema/src/utils/icons.tsx @@ -2,7 +2,6 @@ import DiamondIcon from '@/icons/diamond.svg?react'; import GridIcon from '@/icons/grid-2.svg?react'; import PuzzlePieceIcon from '@/icons/puzzle-piece.svg?react'; -/** Central place to expose schema-related icons for node renderers. */ const BlockIcon = () => { return ; }; diff --git a/import-export-schema/src/utils/isDefined.ts b/import-export-schema/src/utils/isDefined.ts index 614d1dbf..fdca5ef1 100644 --- a/import-export-schema/src/utils/isDefined.ts +++ b/import-export-schema/src/utils/isDefined.ts @@ -1,4 +1,3 @@ -/** Type guard that filters out null/undefined/false values in array helpers. */ export function isDefined( value: T | null | undefined | false, ): value is NonNullable> { diff --git a/import-export-schema/src/utils/render.tsx b/import-export-schema/src/utils/render.tsx index 306f2dac..af01c15c 100644 --- a/import-export-schema/src/utils/render.tsx +++ b/import-export-schema/src/utils/render.tsx @@ -4,7 +4,6 @@ import { createRoot } from 'react-dom/client'; const container = document.getElementById('root'); const root = createRoot(container!); -/** Render the plugin entry component with React strict mode enabled. */ export function render(component: React.ReactNode): void { root.render({component}); } diff --git a/import-export-schema/src/utils/types.ts b/import-export-schema/src/utils/types.ts index 866766ba..6f5fc385 100644 --- a/import-export-schema/src/utils/types.ts +++ b/import-export-schema/src/utils/types.ts @@ -1,6 +1,5 @@ import type { SchemaTypes } from '@datocms/cma-client'; -/** Canonical export document shapes handled by the importer/exporter. */ export type ExportDocV1 = { version: '1'; entities: Array< From 964dcaa169c9953693f67bde0a86fb6f642bb1c7 Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Tue, 23 Sep 2025 14:48:09 +0200 Subject: [PATCH 25/36] refactor --- import-export-schema/src/components/Field.tsx | 2 +- import-export-schema/src/components/PluginNodeRenderer.tsx | 2 +- .../src/components/SchemaOverview/SelectedEntityContext.tsx | 2 ++ .../src/entrypoints/ExportPage/buildGraphFromSchema.ts | 3 --- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/import-export-schema/src/components/Field.tsx b/import-export-schema/src/components/Field.tsx index 3b9dcbdb..c17db0cd 100644 --- a/import-export-schema/src/components/Field.tsx +++ b/import-export-schema/src/components/Field.tsx @@ -1,9 +1,9 @@ -import type { SchemaTypes } from '@datocms/cma-client'; import { fieldGroupColors, fieldTypeDescriptions, fieldTypeGroups, } from '@/utils/datocms/schema'; +import type { SchemaTypes } from '@datocms/cma-client'; export function Field({ field }: { field: SchemaTypes.Field }) { const group = fieldTypeGroups.find((g) => diff --git a/import-export-schema/src/components/PluginNodeRenderer.tsx b/import-export-schema/src/components/PluginNodeRenderer.tsx index fe327e72..c34c5e90 100644 --- a/import-export-schema/src/components/PluginNodeRenderer.tsx +++ b/import-export-schema/src/components/PluginNodeRenderer.tsx @@ -1,3 +1,4 @@ +import { Schema } from '@/utils/icons'; import type { SchemaTypes } from '@datocms/cma-client'; import { Handle, @@ -8,7 +9,6 @@ import { useStore, } from '@xyflow/react'; import classNames from 'classnames'; -import { Schema } from '@/utils/icons'; export type PluginNode = Node< { diff --git a/import-export-schema/src/components/SchemaOverview/SelectedEntityContext.tsx b/import-export-schema/src/components/SchemaOverview/SelectedEntityContext.tsx index 372263b6..3ab853fc 100644 --- a/import-export-schema/src/components/SchemaOverview/SelectedEntityContext.tsx +++ b/import-export-schema/src/components/SchemaOverview/SelectedEntityContext.tsx @@ -1,3 +1,5 @@ +// Provides a shared context for the schema overview to track the currently +// selected entity and expose a setter used by graph interactions. import type { SchemaTypes } from '@datocms/cma-client'; import { createContext } from 'react'; diff --git a/import-export-schema/src/entrypoints/ExportPage/buildGraphFromSchema.ts b/import-export-schema/src/entrypoints/ExportPage/buildGraphFromSchema.ts index 51fb3aff..f23a3273 100644 --- a/import-export-schema/src/entrypoints/ExportPage/buildGraphFromSchema.ts +++ b/import-export-schema/src/entrypoints/ExportPage/buildGraphFromSchema.ts @@ -12,8 +12,6 @@ type Options = { installedPluginIds?: Set; }; -// Note: queue type was unused; removed for strict build - /** * Lightweight wrapper that adapts the current project schema into the shared * `buildGraph` helper so the export view can render a dependency graph. @@ -36,5 +34,4 @@ export async function buildGraphFromSchema({ }); } -// The helper exports moved to utils/graph; kept named export for compatibility if imported elsewhere export type { SchemaTypes }; From c9c137bd53c1cc5dab2453951c92e5ba3d7bc895 Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Tue, 23 Sep 2025 15:51:46 +0200 Subject: [PATCH 26/36] roolback --- import-export-schema/biome.json | 26 ++-- import-export-schema/package-lock.json | 164 ------------------------- import-export-schema/package.json | 1 - 3 files changed, 8 insertions(+), 183 deletions(-) diff --git a/import-export-schema/biome.json b/import-export-schema/biome.json index 72e05e33..5d64b5e3 100644 --- a/import-export-schema/biome.json +++ b/import-export-schema/biome.json @@ -1,18 +1,21 @@ { - "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json", + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "files": { - "ignoreUnknown": false + "ignoreUnknown": false, + "ignore": ["vite.config.ts", "tsconfig.app.json", "tsconfig.node.json"] }, "formatter": { "enabled": true, "indentStyle": "space" }, - + "organizeImports": { + "enabled": true + }, "linter": { "enabled": true, "rules": { @@ -21,8 +24,7 @@ "noNonNullAssertion": "off" }, "a11y": { - "useKeyWithClickEvents": "off", - "useSemanticElements": "off" + "useKeyWithClickEvents": "off" }, "correctness": { "useExhaustiveDependencies": "off" @@ -36,17 +38,5 @@ "formatter": { "quoteStyle": "single" } - }, - "overrides": [ - { - "includes": ["src/types/lodash-es.d.ts"], - "linter": { - "rules": { - "suspicious": { - "noExplicitAny": "off" - } - } - } - } - ] + } } diff --git a/import-export-schema/package-lock.json b/import-export-schema/package-lock.json index 74be9bf8..fa7c4053 100644 --- a/import-export-schema/package-lock.json +++ b/import-export-schema/package-lock.json @@ -26,7 +26,6 @@ "react-final-form": "^6.5.9" }, "devDependencies": { - "@biomejs/biome": "^2.2.0", "@types/node": "^22.13.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -336,169 +335,6 @@ "node": ">=6.9.0" } }, - "node_modules/@biomejs/biome": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.2.0.tgz", - "integrity": "sha512-3On3RSYLsX+n9KnoSgfoYlckYBoU6VRM22cw1gB4Y0OuUVSYd/O/2saOJMrA4HFfA1Ff0eacOvMN1yAAvHtzIw==", - "dev": true, - "license": "MIT OR Apache-2.0", - "bin": { - "biome": "bin/biome" - }, - "engines": { - "node": ">=14.21.3" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/biome" - }, - "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.2.0", - "@biomejs/cli-darwin-x64": "2.2.0", - "@biomejs/cli-linux-arm64": "2.2.0", - "@biomejs/cli-linux-arm64-musl": "2.2.0", - "@biomejs/cli-linux-x64": "2.2.0", - "@biomejs/cli-linux-x64-musl": "2.2.0", - "@biomejs/cli-win32-arm64": "2.2.0", - "@biomejs/cli-win32-x64": "2.2.0" - } - }, - "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.0.tgz", - "integrity": "sha512-zKbwUUh+9uFmWfS8IFxmVD6XwqFcENjZvEyfOxHs1epjdH3wyyMQG80FGDsmauPwS2r5kXdEM0v/+dTIA9FXAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.0.tgz", - "integrity": "sha512-+OmT4dsX2eTfhD5crUOPw3RPhaR+SKVspvGVmSdZ9y9O/AgL8pla6T4hOn1q+VAFBHuHhsdxDRJgFCSC7RaMOw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.0.tgz", - "integrity": "sha512-6eoRdF2yW5FnW9Lpeivh7Mayhq0KDdaDMYOJnH9aT02KuSIX5V1HmWJCQQPwIQbhDh68Zrcpl8inRlTEan0SXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.0.tgz", - "integrity": "sha512-egKpOa+4FL9YO+SMUMLUvf543cprjevNc3CAgDNFLcjknuNMcZ0GLJYa3EGTCR2xIkIUJDVneBV3O9OcIlCEZQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-x64": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.0.tgz", - "integrity": "sha512-5UmQx/OZAfJfi25zAnAGHUMuOd+LOsliIt119x2soA2gLggQYrVPA+2kMUxR6Mw5M1deUF/AWWP2qpxgH7Nyfw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.0.tgz", - "integrity": "sha512-I5J85yWwUWpgJyC1CcytNSGusu2p9HjDnOPAFG4Y515hwRD0jpR9sT9/T1cKHtuCvEQ/sBvx+6zhz9l9wEJGAg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.0.tgz", - "integrity": "sha512-n9a1/f2CwIDmNMNkFs+JI0ZjFnMO0jdOyGNtihgUNFnlmd84yIYY2KMTBmMV58ZlVHjgmY5Y6E1hVTnSRieggA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-win32-x64": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.0.tgz", - "integrity": "sha512-Nawu5nHjP/zPKTIryh2AavzTc/KEg4um/MxWdXW0A6P/RZOyIpa7+QSjeXwAwX/utJGaCoXRPWtF3m5U/bB3Ww==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.21.3" - } - }, "node_modules/@csstools/cascade-layer-name-parser": { "version": "1.0.13", "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-1.0.13.tgz", diff --git a/import-export-schema/package.json b/import-export-schema/package.json index d73d6d67..13ffdbc6 100644 --- a/import-export-schema/package.json +++ b/import-export-schema/package.json @@ -49,7 +49,6 @@ "react-final-form": "^6.5.9" }, "devDependencies": { - "@biomejs/biome": "^2.2.0", "@types/node": "^22.13.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", From ee7eda291dc7037d8f40d1e9a06cd3d857570026 Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Tue, 23 Sep 2025 16:08:06 +0200 Subject: [PATCH 27/36] remove --- import-export-schema/AGENTS.md | 29 ----------------------------- 1 file changed, 29 deletions(-) delete mode 100644 import-export-schema/AGENTS.md diff --git a/import-export-schema/AGENTS.md b/import-export-schema/AGENTS.md deleted file mode 100644 index 99c029d9..00000000 --- a/import-export-schema/AGENTS.md +++ /dev/null @@ -1,29 +0,0 @@ -# Repository Guidelines - -## Project Structure & Module Organization -- `src/entrypoints/` hosts the Config, Export, and Import plugin pages plus local helpers; `index.tsx` wires each page to DatoCMS. -- `src/components/` gathers shared React pieces such as overlays, selectors, and graph controls. -- `src/utils/` contains schema builders, progress types, download helpers, and graph utilities; extend existing modules before adding new single-use files. -- `public/` and `index.html` power the Vite shell; the production entry lives at `dist/index.html`. Keep `dist/` build-only. -- `docs/` stores marketplace assets and baseline QA notes (`docs/refactor-baseline.md`). - -## Build, Test, and Development Commands -- `npm run build` - -## Coding Style & Naming Conventions -- Language stack: TypeScript + React 18 with Vite. -- Follow Biome defaults (2-space indent, single quotes, sorted imports). Run `npm run format` prior to commits. -- Use PascalCase for components (`ExportLandingPanel.tsx`), camelCase for functions/variables, and PascalCase for types/interfaces. -- Prefer CSS modules; reference class names via `styles.` and reuse design tokens from `datocms-react-ui`. - -## Security & Configuration Tips -- Never log or hardcode tokens; rely on `@datocms/cma-client` injected credentials. -- Avoid mutating existing schema objects in place; prefer additive or cloned changes to prevent data loss. -- Inspect diffs for accidental secrets or large asset files before pushing. - -## Active Engineering Tasks (September 17, 2025) -- [x] Deduplicate export task handling by introducing a shared helper/hook that wraps `buildExportDoc` and `useLongTask`. -- [x] Centralize long-task overlay composition so entrypoints declare overlays declaratively instead of repeating JSX. -- [x] Break down `src/entrypoints/ExportPage/Inner.tsx` into smaller modules and move dependency-closure logic to a shared utility. -- [x] Improve `useExportSelection` to reuse cached item types instead of per-id fetch loops. -- [x] Tackle remaining polish items (debug logging helper, shared graph threshold config, styling cleanup) to keep the codebase DRY. From 2ea5e40667d04151d126ce9d3638a9ba422fb01a Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Tue, 23 Sep 2025 16:17:45 +0200 Subject: [PATCH 28/36] types --- import-export-schema/src/types/lodash-es.d.ts | 19 +++---------------- import-export-schema/tsconfig.app.tsbuildinfo | 2 +- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/import-export-schema/src/types/lodash-es.d.ts b/import-export-schema/src/types/lodash-es.d.ts index 0d5e8d5f..d07cb002 100644 --- a/import-export-schema/src/types/lodash-es.d.ts +++ b/import-export-schema/src/types/lodash-es.d.ts @@ -1,18 +1,5 @@ declare module 'lodash-es' { - // Minimal ambient module to satisfy TS in this project. For richer types, install @types/lodash-es. - export const get: any; - export const set: any; - export const pick: any; - export const omit: any; - export const intersection: any; - export const keyBy: any; - export const sortBy: any; - export const find: any; - export const without: any; - export const map: any; - export const defaults: any; - export const mapValues: any; - export const groupBy: any; - export const isEqual: any; - export const cloneDeep: any; + export * from 'lodash'; + import _ from 'lodash'; + export default _; } diff --git a/import-export-schema/tsconfig.app.tsbuildinfo b/import-export-schema/tsconfig.app.tsbuildinfo index f5a2205e..0798bc02 100644 --- a/import-export-schema/tsconfig.app.tsbuildinfo +++ b/import-export-schema/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/blankslate.tsx","./src/components/exportlandingpanel.tsx","./src/components/exportselectionpanel.tsx","./src/components/field.tsx","./src/components/fieldedgerenderer.tsx","./src/components/graphcanvas.tsx","./src/components/itemtypenoderenderer.tsx","./src/components/pluginnoderenderer.tsx","./src/components/progressoverlay.tsx","./src/components/progressstallnotice.tsx","./src/components/taskoverlaystack.tsx","./src/components/taskprogressoverlay.tsx","./src/components/bezier.ts","./src/components/schemaoverview/collapsible.tsx","./src/components/schemaoverview/selectedentitycontext.tsx","./src/entrypoints/config/index.tsx","./src/entrypoints/exporthome/index.tsx","./src/entrypoints/exportpage/dependencyactionspanel.tsx","./src/entrypoints/exportpage/entitiestoexportcontext.ts","./src/entrypoints/exportpage/exportitemtypenoderenderer.tsx","./src/entrypoints/exportpage/exportpluginnoderenderer.tsx","./src/entrypoints/exportpage/exportschema.ts","./src/entrypoints/exportpage/exportschemaoverview.tsx","./src/entrypoints/exportpage/inner.tsx","./src/entrypoints/exportpage/buildexportdoc.ts","./src/entrypoints/exportpage/buildgraphfromschema.ts","./src/entrypoints/exportpage/index.tsx","./src/entrypoints/exportpage/useanimatednodes.tsx","./src/entrypoints/exportpage/useexportgraph.ts","./src/entrypoints/importpage/filedropzone.tsx","./src/entrypoints/importpage/graphentitiescontext.tsx","./src/entrypoints/importpage/importitemtypenoderenderer.tsx","./src/entrypoints/importpage/importpluginnoderenderer.tsx","./src/entrypoints/importpage/importworkflow.tsx","./src/entrypoints/importpage/inner.tsx","./src/entrypoints/importpage/resolutionsform.tsx","./src/entrypoints/importpage/buildgraphfromexportdoc.ts","./src/entrypoints/importpage/buildimportdoc.ts","./src/entrypoints/importpage/importschema.ts","./src/entrypoints/importpage/index.tsx","./src/entrypoints/importpage/userecipeloader.ts","./src/entrypoints/importpage/conflictsmanager/conflictscontext.ts","./src/entrypoints/importpage/conflictsmanager/itemtypeconflict.tsx","./src/entrypoints/importpage/conflictsmanager/pluginconflict.tsx","./src/entrypoints/importpage/conflictsmanager/buildconflicts.ts","./src/entrypoints/importpage/conflictsmanager/index.tsx","./src/shared/constants/graph.ts","./src/shared/hooks/usecmaclient.ts","./src/shared/hooks/useconflictsbuilder.ts","./src/shared/hooks/useexportallhandler.ts","./src/shared/hooks/useexportselection.ts","./src/shared/hooks/useprojectschema.ts","./src/shared/hooks/useschemaexporttask.ts","./src/shared/tasks/uselongtask.ts","./src/types/lodash-es.d.ts","./src/utils/projectschema.ts","./src/utils/createcmaclient.ts","./src/utils/debug.ts","./src/utils/downloadjson.ts","./src/utils/emojiagnosticsorter.ts","./src/utils/icons.tsx","./src/utils/isdefined.ts","./src/utils/render.tsx","./src/utils/types.ts","./src/utils/datocms/appearance.ts","./src/utils/datocms/fieldtypeinfo.ts","./src/utils/datocms/schema.ts","./src/utils/graph/analysis.ts","./src/utils/graph/buildgraph.ts","./src/utils/graph/buildhierarchynodes.ts","./src/utils/graph/dependencies.ts","./src/utils/graph/edges.ts","./src/utils/graph/nodes.ts","./src/utils/graph/rebuildgraphwithpositionsfromhierarchy.ts","./src/utils/graph/sort.ts","./src/utils/graph/types.ts","./src/utils/schema/exportschemasource.ts","./src/utils/schema/ischemasource.ts","./src/utils/schema/projectschemasource.ts"],"version":"5.7.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/blankslate.tsx","./src/components/exportlandingpanel.tsx","./src/components/exportselectionpanel.tsx","./src/components/field.tsx","./src/components/fieldedgerenderer.tsx","./src/components/graphcanvas.tsx","./src/components/itemtypenoderenderer.tsx","./src/components/pluginnoderenderer.tsx","./src/components/progressoverlay.tsx","./src/components/progressstallnotice.tsx","./src/components/taskoverlaystack.tsx","./src/components/taskprogressoverlay.tsx","./src/components/bezier.ts","./src/components/schemaoverview/collapsible.tsx","./src/components/schemaoverview/selectedentitycontext.tsx","./src/entrypoints/config/index.tsx","./src/entrypoints/exporthome/index.tsx","./src/entrypoints/exportpage/dependencyactionspanel.tsx","./src/entrypoints/exportpage/entitiestoexportcontext.ts","./src/entrypoints/exportpage/exportitemtypenoderenderer.tsx","./src/entrypoints/exportpage/exportpluginnoderenderer.tsx","./src/entrypoints/exportpage/exportschema.ts","./src/entrypoints/exportpage/exportschemaoverview.tsx","./src/entrypoints/exportpage/inner.tsx","./src/entrypoints/exportpage/buildexportdoc.ts","./src/entrypoints/exportpage/buildgraphfromschema.ts","./src/entrypoints/exportpage/index.tsx","./src/entrypoints/exportpage/useexportgraph.ts","./src/entrypoints/importpage/filedropzone.tsx","./src/entrypoints/importpage/graphentitiescontext.tsx","./src/entrypoints/importpage/importitemtypenoderenderer.tsx","./src/entrypoints/importpage/importpluginnoderenderer.tsx","./src/entrypoints/importpage/importworkflow.tsx","./src/entrypoints/importpage/inner.tsx","./src/entrypoints/importpage/resolutionsform.tsx","./src/entrypoints/importpage/buildgraphfromexportdoc.ts","./src/entrypoints/importpage/buildimportdoc.ts","./src/entrypoints/importpage/importschema.ts","./src/entrypoints/importpage/index.tsx","./src/entrypoints/importpage/userecipeloader.ts","./src/entrypoints/importpage/conflictsmanager/conflictscontext.ts","./src/entrypoints/importpage/conflictsmanager/itemtypeconflict.tsx","./src/entrypoints/importpage/conflictsmanager/pluginconflict.tsx","./src/entrypoints/importpage/conflictsmanager/buildconflicts.ts","./src/entrypoints/importpage/conflictsmanager/index.tsx","./src/shared/constants/graph.ts","./src/shared/hooks/usecmaclient.ts","./src/shared/hooks/useconflictsbuilder.ts","./src/shared/hooks/useexportallhandler.ts","./src/shared/hooks/useexportselection.ts","./src/shared/hooks/useprojectschema.ts","./src/shared/hooks/useschemaexporttask.ts","./src/shared/tasks/uselongtask.ts","./src/types/lodash-es.d.ts","./src/utils/projectschema.ts","./src/utils/createcmaclient.ts","./src/utils/debug.ts","./src/utils/downloadjson.ts","./src/utils/emojiagnosticsorter.ts","./src/utils/icons.tsx","./src/utils/isdefined.ts","./src/utils/render.tsx","./src/utils/types.ts","./src/utils/datocms/appearance.ts","./src/utils/datocms/fieldtypeinfo.ts","./src/utils/datocms/schema.ts","./src/utils/graph/analysis.ts","./src/utils/graph/buildgraph.ts","./src/utils/graph/buildhierarchynodes.ts","./src/utils/graph/dependencies.ts","./src/utils/graph/edges.ts","./src/utils/graph/nodes.ts","./src/utils/graph/rebuildgraphwithpositionsfromhierarchy.ts","./src/utils/graph/sort.ts","./src/utils/graph/types.ts","./src/utils/schema/exportschemasource.ts","./src/utils/schema/ischemasource.ts","./src/utils/schema/projectschemasource.ts"],"version":"5.7.3"} \ No newline at end of file From 2bc20a94dddb0e43b91f9fa247c73e77384fd97c Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Tue, 23 Sep 2025 17:00:51 +0200 Subject: [PATCH 29/36] rollbackfix --- .../ImportPage/ConflictsManager/index.tsx | 30 ++++++++++++++++--- .../ImportPage/ResolutionsForm.tsx | 20 +------------ 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx index df16c296..b91b12b1 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx @@ -252,10 +252,32 @@ export default function ConflictsManager({ const hasConflicts = itemTypeConflictCount > 0 || pluginConflictCount > 0; - const proceedDisabled = submitting || !valid || validating; - const proceedTooltip = proceedDisabled - ? 'Select how to resolve the conflicts before proceeding' - : undefined; + const unresolvedModelConflicts = itemTypesByCategory.models.some( + ({ exportItemType, projectItemType }) => + isItemTypeConflictUnresolved(exportItemType, projectItemType), + ); + + const unresolvedBlockConflicts = itemTypesByCategory.blocks.some( + ({ exportItemType, projectItemType }) => + isItemTypeConflictUnresolved(exportItemType, projectItemType), + ); + + const unresolvedPluginConflicts = pluginEntries.some( + ({ exportPlugin, projectPlugin }) => + isPluginConflictUnresolved(exportPlugin, projectPlugin), + ); + + const hasUnresolvedConflicts = + unresolvedModelConflicts || + unresolvedBlockConflicts || + unresolvedPluginConflicts; + + const proceedDisabled = + submitting || validating || !valid || hasUnresolvedConflicts; + const proceedTooltip = + hasUnresolvedConflicts || !valid + ? 'Select how to resolve the conflicts before proceeding' + : undefined; return (
    diff --git a/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx b/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx index 3f5840f1..5e392e60 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx @@ -3,7 +3,6 @@ import { type ReactNode, useContext, useMemo } from 'react'; import { Form as FormHandler, useFormState } from 'react-final-form'; import type { ProjectSchema } from '@/utils/ProjectSchema'; import { ConflictsContext } from './ConflictsManager/ConflictsContext'; -import { GraphEntitiesContext } from './GraphEntitiesContext'; export type ItemTypeConflictResolutionRename = { strategy: 'rename'; @@ -70,7 +69,6 @@ function isValidApiKey(apiKey: string) { */ export default function ResolutionsForm({ schema, children, onSubmit }: Props) { const conflicts = useContext(ConflictsContext); - const graphEntities = useContext(GraphEntitiesContext); const initialValues = useMemo( () => @@ -108,10 +106,6 @@ export default function ResolutionsForm({ schema, children, onSubmit }: Props) { } for (const pluginId of Object.keys(conflicts.plugins)) { - if (!graphEntities.hasPluginNode(pluginId)) { - continue; - } - const result = get(values, [`plugin-${pluginId}`]) as PluginValues; if (result?.strategy) { resolutions.plugins[pluginId] = { @@ -121,10 +115,6 @@ export default function ResolutionsForm({ schema, children, onSubmit }: Props) { } for (const itemTypeId of Object.keys(conflicts.itemTypes)) { - if (!graphEntities.hasItemTypeNode(itemTypeId)) { - continue; - } - const fieldPrefix = `itemType-${itemTypeId}`; const result = get(values, fieldPrefix) as ItemTypeValues; @@ -139,7 +129,7 @@ export default function ResolutionsForm({ schema, children, onSubmit }: Props) { } } - onSubmit(resolutions); + await onSubmit(resolutions); } if (!conflicts) { @@ -161,10 +151,6 @@ export default function ResolutionsForm({ schema, children, onSubmit }: Props) { const itemTypesByApiKey = keyBy(projectItemTypes, 'attributes.api_key'); for (const pluginId of Object.keys(conflicts.plugins)) { - if (!graphEntities.hasPluginNode(pluginId)) { - continue; - } - const fieldPrefix = `plugin-${pluginId}`; if (!get(values, [fieldPrefix, 'strategy'])) { set(errors, [fieldPrefix, 'strategy'], 'Required!'); @@ -172,10 +158,6 @@ export default function ResolutionsForm({ schema, children, onSubmit }: Props) { } for (const itemTypeId of Object.keys(conflicts.itemTypes)) { - if (!graphEntities.hasItemTypeNode(itemTypeId)) { - continue; - } - const fieldPrefix = `itemType-${itemTypeId}`; const strategy = get(values, [fieldPrefix, 'strategy']); if (!strategy) { From e6764d7aa59334a24b286d711821956cf441330b Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Mon, 13 Oct 2025 18:04:59 +0200 Subject: [PATCH 30/36] fix appearences --- import-export-schema/package.json | 2 +- .../entrypoints/ImportPage/importSchema.ts | 16 +++-- .../src/utils/datocms/fieldTypeInfo.ts | 3 +- import-export-schema/vite.config.ts | 72 ++++++++++--------- 4 files changed, 50 insertions(+), 43 deletions(-) diff --git a/import-export-schema/package.json b/import-export-schema/package.json index 13ffdbc6..963e466d 100644 --- a/import-export-schema/package.json +++ b/import-export-schema/package.json @@ -3,7 +3,7 @@ "description": "Export one or multiple models/block models as JSON files for reimport into another DatoCMS project.", "homepage": "https://github.com/datocms/plugins", "private": false, - "version": "0.1.15", + "version": "0.1.16", "author": "Stefano Verna ", "type": "module", "keywords": [ diff --git a/import-export-schema/src/entrypoints/ImportPage/importSchema.ts b/import-export-schema/src/entrypoints/ImportPage/importSchema.ts index 71048164..3bccfe62 100644 --- a/import-export-schema/src/entrypoints/ImportPage/importSchema.ts +++ b/import-export-schema/src/entrypoints/ImportPage/importSchema.ts @@ -659,9 +659,17 @@ async function importField( field: SchemaTypes.Field, { client, locales, mappings }: ImportFieldOptions, ) { + const nextAppearance = await mapAppearanceToProject( + field, + mappings.pluginIds, + ); + const data: SchemaTypes.FieldCreateSchema['data'] = { ...field, id: mappings.fieldIds.get(field.id), + attributes: { + ...field.attributes, + }, relationships: { fieldset: { data: field.relationships.fieldset.data @@ -723,12 +731,8 @@ async function importField( }; } - (data.attributes as { appearance?: unknown }).appearance = undefined; - (data.attributes as { appeareance?: unknown }).appeareance = undefined; - const nextAppearance = await mapAppearanceToProject( - field, - mappings.pluginIds, - ); + delete (data.attributes as { appearance?: unknown }).appearance; + delete (data.attributes as { appeareance?: unknown }).appeareance; if (field.attributes.localized) { const oldDefaultValues = field.attributes.default_value as Record< diff --git a/import-export-schema/src/utils/datocms/fieldTypeInfo.ts b/import-export-schema/src/utils/datocms/fieldTypeInfo.ts index b9f6639e..e7d9eb9d 100644 --- a/import-export-schema/src/utils/datocms/fieldTypeInfo.ts +++ b/import-export-schema/src/utils/datocms/fieldTypeInfo.ts @@ -60,7 +60,8 @@ async function fetchFieldTypeInfo() { try { const response = await fetch('https://internal.datocms.com/field-types'); if (!response.ok) throw new Error(`HTTP ${response.status}`); - return (await response.json()) as FieldTypeInfo; + const data = (await response.json()) as FieldTypeInfo; + return data; } catch { // Fall back to a local static map to keep flows working safely return fallbackFieldTypeInfo(); diff --git a/import-export-schema/vite.config.ts b/import-export-schema/vite.config.ts index 2e51e27f..af61eb15 100644 --- a/import-export-schema/vite.config.ts +++ b/import-export-schema/vite.config.ts @@ -4,43 +4,45 @@ import { defineConfig } from 'vite'; import svgr from 'vite-plugin-svgr'; // https://vitejs.dev/config/ -export default defineConfig({ - base: './', - plugins: [ - react(), - // SVGR for SVG imports - svgr({ svgrOptions: {} }), - ], - resolve: { - alias: [ - { - find: '@', - replacement: fileURLToPath(new URL('./src', import.meta.url)), - }, +export default defineConfig(({ command }) => { + const isBuild = command === 'build'; + + return { + base: './', + plugins: [ + react(), + // SVGR for SVG imports + svgr({ svgrOptions: {} }), ], - }, - build: { - sourcemap: false, - cssCodeSplit: true, - chunkSizeWarningLimit: 1024, - rollupOptions: { - output: { - manualChunks: { - 'vendor-react': ['react', 'react-dom'], - 'vendor-datocms': ['datocms-plugin-sdk', 'datocms-react-ui'], - 'vendor-xyflow': ['@xyflow/react', 'd3-hierarchy', 'd3-timer'], - 'vendor-icons': [ - '@fortawesome/react-fontawesome', - '@fortawesome/fontawesome-svg-core', - '@fortawesome/free-solid-svg-icons', - ], - 'vendor-lodash': ['lodash-es'], + resolve: { + alias: [ + { + find: '@', + replacement: fileURLToPath(new URL('./src', import.meta.url)), + }, + ], + }, + build: { + sourcemap: false, + cssCodeSplit: true, + chunkSizeWarningLimit: 1024, + rollupOptions: { + output: { + manualChunks: { + 'vendor-react': ['react', 'react-dom'], + 'vendor-datocms': ['datocms-plugin-sdk', 'datocms-react-ui'], + 'vendor-xyflow': ['@xyflow/react', 'd3-hierarchy', 'd3-timer'], + 'vendor-icons': [ + '@fortawesome/react-fontawesome', + '@fortawesome/fontawesome-svg-core', + '@fortawesome/free-solid-svg-icons', + ], + 'vendor-lodash': ['lodash-es'], + }, }, }, }, - }, - // Drop consoles/debuggers in production bundles - esbuild: { - drop: ['console', 'debugger'], - }, + // Drop consoles/debuggers in production bundles + esbuild: isBuild ? { drop: ['console', 'debugger'] } : undefined, + }; }); From 99bc3dd069023c4664aa32369b7208759ab884a2 Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Mon, 13 Oct 2025 18:21:49 +0200 Subject: [PATCH 31/36] fix --- import-export-schema/package-lock.json | 37 ++++++++++++++++++- import-export-schema/package.json | 1 + .../src/components/ItemTypeNodeRenderer.tsx | 2 +- .../entrypoints/ExportPage/ExportSchema.ts | 2 +- .../src/entrypoints/ExportPage/Inner.tsx | 2 +- .../entrypoints/ExportPage/buildExportDoc.ts | 5 ++- .../ConflictsManager/buildConflicts.ts | 2 +- .../ImportPage/ConflictsManager/index.tsx | 2 +- .../ImportPage/ResolutionsForm.tsx | 4 +- .../entrypoints/ImportPage/importSchema.ts | 8 +++- import-export-schema/src/types/lodash-es.d.ts | 3 +- .../src/utils/datocms/schema.ts | 2 +- import-export-schema/src/utils/graph/edges.ts | 2 +- import-export-schema/src/utils/graph/sort.ts | 2 +- 14 files changed, 59 insertions(+), 15 deletions(-) diff --git a/import-export-schema/package-lock.json b/import-export-schema/package-lock.json index fa7c4053..d8739fda 100644 --- a/import-export-schema/package-lock.json +++ b/import-export-schema/package-lock.json @@ -1,12 +1,12 @@ { "name": "datocms-plugin-schema-import-export", - "version": "0.1.15", + "version": "0.1.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "datocms-plugin-schema-import-export", - "version": "0.1.15", + "version": "0.1.16", "dependencies": { "@datocms/cma-client": "^3.4.5", "@fortawesome/fontawesome-svg-core": "^6.7.2", @@ -26,6 +26,7 @@ "react-final-form": "^6.5.9" }, "devDependencies": { + "@types/lodash-es": "^4.17.12", "@types/node": "^22.13.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -80,6 +81,7 @@ "integrity": "sha512-l+lkXCHS6tQEc5oUpK28xBOZ6+HwaH7YwoYQbLFiYb4nS2/l1tKnZEtEWkD0GuiYdvArf9qBS0XlQGXzPMsNqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -447,6 +449,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": "^14 || ^16 || >=18" }, @@ -470,6 +473,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": "^14 || ^16 || >=18" } @@ -1952,6 +1956,7 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz", "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==", "license": "MIT", + "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.7.2" }, @@ -2687,12 +2692,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/node": { "version": "22.13.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz", "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.20.0" } @@ -2714,6 +2737,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2886,6 +2910,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -3162,6 +3187,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -3401,6 +3427,7 @@ "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.10.tgz", "integrity": "sha512-TL48Pi1oNHeMOHrKv1bCJUrWZDcD3DIG6AGYVNOnyZPr7Bd/pStN0pL+lfzF5BNoj/FclaoiaLenk4XUIFVYng==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.10.0" }, @@ -3776,6 +3803,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", @@ -4488,6 +4516,7 @@ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -4519,6 +4548,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -4531,6 +4561,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -4788,6 +4819,7 @@ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4883,6 +4915,7 @@ "integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/import-export-schema/package.json b/import-export-schema/package.json index 963e466d..0d9d6813 100644 --- a/import-export-schema/package.json +++ b/import-export-schema/package.json @@ -49,6 +49,7 @@ "react-final-form": "^6.5.9" }, "devDependencies": { + "@types/lodash-es": "^4.17.12", "@types/node": "^22.13.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", diff --git a/import-export-schema/src/components/ItemTypeNodeRenderer.tsx b/import-export-schema/src/components/ItemTypeNodeRenderer.tsx index 6880c8d3..3f4479d2 100644 --- a/import-export-schema/src/components/ItemTypeNodeRenderer.tsx +++ b/import-export-schema/src/components/ItemTypeNodeRenderer.tsx @@ -9,7 +9,7 @@ import { useStore, } from '@xyflow/react'; import classNames from 'classnames'; -import { sortBy } from 'lodash-es'; +import sortBy from 'lodash-es/sortBy'; import { useState } from 'react'; import { Schema } from '@/utils/icons'; import { Field } from '../components/Field'; diff --git a/import-export-schema/src/entrypoints/ExportPage/ExportSchema.ts b/import-export-schema/src/entrypoints/ExportPage/ExportSchema.ts index 1791d102..ee5b7230 100644 --- a/import-export-schema/src/entrypoints/ExportPage/ExportSchema.ts +++ b/import-export-schema/src/entrypoints/ExportPage/ExportSchema.ts @@ -5,7 +5,7 @@ * both behaviours working so older exports still import cleanly. */ import type { SchemaTypes } from '@datocms/cma-client'; -import { get } from 'lodash-es'; +import get from 'lodash-es/get'; import { findLinkedItemTypeIds } from '@/utils/datocms/schema'; import { isDefined } from '@/utils/isDefined'; import type { ExportDoc } from '@/utils/types'; diff --git a/import-export-schema/src/entrypoints/ExportPage/Inner.tsx b/import-export-schema/src/entrypoints/ExportPage/Inner.tsx index 18d1bc7b..c23775e4 100644 --- a/import-export-schema/src/entrypoints/ExportPage/Inner.tsx +++ b/import-export-schema/src/entrypoints/ExportPage/Inner.tsx @@ -10,7 +10,7 @@ import type { ProjectSchema } from '@/utils/ProjectSchema'; import '@xyflow/react/dist/style.css'; import type { RenderPageCtx } from 'datocms-plugin-sdk'; import { Button, Spinner, useCtx } from 'datocms-react-ui'; -import { without } from 'lodash-es'; +import without from 'lodash-es/without'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { GraphCanvas } from '@/components/GraphCanvas'; import { SelectedEntityContext } from '@/components/SchemaOverview/SelectedEntityContext'; diff --git a/import-export-schema/src/entrypoints/ExportPage/buildExportDoc.ts b/import-export-schema/src/entrypoints/ExportPage/buildExportDoc.ts index 67fa73aa..2651e76d 100644 --- a/import-export-schema/src/entrypoints/ExportPage/buildExportDoc.ts +++ b/import-export-schema/src/entrypoints/ExportPage/buildExportDoc.ts @@ -1,4 +1,7 @@ -import { cloneDeep, get, intersection, set } from 'lodash-es'; +import cloneDeep from 'lodash-es/cloneDeep'; +import get from 'lodash-es/get'; +import intersection from 'lodash-es/intersection'; +import set from 'lodash-es/set'; import { ensureExportableAppearance } from '@/utils/datocms/appearance'; import { validatorsContainingBlocks, diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/buildConflicts.ts b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/buildConflicts.ts index 6cc82b94..68a7c17c 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/buildConflicts.ts +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/buildConflicts.ts @@ -1,5 +1,5 @@ import type { SchemaTypes } from '@datocms/cma-client'; -import { keyBy } from 'lodash-es'; +import keyBy from 'lodash-es/keyBy'; import type { ExportSchema } from '@/entrypoints/ExportPage/ExportSchema'; import type { ProjectSchema } from '@/utils/ProjectSchema'; diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx index b91b12b1..358b1339 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx @@ -1,7 +1,7 @@ import type { SchemaTypes } from '@datocms/cma-client'; import type { RenderPageCtx } from 'datocms-plugin-sdk'; import { Button, SwitchInput } from 'datocms-react-ui'; -import { get } from 'lodash-es'; +import get from 'lodash-es/get'; import { useContext, useId, useMemo, useState } from 'react'; import { useFormState } from 'react-final-form'; import type { ExportSchema } from '@/entrypoints/ExportPage/ExportSchema'; diff --git a/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx b/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx index 5e392e60..403c0a80 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx @@ -1,4 +1,6 @@ -import { get, keyBy, set } from 'lodash-es'; +import get from 'lodash-es/get'; +import keyBy from 'lodash-es/keyBy'; +import set from 'lodash-es/set'; import { type ReactNode, useContext, useMemo } from 'react'; import { Form as FormHandler, useFormState } from 'react-final-form'; import type { ProjectSchema } from '@/utils/ProjectSchema'; diff --git a/import-export-schema/src/entrypoints/ImportPage/importSchema.ts b/import-export-schema/src/entrypoints/ImportPage/importSchema.ts index 3bccfe62..32261856 100644 --- a/import-export-schema/src/entrypoints/ImportPage/importSchema.ts +++ b/import-export-schema/src/entrypoints/ImportPage/importSchema.ts @@ -1,5 +1,11 @@ import { type Client, generateId, type SchemaTypes } from '@datocms/cma-client'; -import { find, get, isEqual, omit, pick, set, sortBy } from 'lodash-es'; +import find from 'lodash-es/find'; +import get from 'lodash-es/get'; +import isEqual from 'lodash-es/isEqual'; +import omit from 'lodash-es/omit'; +import pick from 'lodash-es/pick'; +import set from 'lodash-es/set'; +import sortBy from 'lodash-es/sortBy'; import { mapAppearanceToProject } from '@/utils/datocms/appearance'; import { validatorsContainingBlocks, diff --git a/import-export-schema/src/types/lodash-es.d.ts b/import-export-schema/src/types/lodash-es.d.ts index d07cb002..3b593500 100644 --- a/import-export-schema/src/types/lodash-es.d.ts +++ b/import-export-schema/src/types/lodash-es.d.ts @@ -1,5 +1,4 @@ declare module 'lodash-es' { export * from 'lodash'; - import _ from 'lodash'; - export default _; + export { default } from 'lodash'; } diff --git a/import-export-schema/src/utils/datocms/schema.ts b/import-export-schema/src/utils/datocms/schema.ts index 1761fa35..e96e36af 100644 --- a/import-export-schema/src/utils/datocms/schema.ts +++ b/import-export-schema/src/utils/datocms/schema.ts @@ -1,6 +1,6 @@ import type { SchemaTypes } from '@datocms/cma-client'; import type { FieldAttributes } from '@datocms/cma-client/dist/types/generated/SchemaTypes'; -import { get } from 'lodash-es'; +import get from 'lodash-es/get'; /** * Shared lookups and helper utilities for interpreting DatoCMS field metadata. */ diff --git a/import-export-schema/src/utils/graph/edges.ts b/import-export-schema/src/utils/graph/edges.ts index 2a122af9..8a562eb4 100644 --- a/import-export-schema/src/utils/graph/edges.ts +++ b/import-export-schema/src/utils/graph/edges.ts @@ -1,6 +1,6 @@ import type { SchemaTypes } from '@datocms/cma-client'; import { MarkerType } from '@xyflow/react'; -import { find } from 'lodash-es'; +import find from 'lodash-es/find'; import { findLinkedItemTypeIds, findLinkedPluginIds, diff --git a/import-export-schema/src/utils/graph/sort.ts b/import-export-schema/src/utils/graph/sort.ts index b9e24fea..00573e01 100644 --- a/import-export-schema/src/utils/graph/sort.ts +++ b/import-export-schema/src/utils/graph/sort.ts @@ -1,4 +1,4 @@ -import { sortBy } from 'lodash-es'; +import sortBy from 'lodash-es/sortBy'; import type { AppNode, Graph } from '@/utils/graph/types'; /** Stable ordering so layout + list views don't flicker across renders. */ From 85eda6e2581cec1319fb7acfa74b50a1175bab1d Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Mon, 13 Oct 2025 18:22:26 +0200 Subject: [PATCH 32/36] version bump --- import-export-schema/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/import-export-schema/package.json b/import-export-schema/package.json index 0d9d6813..5acdee0e 100644 --- a/import-export-schema/package.json +++ b/import-export-schema/package.json @@ -3,7 +3,7 @@ "description": "Export one or multiple models/block models as JSON files for reimport into another DatoCMS project.", "homepage": "https://github.com/datocms/plugins", "private": false, - "version": "0.1.16", + "version": "0.1.17", "author": "Stefano Verna ", "type": "module", "keywords": [ From 63cb9f7321b5943019696a09f12b4a63fc14cea3 Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Wed, 22 Oct 2025 17:54:41 +0200 Subject: [PATCH 33/36] plugin conflict bug fix --- import-export-schema/package.json | 5 +- .../ConflictsManager/PluginConflict.tsx | 9 +- .../ImportPage/ConflictsManager/index.tsx | 175 +++++++++++------- 3 files changed, 116 insertions(+), 73 deletions(-) diff --git a/import-export-schema/package.json b/import-export-schema/package.json index 5acdee0e..d9838927 100644 --- a/import-export-schema/package.json +++ b/import-export-schema/package.json @@ -3,7 +3,7 @@ "description": "Export one or multiple models/block models as JSON files for reimport into another DatoCMS project.", "homepage": "https://github.com/datocms/plugins", "private": false, - "version": "0.1.17", + "version": "0.1.18", "author": "Stefano Verna ", "type": "module", "keywords": [ @@ -58,5 +58,6 @@ "typescript": "^5.5.3", "vite": "^5.4.1", "vite-plugin-svgr": "^4.3.0" - } + }, + "packageManager": "pnpm@10.19.0+sha512.c9fc7236e92adf5c8af42fd5bf1612df99c2ceb62f27047032f4720b33f8eacdde311865e91c411f2774f618d82f320808ecb51718bfa82c060c4ba7c76a32b8" } diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx index fe1bda7e..c25e2a9f 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/PluginConflict.tsx @@ -1,9 +1,8 @@ import type { SchemaTypes } from '@datocms/cma-client'; import { SelectField } from 'datocms-react-ui'; -import { useContext, useId } from 'react'; +import { useId } from 'react'; import { Field } from 'react-final-form'; import Collapsible from '@/components/SchemaOverview/Collapsible'; -import { GraphEntitiesContext } from '../GraphEntitiesContext'; import { useResolutionStatusForPlugin } from '../ResolutionsForm'; type Option = { label: string; value: string }; @@ -30,8 +29,6 @@ export function PluginConflict({ exportPlugin, projectPlugin }: Props) { const selectId = useId(); const fieldPrefix = `plugin-${exportPlugin.id}`; const resolution = useResolutionStatusForPlugin(exportPlugin.id); - const { hasPluginNode } = useContext(GraphEntitiesContext); - const nodeExists = hasPluginNode(exportPlugin.id); const strategy = resolution?.values?.strategy; const hasValidResolution = Boolean( @@ -41,10 +38,6 @@ export function PluginConflict({ exportPlugin, projectPlugin }: Props) { const hasConflict = Boolean(projectPlugin) && !hasValidResolution; - if (!nodeExists) { - return null; - } - return ( + const sortByUnresolvedThenName = (items: ItemTypeEntry[]) => sortEntriesByDisplayName( [...items].sort((a, b) => { + // Unresolved first + const aUnresolved = isItemTypeConflictUnresolved( + a.exportItemType, + a.projectItemType, + ); + const bUnresolved = isItemTypeConflictUnresolved( + b.exportItemType, + b.projectItemType, + ); + if (aUnresolved !== bUnresolved) { + return aUnresolved ? -1 : 1; + } + // Then any remaining conflicts (already resolved) before non-conflicts const aHasConflict = Boolean(a.projectItemType); const bHasConflict = Boolean(b.projectItemType); - if (aHasConflict === bHasConflict) { - return 0; + if (aHasConflict !== bHasConflict) { + return aHasConflict ? -1 : 1; } - return aHasConflict ? -1 : 1; + return 0; }), (entry) => getTextWithoutRepresentativeEmojiAndPadding( @@ -124,10 +137,10 @@ export default function ConflictsManager({ ); return { - blocks: sortByConflictStateThenName(grouped.blocks), - models: sortByConflictStateThenName(grouped.models), + blocks: sortByUnresolvedThenName(grouped.blocks), + models: sortByUnresolvedThenName(grouped.models), }; - }, [conflicts, exportSchema]); + }, [conflicts, exportSchema, formValues, formErrors]); // Deterministic sorting keeps plugin ordering stable between renders. const pluginEntries = useMemo(() => { @@ -140,21 +153,32 @@ export default function ConflictsManager({ projectPlugin: conflicts.plugins[String(exportPlugin.id)] ?? undefined, })); - const conflictFirst = [...entries].sort((a, b) => { + const unresolvedFirst = [...entries].sort((a, b) => { + const aUnresolved = isPluginConflictUnresolved( + a.exportPlugin, + a.projectPlugin, + ); + const bUnresolved = isPluginConflictUnresolved( + b.exportPlugin, + b.projectPlugin, + ); + if (aUnresolved !== bUnresolved) { + return aUnresolved ? -1 : 1; + } const aHasConflict = Boolean(a.projectPlugin); const bHasConflict = Boolean(b.projectPlugin); - if (aHasConflict === bHasConflict) { - return 0; + if (aHasConflict !== bHasConflict) { + return aHasConflict ? -1 : 1; } - return aHasConflict ? -1 : 1; + return 0; }); - return sortEntriesByDisplayName(conflictFirst, (entry) => + return sortEntriesByDisplayName(unresolvedFirst, (entry) => getTextWithoutRepresentativeEmojiAndPadding( entry.exportPlugin.attributes.name, ), ); - }, [conflicts, exportSchema]); + }, [conflicts, exportSchema, formValues, formErrors]); // Returns true while an item-type conflict still needs user input. function isItemTypeConflictUnresolved( @@ -315,56 +339,81 @@ export default function ConflictsManager({
    )} - {visibleModels.length > 0 && ( -
    -
    - Models ({visibleModels.length}) -
    -
    - {visibleModels.map(({ exportItemType, projectItemType }) => ( - - ))} -
    -
    - )} - - {visibleBlocks.length > 0 && ( -
    -
    - Block models ({visibleBlocks.length}) -
    -
    - {visibleBlocks.map(({ exportItemType, projectItemType }) => ( - - ))} -
    -
    - )} - - {visiblePlugins.length > 0 && ( -
    -
    - Plugins ({visiblePlugins.length}) -
    -
    - {visiblePlugins.map(({ exportPlugin, projectPlugin }) => ( - - ))} -
    -
    - )} + {(() => { + type SectionKey = 'models' | 'blocks' | 'plugins'; + const baseOrder: SectionKey[] = ['models', 'blocks', 'plugins']; + const unresolvedByKey: Record = { + models: unresolvedModelConflicts, + blocks: unresolvedBlockConflicts, + plugins: unresolvedPluginConflicts, + }; + const sectionOrder = [...baseOrder].sort((a, b) => { + if (unresolvedByKey[a] !== unresolvedByKey[b]) { + return unresolvedByKey[a] ? -1 : 1; + } + return baseOrder.indexOf(a) - baseOrder.indexOf(b); + }); + + return sectionOrder.map((section) => { + if (section === 'models' && visibleModels.length > 0) { + return ( +
    +
    + Models ({visibleModels.length}) +
    +
    + {visibleModels.map(({ exportItemType, projectItemType }) => ( + + ))} +
    +
    + ); + } + + if (section === 'blocks' && visibleBlocks.length > 0) { + return ( +
    +
    + Block models ({visibleBlocks.length}) +
    +
    + {visibleBlocks.map(({ exportItemType, projectItemType }) => ( + + ))} +
    +
    + ); + } + + if (section === 'plugins' && visiblePlugins.length > 0) { + return ( +
    +
    + Plugins ({visiblePlugins.length}) +
    +
    + {visiblePlugins.map(({ exportPlugin, projectPlugin }) => ( + + ))} +
    +
    + ); + } + return null; + }); + })()}
    {/* Left slot intentionally empty for layout parity with Export flow */} From 510e692b26a315e52e1d4d69455cdae50a60498b Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Mon, 10 Nov 2025 15:29:56 +0100 Subject: [PATCH 34/36] Update version to 0.1.19, enhance root item type coverage logic, and clean up unused context in ItemTypeConflict component. --- import-export-schema/package.json | 4 +- import-export-schema/pnpm-lock.yaml | 3089 +++++++++++++++++ .../entrypoints/ExportPage/ExportSchema.ts | 95 + .../ConflictsManager/ItemTypeConflict.tsx | 9 +- 4 files changed, 3187 insertions(+), 10 deletions(-) create mode 100644 import-export-schema/pnpm-lock.yaml diff --git a/import-export-schema/package.json b/import-export-schema/package.json index d9838927..7bbea7df 100644 --- a/import-export-schema/package.json +++ b/import-export-schema/package.json @@ -3,7 +3,7 @@ "description": "Export one or multiple models/block models as JSON files for reimport into another DatoCMS project.", "homepage": "https://github.com/datocms/plugins", "private": false, - "version": "0.1.18", + "version": "0.1.19", "author": "Stefano Verna ", "type": "module", "keywords": [ @@ -59,5 +59,5 @@ "vite": "^5.4.1", "vite-plugin-svgr": "^4.3.0" }, - "packageManager": "pnpm@10.19.0+sha512.c9fc7236e92adf5c8af42fd5bf1612df99c2ceb62f27047032f4720b33f8eacdde311865e91c411f2774f618d82f320808ecb51718bfa82c060c4ba7c76a32b8" + "packageManager": "pnpm@10.21.0+sha512.da3337267e400fdd3d479a6c68079ac6db01d8ca4f67572083e722775a796788a7a9956613749e000fac20d424b594f7a791a5f4e2e13581c5ef947f26968a40" } diff --git a/import-export-schema/pnpm-lock.yaml b/import-export-schema/pnpm-lock.yaml new file mode 100644 index 00000000..3f78a30e --- /dev/null +++ b/import-export-schema/pnpm-lock.yaml @@ -0,0 +1,3089 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@datocms/cma-client': + specifier: ^3.4.5 + version: 3.4.5 + '@fortawesome/fontawesome-svg-core': + specifier: ^6.7.2 + version: 6.7.2 + '@fortawesome/free-solid-svg-icons': + specifier: ^6.7.2 + version: 6.7.2 + '@fortawesome/react-fontawesome': + specifier: ^0.2.2 + version: 0.2.6(@fortawesome/fontawesome-svg-core@6.7.2)(react@18.3.1) + '@types/d3-hierarchy': + specifier: ^3.1.7 + version: 3.1.7 + '@xyflow/react': + specifier: ^12.3.6 + version: 12.9.2(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: + specifier: ^2.5.1 + version: 2.5.1 + d3-hierarchy: + specifier: ^3.1.2 + version: 3.1.2 + datocms-plugin-sdk: + specifier: ^2.0.13 + version: 2.0.15 + datocms-react-ui: + specifier: ^2.0.13 + version: 2.0.15(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + emoji-regex: + specifier: ^10.4.0 + version: 10.6.0 + final-form: + specifier: ^4.20.10 + version: 4.20.10 + lodash-es: + specifier: ^4.17.21 + version: 4.17.21 + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + react-final-form: + specifier: ^6.5.9 + version: 6.5.9(final-form@4.20.10)(react@18.3.1) + devDependencies: + '@types/lodash-es': + specifier: ^4.17.12 + version: 4.17.12 + '@types/node': + specifier: ^22.13.1 + version: 22.19.0 + '@types/react': + specifier: ^18.3.3 + version: 18.3.26 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.26) + '@vitejs/plugin-react': + specifier: ^4.3.1 + version: 4.7.0(vite@5.4.21(@types/node@22.19.0)) + postcss-preset-env: + specifier: ^9.6.0 + version: 9.6.0(postcss@8.5.6) + typescript: + specifier: ^5.5.3 + version: 5.9.3 + vite: + specifier: ^5.4.1 + version: 5.4.21(@types/node@22.19.0) + vite-plugin-svgr: + specifier: ^4.3.0 + version: 4.5.0(rollup@4.53.2)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.0)) + +packages: + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.5': + resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.5': + resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@csstools/cascade-layer-name-parser@1.0.13': + resolution: {integrity: sha512-MX0yLTwtZzr82sQ0zOjqimpZbzjMaK/h2pmlrLK7DCzlmiZLYFpoO94WmN1akRVo6ll/TdpHb53vihHLUMyvng==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + '@csstools/css-parser-algorithms': ^2.7.1 + '@csstools/css-tokenizer': ^2.4.1 + + '@csstools/color-helpers@4.2.1': + resolution: {integrity: sha512-CEypeeykO9AN7JWkr1OEOQb0HRzZlPWGwV0Ya6DuVgFdDi6g3ma/cPZ5ZPZM4AWQikDpq/0llnGGlIL+j8afzw==} + engines: {node: ^14 || ^16 || >=18} + + '@csstools/css-calc@1.2.4': + resolution: {integrity: sha512-tfOuvUQeo7Hz+FcuOd3LfXVp+342pnWUJ7D2y8NUpu1Ww6xnTbHLpz018/y6rtbHifJ3iIEf9ttxXd8KG7nL0Q==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + '@csstools/css-parser-algorithms': ^2.7.1 + '@csstools/css-tokenizer': ^2.4.1 + + '@csstools/css-color-parser@2.0.5': + resolution: {integrity: sha512-lRZSmtl+DSjok3u9hTWpmkxFZnz7stkbZxzKc08aDUsdrWwhSgWo8yq9rq9DaFUtbAyAq2xnH92fj01S+pwIww==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + '@csstools/css-parser-algorithms': ^2.7.1 + '@csstools/css-tokenizer': ^2.4.1 + + '@csstools/css-parser-algorithms@2.7.1': + resolution: {integrity: sha512-2SJS42gxmACHgikc1WGesXLIT8d/q2l0UFM7TaEeIzdFCE/FPMtTiizcPGGJtlPo2xuQzY09OhrLTzRxqJqwGw==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + '@csstools/css-tokenizer': ^2.4.1 + + '@csstools/css-tokenizer@2.4.1': + resolution: {integrity: sha512-eQ9DIktFJBhGjioABJRtUucoWR2mwllurfnM8LuNGAqX3ViZXaUchqk+1s7jjtkFiT9ySdACsFEA3etErkALUg==} + engines: {node: ^14 || ^16 || >=18} + + '@csstools/media-query-list-parser@2.1.13': + resolution: {integrity: sha512-XaHr+16KRU9Gf8XLi3q8kDlI18d5vzKSKCY510Vrtc9iNR0NJzbY9hhTmwhzYZj/ZwGL4VmB3TA9hJW0Um2qFA==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + '@csstools/css-parser-algorithms': ^2.7.1 + '@csstools/css-tokenizer': ^2.4.1 + + '@csstools/postcss-cascade-layers@4.0.6': + resolution: {integrity: sha512-Xt00qGAQyqAODFiFEJNkTpSUz5VfYqnDLECdlA/Vv17nl/OIV5QfTRHGAXrBGG5YcJyHpJ+GF9gF/RZvOQz4oA==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-color-function@3.0.19': + resolution: {integrity: sha512-d1OHEXyYGe21G3q88LezWWx31ImEDdmINNDy0LyLNN9ChgN2bPxoubUPiHf9KmwypBMaHmNcMuA/WZOKdZk/Lg==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-color-mix-function@2.0.19': + resolution: {integrity: sha512-mLvQlMX+keRYr16AuvuV8WYKUwF+D0DiCqlBdvhQ0KYEtcQl9/is9Ssg7RcIys8x0jIn2h1zstS4izckdZj9wg==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-content-alt-text@1.0.0': + resolution: {integrity: sha512-SkHdj7EMM/57GVvSxSELpUg7zb5eAndBeuvGwFzYtU06/QXJ/h9fuK7wO5suteJzGhm3GDF/EWPCdWV2h1IGHQ==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-exponential-functions@1.0.9': + resolution: {integrity: sha512-x1Avr15mMeuX7Z5RJUl7DmjhUtg+Amn5DZRD0fQ2TlTFTcJS8U1oxXQ9e5mA62S2RJgUU6db20CRoJyDvae2EQ==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-font-format-keywords@3.0.2': + resolution: {integrity: sha512-E0xz2sjm4AMCkXLCFvI/lyl4XO6aN1NCSMMVEOngFDJ+k2rDwfr6NDjWljk1li42jiLNChVX+YFnmfGCigZKXw==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-gamut-mapping@1.0.11': + resolution: {integrity: sha512-KrHGsUPXRYxboXmJ9wiU/RzDM7y/5uIefLWKFSc36Pok7fxiPyvkSHO51kh+RLZS1W5hbqw9qaa6+tKpTSxa5g==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-gradients-interpolation-method@4.0.20': + resolution: {integrity: sha512-ZFl2JBHano6R20KB5ZrB8KdPM2pVK0u+/3cGQ2T8VubJq982I2LSOvQ4/VtxkAXjkPkk1rXt4AD1ni7UjTZ1Og==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-hwb-function@3.0.18': + resolution: {integrity: sha512-3ifnLltR5C7zrJ+g18caxkvSRnu9jBBXCYgnBznRjxm6gQJGnnCO9H6toHfywNdNr/qkiVf2dymERPQLDnjLRQ==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-ic-unit@3.0.7': + resolution: {integrity: sha512-YoaNHH2wNZD+c+rHV02l4xQuDpfR8MaL7hD45iJyr+USwvr0LOheeytJ6rq8FN6hXBmEeoJBeXXgGmM8fkhH4g==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-initial@1.0.1': + resolution: {integrity: sha512-wtb+IbUIrIf8CrN6MLQuFR7nlU5C7PwuebfeEXfjthUha1+XZj2RVi+5k/lukToA24sZkYAiSJfHM8uG/UZIdg==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-is-pseudo-class@4.0.8': + resolution: {integrity: sha512-0aj591yGlq5Qac+plaWCbn5cpjs5Sh0daovYUKJUOMjIp70prGH/XPLp7QjxtbFXz3CTvb0H9a35dpEuIuUi3Q==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-light-dark-function@1.0.8': + resolution: {integrity: sha512-x0UtpCyVnERsplUeoaY6nEtp1HxTf4lJjoK/ULEm40DraqFfUdUSt76yoOyX5rGY6eeOUOkurHyYlFHVKv/pew==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-logical-float-and-clear@2.0.1': + resolution: {integrity: sha512-SsrWUNaXKr+e/Uo4R/uIsqJYt3DaggIh/jyZdhy/q8fECoJSKsSMr7nObSLdvoULB69Zb6Bs+sefEIoMG/YfOA==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-logical-overflow@1.0.1': + resolution: {integrity: sha512-Kl4lAbMg0iyztEzDhZuQw8Sj9r2uqFDcU1IPl+AAt2nue8K/f1i7ElvKtXkjhIAmKiy5h2EY8Gt/Cqg0pYFDCw==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-logical-overscroll-behavior@1.0.1': + resolution: {integrity: sha512-+kHamNxAnX8ojPCtV8WPcUP3XcqMFBSDuBuvT6MHgq7oX4IQxLIXKx64t7g9LiuJzE7vd06Q9qUYR6bh4YnGpQ==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-logical-resize@2.0.1': + resolution: {integrity: sha512-W5Gtwz7oIuFcKa5SmBjQ2uxr8ZoL7M2bkoIf0T1WeNqljMkBrfw1DDA8/J83k57NQ1kcweJEjkJ04pUkmyee3A==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-logical-viewport-units@2.0.11': + resolution: {integrity: sha512-ElITMOGcjQtvouxjd90WmJRIw1J7KMP+M+O87HaVtlgOOlDt1uEPeTeii8qKGe2AiedEp0XOGIo9lidbiU2Ogg==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-media-minmax@1.1.8': + resolution: {integrity: sha512-KYQCal2i7XPNtHAUxCECdrC7tuxIWQCW+s8eMYs5r5PaAiVTeKwlrkRS096PFgojdNCmHeG0Cb7njtuNswNf+w==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-media-queries-aspect-ratio-number-values@2.0.11': + resolution: {integrity: sha512-YD6jrib20GRGQcnOu49VJjoAnQ/4249liuz7vTpy/JfgqQ1Dlc5eD4HPUMNLOw9CWey9E6Etxwf/xc/ZF8fECA==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-nested-calc@3.0.2': + resolution: {integrity: sha512-ySUmPyawiHSmBW/VI44+IObcKH0v88LqFe0d09Sb3w4B1qjkaROc6d5IA3ll9kjD46IIX/dbO5bwFN/swyoyZA==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-normalize-display-values@3.0.2': + resolution: {integrity: sha512-fCapyyT/dUdyPtrelQSIV+d5HqtTgnNP/BEG9IuhgXHt93Wc4CfC1bQ55GzKAjWrZbgakMQ7MLfCXEf3rlZJOw==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-oklab-function@3.0.19': + resolution: {integrity: sha512-e3JxXmxjU3jpU7TzZrsNqSX4OHByRC3XjItV3Ieo/JEQmLg5rdOL4lkv/1vp27gXemzfNt44F42k/pn0FpE21Q==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-progressive-custom-properties@3.3.0': + resolution: {integrity: sha512-W2oV01phnILaRGYPmGFlL2MT/OgYjQDrL9sFlbdikMFi6oQkFki9B86XqEWR7HCsTZFVq7dbzr/o71B75TKkGg==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-relative-color-syntax@2.0.19': + resolution: {integrity: sha512-MxUMSNvio1WwuS6WRLlQuv6nNPXwIWUFzBBAvL/tBdWfiKjiJnAa6eSSN5gtaacSqUkQ/Ce5Z1OzLRfeaWhADA==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-scope-pseudo-class@3.0.1': + resolution: {integrity: sha512-3ZFonK2gfgqg29gUJ2w7xVw2wFJ1eNWVDONjbzGkm73gJHVCYK5fnCqlLr+N+KbEfv2XbWAO0AaOJCFB6Fer6A==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-stepped-value-functions@3.0.10': + resolution: {integrity: sha512-MZwo0D0TYrQhT5FQzMqfy/nGZ28D1iFtpN7Su1ck5BPHS95+/Y5O9S4kEvo76f2YOsqwYcT8ZGehSI1TnzuX2g==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-text-decoration-shorthand@3.0.7': + resolution: {integrity: sha512-+cptcsM5r45jntU6VjotnkC9GteFR7BQBfZ5oW7inLCxj7AfLGAzMbZ60hKTP13AULVZBdxky0P8um0IBfLHVA==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-trigonometric-functions@3.0.10': + resolution: {integrity: sha512-G9G8moTc2wiad61nY5HfvxLiM/myX0aYK4s1x8MQlPH29WDPxHQM7ghGgvv2qf2xH+rrXhztOmjGHJj4jsEqXw==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-unset-value@3.0.1': + resolution: {integrity: sha512-dbDnZ2ja2U8mbPP0Hvmt2RMEGBiF1H7oY6HYSpjteXJGihYwgxgTr6KRbbJ/V6c+4wd51M+9980qG4gKVn5ttg==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@csstools/selector-resolve-nested@1.1.0': + resolution: {integrity: sha512-uWvSaeRcHyeNenKg8tp17EVDRkpflmdyvbE0DHo6D/GdBb6PDnCYYU6gRpXhtICMGMcahQmj2zGxwFM/WC8hCg==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss-selector-parser: ^6.0.13 + + '@csstools/selector-specificity@3.1.1': + resolution: {integrity: sha512-a7cxGcJ2wIlMFLlh8z2ONm+715QkPHiyJcxwQlKOz/03GPw1COpfhcmC9wm4xlZfp//jWHNNMwzjtqHXVWU9KA==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss-selector-parser: ^6.0.13 + + '@csstools/utilities@1.0.0': + resolution: {integrity: sha512-tAgvZQe/t2mlvpNosA4+CkMiZ2azISW5WPAcdSalZlEjQvUfghHxfQcrCiK/7/CrfAWVxyM88kGFYO82heIGDg==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + '@datocms/cma-client@3.4.5': + resolution: {integrity: sha512-ddwqN1c0gNf6D79GjxkcZZXKqGk4541GTZfrpXUnU5H0NQJoh1avkCqaecaI9CybJClYwKmoEWgcXZYWjednCQ==} + + '@datocms/rest-client-utils@3.4.2': + resolution: {integrity: sha512-VjAtxySGH2c1qlZkJUnaRkujDiGAtoc5BtN1V42lvz35hFi/s/fkVOL40Ybr+lkIYsNtFdCPFaE5sW0tABHqaA==} + + '@emotion/babel-plugin@11.13.5': + resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} + + '@emotion/cache@11.14.0': + resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==} + + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + + '@emotion/memoize@0.9.0': + resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} + + '@emotion/react@11.14.0': + resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==} + peerDependencies: + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/serialize@1.3.3': + resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==} + + '@emotion/sheet@1.4.0': + resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==} + + '@emotion/unitless@0.10.0': + resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0': + resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==} + peerDependencies: + react: '>=16.8.0' + + '@emotion/utils@1.4.2': + resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==} + + '@emotion/weak-memoize@0.4.0': + resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + + '@fortawesome/fontawesome-common-types@6.7.2': + resolution: {integrity: sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==} + engines: {node: '>=6'} + + '@fortawesome/fontawesome-svg-core@6.7.2': + resolution: {integrity: sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==} + engines: {node: '>=6'} + + '@fortawesome/free-solid-svg-icons@6.7.2': + resolution: {integrity: sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==} + engines: {node: '>=6'} + + '@fortawesome/react-fontawesome@0.2.6': + resolution: {integrity: sha512-mtBFIi1UsYQo7rYonYFkjgYKGoL8T+fEH6NGUpvuqtY3ytMsAoDaPo5rk25KuMtKDipY4bGYM/CkmCHA1N3FUg==} + peerDependencies: + '@fortawesome/fontawesome-svg-core': ~1 || ~6 || ~7 + react: ^16.3 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.53.2': + resolution: {integrity: sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.53.2': + resolution: {integrity: sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.53.2': + resolution: {integrity: sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.53.2': + resolution: {integrity: sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.53.2': + resolution: {integrity: sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.53.2': + resolution: {integrity: sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.53.2': + resolution: {integrity: sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.53.2': + resolution: {integrity: sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.53.2': + resolution: {integrity: sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.53.2': + resolution: {integrity: sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.53.2': + resolution: {integrity: sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.53.2': + resolution: {integrity: sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.53.2': + resolution: {integrity: sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.53.2': + resolution: {integrity: sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.53.2': + resolution: {integrity: sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.53.2': + resolution: {integrity: sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.53.2': + resolution: {integrity: sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.53.2': + resolution: {integrity: sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.53.2': + resolution: {integrity: sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.53.2': + resolution: {integrity: sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.53.2': + resolution: {integrity: sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.53.2': + resolution: {integrity: sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==} + cpu: [x64] + os: [win32] + + '@svgr/babel-plugin-add-jsx-attribute@8.0.0': + resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-remove-jsx-attribute@8.0.0': + resolution: {integrity: sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0': + resolution: {integrity: sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0': + resolution: {integrity: sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-svg-dynamic-title@8.0.0': + resolution: {integrity: sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-svg-em-dimensions@8.0.0': + resolution: {integrity: sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-transform-react-native-svg@8.1.0': + resolution: {integrity: sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-transform-svg-component@8.0.0': + resolution: {integrity: sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==} + engines: {node: '>=12'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-preset@8.1.0': + resolution: {integrity: sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/core@8.1.0': + resolution: {integrity: sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==} + engines: {node: '>=14'} + + '@svgr/hast-util-to-babel-ast@8.0.0': + resolution: {integrity: sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==} + engines: {node: '>=14'} + + '@svgr/plugin-jsx@8.1.0': + resolution: {integrity: sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==} + engines: {node: '>=14'} + peerDependencies: + '@svgr/core': '*' + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.20': + resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} + + '@types/node@22.19.0': + resolution: {integrity: sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==} + + '@types/parse-json@4.0.2': + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react-transition-group@4.4.12': + resolution: {integrity: sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==} + peerDependencies: + '@types/react': '*' + + '@types/react@17.0.89': + resolution: {integrity: sha512-I98SaDCar5lvEYl80ClRIUztH/hyWHR+I2f+5yTVp/MQ205HgYkA2b5mVdry/+nsEIrf8I65KA5V/PASx68MsQ==} + + '@types/react@18.3.26': + resolution: {integrity: sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==} + + '@types/scheduler@0.16.8': + resolution: {integrity: sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@xyflow/react@12.9.2': + resolution: {integrity: sha512-Xr+LFcysHCCoc5KRHaw+FwbqbWYxp9tWtk1mshNcqy25OAPuaKzXSdqIMNOA82TIXF/gFKo0Wgpa6PU7wUUVqw==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@xyflow/system@0.0.72': + resolution: {integrity: sha512-WBI5Aau0fXTXwxHPzceLNS6QdXggSWnGjDtj/gG669crApN8+SCmEtkBth1m7r6pStNo/5fI9McEi7Dk0ymCLA==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-flatten@3.0.0: + resolution: {integrity: sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==} + + async-scheduler@1.4.4: + resolution: {integrity: sha512-KVWlF6WKlUGJA8FOJYVVk7xDm3PxITWXp9hTeDsXMtUPvItQ2x6g2rIeNAa0TtAiiWvHJqhyA3wo+pj0rA7Ldg==} + + autoprefixer@10.4.21: + resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + babel-plugin-macros@3.1.0: + resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} + engines: {node: '>=10', npm: '>=6'} + + baseline-browser-mapping@2.8.25: + resolution: {integrity: sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA==} + hasBin: true + + browserslist@4.27.0: + resolution: {integrity: sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001754: + resolution: {integrity: sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==} + + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + + compute-scroll-into-view@1.0.20: + resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==} + + convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + + cosmiconfig@8.3.6: + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + css-blank-pseudo@6.0.2: + resolution: {integrity: sha512-J/6m+lsqpKPqWHOifAFtKFeGLOzw3jR92rxQcwRUfA/eTuZzKfKlxOmYDx2+tqOPQAueNvBiY8WhAeHu5qNmTg==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + css-has-pseudo@6.0.5: + resolution: {integrity: sha512-ZTv6RlvJJZKp32jPYnAJVhowDCrRrHUTAxsYSuUPBEDJjzws6neMnzkRblxtgmv1RgcV5dhH2gn7E3wA9Wt6lw==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + css-prefers-color-scheme@9.0.1: + resolution: {integrity: sha512-iFit06ochwCKPRiWagbTa1OAWCvWWVdEnIFd8BaRrgO8YrrNh4RAWUQTFcYX5tdFZgFl1DJ3iiULchZyEbnF4g==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + cssdb@8.4.2: + resolution: {integrity: sha512-PzjkRkRUS+IHDJohtxkIczlxPPZqRo0nXplsYXOMBRPjcVRjj1W4DfvRgshUYTVuUigU7ptVYkFJQ7abUB0nyg==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + datocms-plugin-sdk@2.0.15: + resolution: {integrity: sha512-MXuOo8i4Ksw6TIY6YYpb8czukp2/e9pKv/TXYXKIglRBtP2q62xfsP5F+JCDRSM5VOdR8hwGQU9QkDHjHJoR8A==} + + datocms-react-ui@2.0.15: + resolution: {integrity: sha512-NX4Ju2gJDQNXZC2fQQDFZ/ros9zAEU4NMFYoCX73wy32sbIFlBt1NtjawpBl/cB5akq8BLMW3WVj0dsskpLpsA==} + + datocms-structured-text-utils@2.1.12: + resolution: {integrity: sha512-tZtiPN/sEjl+4Z6igojPdap6Miy0Z6VO6Afor3vcyY/8PIwKVGbTsdd5trD+skWPCPRZVNzKpfYoAVziXWTL8Q==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + + dot-case@3.0.4: + resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + + electron-to-chromium@1.5.249: + resolution: {integrity: sha512-5vcfL3BBe++qZ5kuFhD/p8WOM1N9m3nwvJPULJx+4xf2usSlZFJ0qoNYO2fOX4hi3ocuDcmDobtA+5SFr4OmBg==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + final-form@4.20.10: + resolution: {integrity: sha512-TL48Pi1oNHeMOHrKv1bCJUrWZDcD3DIG6AGYVNOnyZPr7Bd/pStN0pL+lfzF5BNoj/FclaoiaLenk4XUIFVYng==} + engines: {node: '>=8'} + + find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + + fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lower-case@2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + no-case@3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + penpal@4.1.1: + resolution: {integrity: sha512-6d1f8khVLyBz3DnhLztbfjJ7+ANxdXRM2l6awpnCdEtbrmse4AGTsELOvGuNY0SU7xZw7heGbP6IikVvaVTOWw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss-attribute-case-insensitive@6.0.3: + resolution: {integrity: sha512-KHkmCILThWBRtg+Jn1owTnHPnFit4OkqS+eKiGEOPIGke54DCeYGJ6r0Fx/HjfE9M9kznApCLcU0DvnPchazMQ==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + postcss-clamp@4.1.0: + resolution: {integrity: sha512-ry4b1Llo/9zz+PKC+030KUnPITTJAHeOwjfAyyB60eT0AorGLdzp52s31OsPRHRf8NchkgFoG2y6fCfn1IV1Ow==} + engines: {node: '>=7.6.0'} + peerDependencies: + postcss: ^8.4.6 + + postcss-color-functional-notation@6.0.14: + resolution: {integrity: sha512-dNUX+UH4dAozZ8uMHZ3CtCNYw8fyFAmqqdcyxMr7PEdM9jLXV19YscoYO0F25KqZYhmtWKQ+4tKrIZQrwzwg7A==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + postcss-color-hex-alpha@9.0.4: + resolution: {integrity: sha512-XQZm4q4fNFqVCYMGPiBjcqDhuG7Ey2xrl99AnDJMyr5eDASsAGalndVgHZF8i97VFNy1GQeZc4q2ydagGmhelQ==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + postcss-color-rebeccapurple@9.0.3: + resolution: {integrity: sha512-ruBqzEFDYHrcVq3FnW3XHgwRqVMrtEPLBtD7K2YmsLKVc2jbkxzzNEctJKsPCpDZ+LeMHLKRDoSShVefGc+CkQ==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + postcss-custom-media@10.0.8: + resolution: {integrity: sha512-V1KgPcmvlGdxTel4/CyQtBJEFhMVpEmRGFrnVtgfGIHj5PJX9vO36eFBxKBeJn+aCDTed70cc+98Mz3J/uVdGQ==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + postcss-custom-properties@13.3.12: + resolution: {integrity: sha512-oPn/OVqONB2ZLNqN185LDyaVByELAA/u3l2CS2TS16x2j2XsmV4kd8U49+TMxmUsEU9d8fB/I10E6U7kB0L1BA==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + postcss-custom-selectors@7.1.12: + resolution: {integrity: sha512-ctIoprBMJwByYMGjXG0F7IT2iMF2hnamQ+aWZETyBM0aAlyaYdVZTeUkk8RB+9h9wP+NdN3f01lfvKl2ZSqC0g==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + postcss-dir-pseudo-class@8.0.1: + resolution: {integrity: sha512-uULohfWBBVoFiZXgsQA24JV6FdKIidQ+ZqxOouhWwdE+qJlALbkS5ScB43ZTjPK+xUZZhlaO/NjfCt5h4IKUfw==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + postcss-double-position-gradients@5.0.7: + resolution: {integrity: sha512-1xEhjV9u1s4l3iP5lRt1zvMjI/ya8492o9l/ivcxHhkO3nOz16moC4JpMxDUGrOs4R3hX+KWT7gKoV842cwRgg==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + postcss-focus-visible@9.0.1: + resolution: {integrity: sha512-N2VQ5uPz3Z9ZcqI5tmeholn4d+1H14fKXszpjogZIrFbhaq0zNAtq8sAnw6VLiqGbL8YBzsnu7K9bBkTqaRimQ==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + postcss-focus-within@8.0.1: + resolution: {integrity: sha512-NFU3xcY/xwNaapVb+1uJ4n23XImoC86JNwkY/uduytSl2s9Ekc2EpzmRR63+ExitnW3Mab3Fba/wRPCT5oDILA==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + postcss-font-variant@5.0.0: + resolution: {integrity: sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA==} + peerDependencies: + postcss: ^8.1.0 + + postcss-gap-properties@5.0.1: + resolution: {integrity: sha512-k2z9Cnngc24c0KF4MtMuDdToROYqGMMUQGcE6V0odwjHyOHtaDBlLeRBV70y9/vF7KIbShrTRZ70JjsI1BZyWw==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + postcss-image-set-function@6.0.3: + resolution: {integrity: sha512-i2bXrBYzfbRzFnm+pVuxVePSTCRiNmlfssGI4H0tJQvDue+yywXwUxe68VyzXs7cGtMaH6MCLY6IbCShrSroCw==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + postcss-lab-function@6.0.19: + resolution: {integrity: sha512-vwln/mgvFrotJuGV8GFhpAOu9iGf3pvTBr6dLPDmUcqVD5OsQpEFyQMAFTxSxWXGEzBj6ld4pZ/9GDfEpXvo0g==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + postcss-logical@7.0.1: + resolution: {integrity: sha512-8GwUQZE0ri0K0HJHkDv87XOLC8DE0msc+HoWLeKdtjDZEwpZ5xuK3QdV6FhmHSQW40LPkg43QzvATRAI3LsRkg==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + postcss-nesting@12.1.5: + resolution: {integrity: sha512-N1NgI1PDCiAGWPTYrwqm8wpjv0bgDmkYHH72pNsqTCv9CObxjxftdYu6AKtGN+pnJa7FQjMm3v4sp8QJbFsYdQ==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + postcss-opacity-percentage@2.0.0: + resolution: {integrity: sha512-lyDrCOtntq5Y1JZpBFzIWm2wG9kbEdujpNt4NLannF+J9c8CgFIzPa80YQfdza+Y+yFfzbYj/rfoOsYsooUWTQ==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.2 + + postcss-overflow-shorthand@5.0.1: + resolution: {integrity: sha512-XzjBYKLd1t6vHsaokMV9URBt2EwC9a7nDhpQpjoPk2HRTSQfokPfyAS/Q7AOrzUu6q+vp/GnrDBGuj/FCaRqrQ==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + postcss-page-break@3.0.4: + resolution: {integrity: sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ==} + peerDependencies: + postcss: ^8 + + postcss-place@9.0.1: + resolution: {integrity: sha512-JfL+paQOgRQRMoYFc2f73pGuG/Aw3tt4vYMR6UA3cWVMxivviPTnMFnFTczUJOA4K2Zga6xgQVE+PcLs64WC8Q==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + postcss-preset-env@9.6.0: + resolution: {integrity: sha512-Lxfk4RYjUdwPCYkc321QMdgtdCP34AeI94z+/8kVmqnTIlD4bMRQeGcMZgwz8BxHrzQiFXYIR5d7k/9JMs2MEA==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + postcss-pseudo-class-any-link@9.0.2: + resolution: {integrity: sha512-HFSsxIqQ9nA27ahyfH37cRWGk3SYyQLpk0LiWw/UGMV4VKT5YG2ONee4Pz/oFesnK0dn2AjcyequDbIjKJgB0g==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + postcss-replace-overflow-wrap@4.0.0: + resolution: {integrity: sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw==} + peerDependencies: + postcss: ^8.0.3 + + postcss-selector-not@7.0.2: + resolution: {integrity: sha512-/SSxf/90Obye49VZIfc0ls4H0P6i6V1iHv0pzZH8SdgvZOPFkF37ef1r5cyWcMflJSFJ5bfuoluTnFnBBFiuSA==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.4 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-final-form@6.5.9: + resolution: {integrity: sha512-x3XYvozolECp3nIjly+4QqxdjSSWfcnpGEL5K8OBT6xmGrq5kBqbA6+/tOqoom9NwqIPPbxPNsOViFlbKgowbA==} + peerDependencies: + final-form: ^4.20.4 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + + react-intersection-observer@8.34.0: + resolution: {integrity: sha512-TYKh52Zc0Uptp5/b4N91XydfSGKubEhgZRtcg1rhTKABXijc4Sdr1uTp5lJ8TN27jwUsdXxjHXtHa0kPj704sw==} + peerDependencies: + react: ^15.0.0 || ^16.0.0 || ^17.0.0|| ^18.0.0 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-select@5.10.2: + resolution: {integrity: sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + rollup@4.53.2: + resolution: {integrity: sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + scroll-into-view-if-needed@2.2.31: + resolution: {integrity: sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + snake-case@3.0.4: + resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + + stylis@4.2.0: + resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svg-parser@2.0.4: + resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + update-browserslist-db@1.1.4: + resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-isomorphic-layout-effect@1.2.1: + resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + + vite-plugin-svgr@4.5.0: + resolution: {integrity: sha512-W+uoSpmVkSmNOGPSsDCWVW/DDAyv+9fap9AZXBvWiQqrboJ08j2vh0tFxTD/LjwqwAd3yYSVJgm54S/1GhbdnA==} + peerDependencies: + vite: '>=2.6.0' + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + +snapshots: + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.5': {} + + '@babel/core@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.5 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.27.0 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/runtime@7.28.4': {} + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@csstools/cascade-layer-name-parser@1.0.13(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1)': + dependencies: + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 + + '@csstools/color-helpers@4.2.1': {} + + '@csstools/css-calc@1.2.4(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1)': + dependencies: + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 + + '@csstools/css-color-parser@2.0.5(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1)': + dependencies: + '@csstools/color-helpers': 4.2.1 + '@csstools/css-calc': 1.2.4(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1) + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 + + '@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1)': + dependencies: + '@csstools/css-tokenizer': 2.4.1 + + '@csstools/css-tokenizer@2.4.1': {} + + '@csstools/media-query-list-parser@2.1.13(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1)': + dependencies: + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 + + '@csstools/postcss-cascade-layers@4.0.6(postcss@8.5.6)': + dependencies: + '@csstools/selector-specificity': 3.1.1(postcss-selector-parser@6.1.2) + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + '@csstools/postcss-color-function@3.0.19(postcss@8.5.6)': + dependencies: + '@csstools/css-color-parser': 2.0.5(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1) + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 + '@csstools/postcss-progressive-custom-properties': 3.3.0(postcss@8.5.6) + '@csstools/utilities': 1.0.0(postcss@8.5.6) + postcss: 8.5.6 + + '@csstools/postcss-color-mix-function@2.0.19(postcss@8.5.6)': + dependencies: + '@csstools/css-color-parser': 2.0.5(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1) + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 + '@csstools/postcss-progressive-custom-properties': 3.3.0(postcss@8.5.6) + '@csstools/utilities': 1.0.0(postcss@8.5.6) + postcss: 8.5.6 + + '@csstools/postcss-content-alt-text@1.0.0(postcss@8.5.6)': + dependencies: + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 + '@csstools/postcss-progressive-custom-properties': 3.3.0(postcss@8.5.6) + '@csstools/utilities': 1.0.0(postcss@8.5.6) + postcss: 8.5.6 + + '@csstools/postcss-exponential-functions@1.0.9(postcss@8.5.6)': + dependencies: + '@csstools/css-calc': 1.2.4(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1) + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 + postcss: 8.5.6 + + '@csstools/postcss-font-format-keywords@3.0.2(postcss@8.5.6)': + dependencies: + '@csstools/utilities': 1.0.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-gamut-mapping@1.0.11(postcss@8.5.6)': + dependencies: + '@csstools/css-color-parser': 2.0.5(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1) + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 + postcss: 8.5.6 + + '@csstools/postcss-gradients-interpolation-method@4.0.20(postcss@8.5.6)': + dependencies: + '@csstools/css-color-parser': 2.0.5(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1) + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 + '@csstools/postcss-progressive-custom-properties': 3.3.0(postcss@8.5.6) + '@csstools/utilities': 1.0.0(postcss@8.5.6) + postcss: 8.5.6 + + '@csstools/postcss-hwb-function@3.0.18(postcss@8.5.6)': + dependencies: + '@csstools/css-color-parser': 2.0.5(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1) + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 + '@csstools/postcss-progressive-custom-properties': 3.3.0(postcss@8.5.6) + '@csstools/utilities': 1.0.0(postcss@8.5.6) + postcss: 8.5.6 + + '@csstools/postcss-ic-unit@3.0.7(postcss@8.5.6)': + dependencies: + '@csstools/postcss-progressive-custom-properties': 3.3.0(postcss@8.5.6) + '@csstools/utilities': 1.0.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-initial@1.0.1(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + + '@csstools/postcss-is-pseudo-class@4.0.8(postcss@8.5.6)': + dependencies: + '@csstools/selector-specificity': 3.1.1(postcss-selector-parser@6.1.2) + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + '@csstools/postcss-light-dark-function@1.0.8(postcss@8.5.6)': + dependencies: + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 + '@csstools/postcss-progressive-custom-properties': 3.3.0(postcss@8.5.6) + '@csstools/utilities': 1.0.0(postcss@8.5.6) + postcss: 8.5.6 + + '@csstools/postcss-logical-float-and-clear@2.0.1(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + + '@csstools/postcss-logical-overflow@1.0.1(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + + '@csstools/postcss-logical-overscroll-behavior@1.0.1(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + + '@csstools/postcss-logical-resize@2.0.1(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-logical-viewport-units@2.0.11(postcss@8.5.6)': + dependencies: + '@csstools/css-tokenizer': 2.4.1 + '@csstools/utilities': 1.0.0(postcss@8.5.6) + postcss: 8.5.6 + + '@csstools/postcss-media-minmax@1.1.8(postcss@8.5.6)': + dependencies: + '@csstools/css-calc': 1.2.4(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1) + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 + '@csstools/media-query-list-parser': 2.1.13(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1) + postcss: 8.5.6 + + '@csstools/postcss-media-queries-aspect-ratio-number-values@2.0.11(postcss@8.5.6)': + dependencies: + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 + '@csstools/media-query-list-parser': 2.1.13(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1) + postcss: 8.5.6 + + '@csstools/postcss-nested-calc@3.0.2(postcss@8.5.6)': + dependencies: + '@csstools/utilities': 1.0.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-normalize-display-values@3.0.2(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-oklab-function@3.0.19(postcss@8.5.6)': + dependencies: + '@csstools/css-color-parser': 2.0.5(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1) + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 + '@csstools/postcss-progressive-custom-properties': 3.3.0(postcss@8.5.6) + '@csstools/utilities': 1.0.0(postcss@8.5.6) + postcss: 8.5.6 + + '@csstools/postcss-progressive-custom-properties@3.3.0(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-relative-color-syntax@2.0.19(postcss@8.5.6)': + dependencies: + '@csstools/css-color-parser': 2.0.5(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1) + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 + '@csstools/postcss-progressive-custom-properties': 3.3.0(postcss@8.5.6) + '@csstools/utilities': 1.0.0(postcss@8.5.6) + postcss: 8.5.6 + + '@csstools/postcss-scope-pseudo-class@3.0.1(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + '@csstools/postcss-stepped-value-functions@3.0.10(postcss@8.5.6)': + dependencies: + '@csstools/css-calc': 1.2.4(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1) + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 + postcss: 8.5.6 + + '@csstools/postcss-text-decoration-shorthand@3.0.7(postcss@8.5.6)': + dependencies: + '@csstools/color-helpers': 4.2.1 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-trigonometric-functions@3.0.10(postcss@8.5.6)': + dependencies: + '@csstools/css-calc': 1.2.4(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1) + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 + postcss: 8.5.6 + + '@csstools/postcss-unset-value@3.0.1(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + + '@csstools/selector-resolve-nested@1.1.0(postcss-selector-parser@6.1.2)': + dependencies: + postcss-selector-parser: 6.1.2 + + '@csstools/selector-specificity@3.1.1(postcss-selector-parser@6.1.2)': + dependencies: + postcss-selector-parser: 6.1.2 + + '@csstools/utilities@1.0.0(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + + '@datocms/cma-client@3.4.5': + dependencies: + '@datocms/rest-client-utils': 3.4.2 + uuid: 9.0.1 + + '@datocms/rest-client-utils@3.4.2': + dependencies: + async-scheduler: 1.4.4 + + '@emotion/babel-plugin@11.13.5': + dependencies: + '@babel/helper-module-imports': 7.27.1 + '@babel/runtime': 7.28.4 + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/serialize': 1.3.3 + babel-plugin-macros: 3.1.0 + convert-source-map: 1.9.0 + escape-string-regexp: 4.0.0 + find-root: 1.1.0 + source-map: 0.5.7 + stylis: 4.2.0 + transitivePeerDependencies: + - supports-color + + '@emotion/cache@11.14.0': + dependencies: + '@emotion/memoize': 0.9.0 + '@emotion/sheet': 1.4.0 + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + stylis: 4.2.0 + + '@emotion/hash@0.9.2': {} + + '@emotion/memoize@0.9.0': {} + + '@emotion/react@11.14.0(@types/react@18.3.26)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@emotion/babel-plugin': 11.13.5 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@18.3.1) + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + hoist-non-react-statics: 3.3.2 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.26 + transitivePeerDependencies: + - supports-color + + '@emotion/serialize@1.3.3': + dependencies: + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/unitless': 0.10.0 + '@emotion/utils': 1.4.2 + csstype: 3.1.3 + + '@emotion/sheet@1.4.0': {} + + '@emotion/unitless@0.10.0': {} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@18.3.1)': + dependencies: + react: 18.3.1 + + '@emotion/utils@1.4.2': {} + + '@emotion/weak-memoize@0.4.0': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@floating-ui/core@1.7.3': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.4': + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/utils@0.2.10': {} + + '@fortawesome/fontawesome-common-types@6.7.2': {} + + '@fortawesome/fontawesome-svg-core@6.7.2': + dependencies: + '@fortawesome/fontawesome-common-types': 6.7.2 + + '@fortawesome/free-solid-svg-icons@6.7.2': + dependencies: + '@fortawesome/fontawesome-common-types': 6.7.2 + + '@fortawesome/react-fontawesome@0.2.6(@fortawesome/fontawesome-svg-core@6.7.2)(react@18.3.1)': + dependencies: + '@fortawesome/fontawesome-svg-core': 6.7.2 + prop-types: 15.8.1 + react: 18.3.1 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/pluginutils@5.3.0(rollup@4.53.2)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.53.2 + + '@rollup/rollup-android-arm-eabi@4.53.2': + optional: true + + '@rollup/rollup-android-arm64@4.53.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.53.2': + optional: true + + '@rollup/rollup-darwin-x64@4.53.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.53.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.53.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.53.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.53.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.53.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.53.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.53.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.53.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.53.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.53.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.53.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.53.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.53.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.53.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.53.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.53.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.53.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.53.2': + optional: true + + '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + + '@svgr/babel-plugin-remove-jsx-attribute@8.0.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + + '@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + + '@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + + '@svgr/babel-plugin-svg-dynamic-title@8.0.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + + '@svgr/babel-plugin-svg-em-dimensions@8.0.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + + '@svgr/babel-plugin-transform-react-native-svg@8.1.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + + '@svgr/babel-plugin-transform-svg-component@8.0.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + + '@svgr/babel-preset@8.1.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@svgr/babel-plugin-add-jsx-attribute': 8.0.0(@babel/core@7.28.5) + '@svgr/babel-plugin-remove-jsx-attribute': 8.0.0(@babel/core@7.28.5) + '@svgr/babel-plugin-remove-jsx-empty-expression': 8.0.0(@babel/core@7.28.5) + '@svgr/babel-plugin-replace-jsx-attribute-value': 8.0.0(@babel/core@7.28.5) + '@svgr/babel-plugin-svg-dynamic-title': 8.0.0(@babel/core@7.28.5) + '@svgr/babel-plugin-svg-em-dimensions': 8.0.0(@babel/core@7.28.5) + '@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.28.5) + '@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.28.5) + + '@svgr/core@8.1.0(typescript@5.9.3)': + dependencies: + '@babel/core': 7.28.5 + '@svgr/babel-preset': 8.1.0(@babel/core@7.28.5) + camelcase: 6.3.0 + cosmiconfig: 8.3.6(typescript@5.9.3) + snake-case: 3.0.4 + transitivePeerDependencies: + - supports-color + - typescript + + '@svgr/hast-util-to-babel-ast@8.0.0': + dependencies: + '@babel/types': 7.28.5 + entities: 4.5.0 + + '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))': + dependencies: + '@babel/core': 7.28.5 + '@svgr/babel-preset': 8.1.0(@babel/core@7.28.5) + '@svgr/core': 8.1.0(typescript@5.9.3) + '@svgr/hast-util-to-babel-ast': 8.0.0 + svg-parser: 2.0.4 + transitivePeerDependencies: + - supports-color + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/d3-color@3.1.3': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/estree@1.0.8': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.20 + + '@types/lodash@4.17.20': {} + + '@types/node@22.19.0': + dependencies: + undici-types: 6.21.0 + + '@types/parse-json@4.0.2': {} + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.26)': + dependencies: + '@types/react': 18.3.26 + + '@types/react-transition-group@4.4.12(@types/react@18.3.26)': + dependencies: + '@types/react': 18.3.26 + + '@types/react@17.0.89': + dependencies: + '@types/prop-types': 15.7.15 + '@types/scheduler': 0.16.8 + csstype: 3.1.3 + + '@types/react@18.3.26': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.1.3 + + '@types/scheduler@0.16.8': {} + + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.19.0))': + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 5.4.21(@types/node@22.19.0) + transitivePeerDependencies: + - supports-color + + '@xyflow/react@12.9.2(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@xyflow/system': 0.0.72 + classcat: 5.0.5 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + zustand: 4.5.7(@types/react@18.3.26)(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - immer + + '@xyflow/system@0.0.72': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + + argparse@2.0.1: {} + + array-flatten@3.0.0: {} + + async-scheduler@1.4.4: {} + + autoprefixer@10.4.21(postcss@8.5.6): + dependencies: + browserslist: 4.27.0 + caniuse-lite: 1.0.30001754 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + babel-plugin-macros@3.1.0: + dependencies: + '@babel/runtime': 7.28.4 + cosmiconfig: 7.1.0 + resolve: 1.22.11 + + baseline-browser-mapping@2.8.25: {} + + browserslist@4.27.0: + dependencies: + baseline-browser-mapping: 2.8.25 + caniuse-lite: 1.0.30001754 + electron-to-chromium: 1.5.249 + node-releases: 2.0.27 + update-browserslist-db: 1.1.4(browserslist@4.27.0) + + callsites@3.1.0: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001754: {} + + classcat@5.0.5: {} + + classnames@2.5.1: {} + + compute-scroll-into-view@1.0.20: {} + + convert-source-map@1.9.0: {} + + convert-source-map@2.0.0: {} + + cosmiconfig@7.1.0: + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.1 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.2 + + cosmiconfig@8.3.6(typescript@5.9.3): + dependencies: + import-fresh: 3.3.1 + js-yaml: 4.1.0 + parse-json: 5.2.0 + path-type: 4.0.0 + optionalDependencies: + typescript: 5.9.3 + + css-blank-pseudo@6.0.2(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + css-has-pseudo@6.0.5(postcss@8.5.6): + dependencies: + '@csstools/selector-specificity': 3.1.1(postcss-selector-parser@6.1.2) + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + postcss-value-parser: 4.2.0 + + css-prefers-color-scheme@9.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + cssdb@8.4.2: {} + + cssesc@3.0.0: {} + + csstype@3.1.3: {} + + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + datocms-plugin-sdk@2.0.15: + dependencies: + '@datocms/cma-client': 3.4.5 + '@types/react': 17.0.89 + datocms-structured-text-utils: 2.1.12 + penpal: 4.1.1 + + datocms-react-ui@2.0.15(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + classnames: 2.5.1 + datocms-plugin-sdk: 2.0.15 + react-intersection-observer: 8.34.0(react@18.3.1) + react-select: 5.10.2(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + scroll-into-view-if-needed: 2.2.31 + transitivePeerDependencies: + - '@types/react' + - react + - react-dom + - supports-color + + datocms-structured-text-utils@2.1.12: + dependencies: + array-flatten: 3.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.28.4 + csstype: 3.1.3 + + dot-case@3.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + + electron-to-chromium@1.5.249: {} + + emoji-regex@10.6.0: {} + + entities@4.5.0: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + estree-walker@2.0.2: {} + + final-form@4.20.10: + dependencies: + '@babel/runtime': 7.28.4 + + find-root@1.1.0: {} + + fraction.js@4.3.7: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + is-arrayish@0.2.1: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + js-tokens@4.0.0: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json5@2.2.3: {} + + lines-and-columns@1.2.4: {} + + lodash-es@4.17.21: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lower-case@2.0.2: + dependencies: + tslib: 2.8.1 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + memoize-one@6.0.0: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + no-case@3.0.4: + dependencies: + lower-case: 2.0.2 + tslib: 2.8.1 + + node-releases@2.0.27: {} + + normalize-range@0.1.2: {} + + object-assign@4.1.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-parse@1.0.7: {} + + path-type@4.0.0: {} + + penpal@4.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + postcss-attribute-case-insensitive@6.0.3(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-clamp@4.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-color-functional-notation@6.0.14(postcss@8.5.6): + dependencies: + '@csstools/css-color-parser': 2.0.5(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1) + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 + '@csstools/postcss-progressive-custom-properties': 3.3.0(postcss@8.5.6) + '@csstools/utilities': 1.0.0(postcss@8.5.6) + postcss: 8.5.6 + + postcss-color-hex-alpha@9.0.4(postcss@8.5.6): + dependencies: + '@csstools/utilities': 1.0.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-color-rebeccapurple@9.0.3(postcss@8.5.6): + dependencies: + '@csstools/utilities': 1.0.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-custom-media@10.0.8(postcss@8.5.6): + dependencies: + '@csstools/cascade-layer-name-parser': 1.0.13(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1) + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 + '@csstools/media-query-list-parser': 2.1.13(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1) + postcss: 8.5.6 + + postcss-custom-properties@13.3.12(postcss@8.5.6): + dependencies: + '@csstools/cascade-layer-name-parser': 1.0.13(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1) + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 + '@csstools/utilities': 1.0.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-custom-selectors@7.1.12(postcss@8.5.6): + dependencies: + '@csstools/cascade-layer-name-parser': 1.0.13(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1) + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-dir-pseudo-class@8.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-double-position-gradients@5.0.7(postcss@8.5.6): + dependencies: + '@csstools/postcss-progressive-custom-properties': 3.3.0(postcss@8.5.6) + '@csstools/utilities': 1.0.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-focus-visible@9.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-focus-within@8.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-font-variant@5.0.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-gap-properties@5.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-image-set-function@6.0.3(postcss@8.5.6): + dependencies: + '@csstools/utilities': 1.0.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-lab-function@6.0.19(postcss@8.5.6): + dependencies: + '@csstools/css-color-parser': 2.0.5(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1) + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 + '@csstools/postcss-progressive-custom-properties': 3.3.0(postcss@8.5.6) + '@csstools/utilities': 1.0.0(postcss@8.5.6) + postcss: 8.5.6 + + postcss-logical@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-nesting@12.1.5(postcss@8.5.6): + dependencies: + '@csstools/selector-resolve-nested': 1.1.0(postcss-selector-parser@6.1.2) + '@csstools/selector-specificity': 3.1.1(postcss-selector-parser@6.1.2) + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-opacity-percentage@2.0.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-overflow-shorthand@5.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-page-break@3.0.4(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-place@9.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-preset-env@9.6.0(postcss@8.5.6): + dependencies: + '@csstools/postcss-cascade-layers': 4.0.6(postcss@8.5.6) + '@csstools/postcss-color-function': 3.0.19(postcss@8.5.6) + '@csstools/postcss-color-mix-function': 2.0.19(postcss@8.5.6) + '@csstools/postcss-content-alt-text': 1.0.0(postcss@8.5.6) + '@csstools/postcss-exponential-functions': 1.0.9(postcss@8.5.6) + '@csstools/postcss-font-format-keywords': 3.0.2(postcss@8.5.6) + '@csstools/postcss-gamut-mapping': 1.0.11(postcss@8.5.6) + '@csstools/postcss-gradients-interpolation-method': 4.0.20(postcss@8.5.6) + '@csstools/postcss-hwb-function': 3.0.18(postcss@8.5.6) + '@csstools/postcss-ic-unit': 3.0.7(postcss@8.5.6) + '@csstools/postcss-initial': 1.0.1(postcss@8.5.6) + '@csstools/postcss-is-pseudo-class': 4.0.8(postcss@8.5.6) + '@csstools/postcss-light-dark-function': 1.0.8(postcss@8.5.6) + '@csstools/postcss-logical-float-and-clear': 2.0.1(postcss@8.5.6) + '@csstools/postcss-logical-overflow': 1.0.1(postcss@8.5.6) + '@csstools/postcss-logical-overscroll-behavior': 1.0.1(postcss@8.5.6) + '@csstools/postcss-logical-resize': 2.0.1(postcss@8.5.6) + '@csstools/postcss-logical-viewport-units': 2.0.11(postcss@8.5.6) + '@csstools/postcss-media-minmax': 1.1.8(postcss@8.5.6) + '@csstools/postcss-media-queries-aspect-ratio-number-values': 2.0.11(postcss@8.5.6) + '@csstools/postcss-nested-calc': 3.0.2(postcss@8.5.6) + '@csstools/postcss-normalize-display-values': 3.0.2(postcss@8.5.6) + '@csstools/postcss-oklab-function': 3.0.19(postcss@8.5.6) + '@csstools/postcss-progressive-custom-properties': 3.3.0(postcss@8.5.6) + '@csstools/postcss-relative-color-syntax': 2.0.19(postcss@8.5.6) + '@csstools/postcss-scope-pseudo-class': 3.0.1(postcss@8.5.6) + '@csstools/postcss-stepped-value-functions': 3.0.10(postcss@8.5.6) + '@csstools/postcss-text-decoration-shorthand': 3.0.7(postcss@8.5.6) + '@csstools/postcss-trigonometric-functions': 3.0.10(postcss@8.5.6) + '@csstools/postcss-unset-value': 3.0.1(postcss@8.5.6) + autoprefixer: 10.4.21(postcss@8.5.6) + browserslist: 4.27.0 + css-blank-pseudo: 6.0.2(postcss@8.5.6) + css-has-pseudo: 6.0.5(postcss@8.5.6) + css-prefers-color-scheme: 9.0.1(postcss@8.5.6) + cssdb: 8.4.2 + postcss: 8.5.6 + postcss-attribute-case-insensitive: 6.0.3(postcss@8.5.6) + postcss-clamp: 4.1.0(postcss@8.5.6) + postcss-color-functional-notation: 6.0.14(postcss@8.5.6) + postcss-color-hex-alpha: 9.0.4(postcss@8.5.6) + postcss-color-rebeccapurple: 9.0.3(postcss@8.5.6) + postcss-custom-media: 10.0.8(postcss@8.5.6) + postcss-custom-properties: 13.3.12(postcss@8.5.6) + postcss-custom-selectors: 7.1.12(postcss@8.5.6) + postcss-dir-pseudo-class: 8.0.1(postcss@8.5.6) + postcss-double-position-gradients: 5.0.7(postcss@8.5.6) + postcss-focus-visible: 9.0.1(postcss@8.5.6) + postcss-focus-within: 8.0.1(postcss@8.5.6) + postcss-font-variant: 5.0.0(postcss@8.5.6) + postcss-gap-properties: 5.0.1(postcss@8.5.6) + postcss-image-set-function: 6.0.3(postcss@8.5.6) + postcss-lab-function: 6.0.19(postcss@8.5.6) + postcss-logical: 7.0.1(postcss@8.5.6) + postcss-nesting: 12.1.5(postcss@8.5.6) + postcss-opacity-percentage: 2.0.0(postcss@8.5.6) + postcss-overflow-shorthand: 5.0.1(postcss@8.5.6) + postcss-page-break: 3.0.4(postcss@8.5.6) + postcss-place: 9.0.1(postcss@8.5.6) + postcss-pseudo-class-any-link: 9.0.2(postcss@8.5.6) + postcss-replace-overflow-wrap: 4.0.0(postcss@8.5.6) + postcss-selector-not: 7.0.2(postcss@8.5.6) + + postcss-pseudo-class-any-link@9.0.2(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-replace-overflow-wrap@4.0.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-selector-not@7.0.2(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-final-form@6.5.9(final-form@4.20.10)(react@18.3.1): + dependencies: + '@babel/runtime': 7.28.4 + final-form: 4.20.10 + react: 18.3.1 + + react-intersection-observer@8.34.0(react@18.3.1): + dependencies: + react: 18.3.1 + + react-is@16.13.1: {} + + react-refresh@0.17.0: {} + + react-select@5.10.2(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.28.4 + '@emotion/cache': 11.14.0 + '@emotion/react': 11.14.0(@types/react@18.3.26)(react@18.3.1) + '@floating-ui/dom': 1.7.4 + '@types/react-transition-group': 4.4.12(@types/react@18.3.26) + memoize-one: 6.0.0 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + use-isomorphic-layout-effect: 1.2.1(@types/react@18.3.26)(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - supports-color + + react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.28.4 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + resolve-from@4.0.0: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + rollup@4.53.2: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.53.2 + '@rollup/rollup-android-arm64': 4.53.2 + '@rollup/rollup-darwin-arm64': 4.53.2 + '@rollup/rollup-darwin-x64': 4.53.2 + '@rollup/rollup-freebsd-arm64': 4.53.2 + '@rollup/rollup-freebsd-x64': 4.53.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.53.2 + '@rollup/rollup-linux-arm-musleabihf': 4.53.2 + '@rollup/rollup-linux-arm64-gnu': 4.53.2 + '@rollup/rollup-linux-arm64-musl': 4.53.2 + '@rollup/rollup-linux-loong64-gnu': 4.53.2 + '@rollup/rollup-linux-ppc64-gnu': 4.53.2 + '@rollup/rollup-linux-riscv64-gnu': 4.53.2 + '@rollup/rollup-linux-riscv64-musl': 4.53.2 + '@rollup/rollup-linux-s390x-gnu': 4.53.2 + '@rollup/rollup-linux-x64-gnu': 4.53.2 + '@rollup/rollup-linux-x64-musl': 4.53.2 + '@rollup/rollup-openharmony-arm64': 4.53.2 + '@rollup/rollup-win32-arm64-msvc': 4.53.2 + '@rollup/rollup-win32-ia32-msvc': 4.53.2 + '@rollup/rollup-win32-x64-gnu': 4.53.2 + '@rollup/rollup-win32-x64-msvc': 4.53.2 + fsevents: 2.3.3 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + scroll-into-view-if-needed@2.2.31: + dependencies: + compute-scroll-into-view: 1.0.20 + + semver@6.3.1: {} + + snake-case@3.0.4: + dependencies: + dot-case: 3.0.4 + tslib: 2.8.1 + + source-map-js@1.2.1: {} + + source-map@0.5.7: {} + + stylis@4.2.0: {} + + supports-preserve-symlinks-flag@1.0.0: {} + + svg-parser@2.0.4: {} + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + update-browserslist-db@1.1.4(browserslist@4.27.0): + dependencies: + browserslist: 4.27.0 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-isomorphic-layout-effect@1.2.1(@types/react@18.3.26)(react@18.3.1): + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.26 + + use-sync-external-store@1.6.0(react@18.3.1): + dependencies: + react: 18.3.1 + + util-deprecate@1.0.2: {} + + uuid@9.0.1: {} + + vite-plugin-svgr@4.5.0(rollup@4.53.2)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.0)): + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.53.2) + '@svgr/core': 8.1.0(typescript@5.9.3) + '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) + vite: 5.4.21(@types/node@22.19.0) + transitivePeerDependencies: + - rollup + - supports-color + - typescript + + vite@5.4.21(@types/node@22.19.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.53.2 + optionalDependencies: + '@types/node': 22.19.0 + fsevents: 2.3.3 + + yallist@3.1.1: {} + + yaml@1.10.2: {} + + zustand@4.5.7(@types/react@18.3.26)(react@18.3.1): + dependencies: + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.26 + react: 18.3.1 diff --git a/import-export-schema/src/entrypoints/ExportPage/ExportSchema.ts b/import-export-schema/src/entrypoints/ExportPage/ExportSchema.ts index ee5b7230..ed9e4c90 100644 --- a/import-export-schema/src/entrypoints/ExportPage/ExportSchema.ts +++ b/import-export-schema/src/entrypoints/ExportPage/ExportSchema.ts @@ -98,6 +98,11 @@ export class ExportSchema { this.rootItemTypes = [any]; } } + + this.rootItemTypes = this.ensureRootCoverage(this.rootItemTypes); + if (!this.rootItemType && this.rootItemTypes.length > 0) { + this.rootItemType = this.rootItemTypes[0]; + } } get fields() { @@ -155,4 +160,94 @@ export class ExportSchema { .map((fsid) => this.fieldsetsById.get(String(fsid))) .filter(isDefined); } + + private ensureRootCoverage( + initialRoots: SchemaTypes.ItemType[], + ): SchemaTypes.ItemType[] { + if (this.itemTypes.length === 0) { + return []; + } + + const adjacency = new Map>(); + + const ensureNeighbors = (id: string) => { + const key = String(id); + if (!adjacency.has(key)) { + adjacency.set(key, new Set()); + } + return adjacency.get(key)!; + }; + + for (const itemType of this.itemTypes) { + const currentId = String(itemType.id); + const neighbors = ensureNeighbors(currentId); + const fields = this.getItemTypeFields(itemType); + + for (const field of fields) { + for (const rawLinkedId of findLinkedItemTypeIds(field)) { + const linkedId = String(rawLinkedId); + if (!this.itemTypesById.has(linkedId) || linkedId === currentId) { + continue; + } + neighbors.add(linkedId); + ensureNeighbors(linkedId).add(currentId); + } + } + } + + const visited = new Set(); + const result: SchemaTypes.ItemType[] = []; + const resultIds = new Set(); + + const visitFrom = (startId: string) => { + const queue: string[] = [startId]; + while (queue.length > 0) { + const current = String(queue.shift()!); + if (visited.has(current)) { + continue; + } + visited.add(current); + const neighbors = adjacency.get(current); + if (!neighbors) { + continue; + } + for (const neighbor of neighbors) { + if (!visited.has(neighbor)) { + queue.push(neighbor); + } + } + } + }; + + const addSeed = (itemType: SchemaTypes.ItemType | undefined) => { + if (!itemType) return; + const id = String(itemType.id); + if (!resultIds.has(id)) { + resultIds.add(id); + result.push(itemType); + } + visitFrom(id); + }; + + for (const root of initialRoots) { + addSeed(root); + } + + if (result.length === 0) { + const fallbackRoot = + this.rootItemType ?? + this.itemTypesById.values().next().value ?? + undefined; + addSeed(fallbackRoot); + } + + for (const itemType of this.itemTypes) { + const id = String(itemType.id); + if (!visited.has(id)) { + addSeed(itemType); + } + } + + return result; + } } diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx index 97e9c0cb..5a890ee9 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/ItemTypeConflict.tsx @@ -1,9 +1,8 @@ import type { SchemaTypes } from '@datocms/cma-client'; import { SelectField, TextField } from 'datocms-react-ui'; -import { useContext, useId } from 'react'; +import { useId } from 'react'; import { Field } from 'react-final-form'; import Collapsible from '@/components/SchemaOverview/Collapsible'; -import { GraphEntitiesContext } from '../GraphEntitiesContext'; import { useResolutionStatusForItemType } from '../ResolutionsForm'; type Option = { label: string; value: string }; @@ -26,8 +25,6 @@ export function ItemTypeConflict({ exportItemType, projectItemType }: Props) { const apiKeyId = useId(); const fieldPrefix = `itemType-${exportItemType.id}`; const resolution = useResolutionStatusForItemType(exportItemType.id); - const { hasItemTypeNode } = useContext(GraphEntitiesContext); - const nodeExists = hasItemTypeNode(exportItemType.id); const exportType = exportItemType.attributes.modular_block ? 'block' @@ -76,10 +73,6 @@ export function ItemTypeConflict({ exportItemType, projectItemType }: Props) { } } - if (!nodeExists) { - return null; - } - const isInvalid = hasConflict && Boolean(resolution?.invalid); return ( From 4d8d83639ca669cdb2af065b24cfec920dc79a44 Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Mon, 10 Nov 2025 15:33:41 +0100 Subject: [PATCH 35/36] Add d3-timer dependency and update TypeScript build info versions --- import-export-schema/package.json | 1 + import-export-schema/pnpm-lock.yaml | 3 +++ import-export-schema/tsconfig.app.tsbuildinfo | 2 +- import-export-schema/tsconfig.node.tsbuildinfo | 2 +- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/import-export-schema/package.json b/import-export-schema/package.json index 7bbea7df..3de968cb 100644 --- a/import-export-schema/package.json +++ b/import-export-schema/package.json @@ -39,6 +39,7 @@ "@xyflow/react": "^12.3.6", "classnames": "^2.5.1", "d3-hierarchy": "^3.1.2", + "d3-timer": "^3.0.1", "datocms-plugin-sdk": "^2.0.13", "datocms-react-ui": "^2.0.13", "emoji-regex": "^10.4.0", diff --git a/import-export-schema/pnpm-lock.yaml b/import-export-schema/pnpm-lock.yaml index 3f78a30e..345b3959 100644 --- a/import-export-schema/pnpm-lock.yaml +++ b/import-export-schema/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: d3-hierarchy: specifier: ^3.1.2 version: 3.1.2 + d3-timer: + specifier: ^3.0.1 + version: 3.0.1 datocms-plugin-sdk: specifier: ^2.0.13 version: 2.0.15 diff --git a/import-export-schema/tsconfig.app.tsbuildinfo b/import-export-schema/tsconfig.app.tsbuildinfo index 0798bc02..d3476ed9 100644 --- a/import-export-schema/tsconfig.app.tsbuildinfo +++ b/import-export-schema/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/blankslate.tsx","./src/components/exportlandingpanel.tsx","./src/components/exportselectionpanel.tsx","./src/components/field.tsx","./src/components/fieldedgerenderer.tsx","./src/components/graphcanvas.tsx","./src/components/itemtypenoderenderer.tsx","./src/components/pluginnoderenderer.tsx","./src/components/progressoverlay.tsx","./src/components/progressstallnotice.tsx","./src/components/taskoverlaystack.tsx","./src/components/taskprogressoverlay.tsx","./src/components/bezier.ts","./src/components/schemaoverview/collapsible.tsx","./src/components/schemaoverview/selectedentitycontext.tsx","./src/entrypoints/config/index.tsx","./src/entrypoints/exporthome/index.tsx","./src/entrypoints/exportpage/dependencyactionspanel.tsx","./src/entrypoints/exportpage/entitiestoexportcontext.ts","./src/entrypoints/exportpage/exportitemtypenoderenderer.tsx","./src/entrypoints/exportpage/exportpluginnoderenderer.tsx","./src/entrypoints/exportpage/exportschema.ts","./src/entrypoints/exportpage/exportschemaoverview.tsx","./src/entrypoints/exportpage/inner.tsx","./src/entrypoints/exportpage/buildexportdoc.ts","./src/entrypoints/exportpage/buildgraphfromschema.ts","./src/entrypoints/exportpage/index.tsx","./src/entrypoints/exportpage/useexportgraph.ts","./src/entrypoints/importpage/filedropzone.tsx","./src/entrypoints/importpage/graphentitiescontext.tsx","./src/entrypoints/importpage/importitemtypenoderenderer.tsx","./src/entrypoints/importpage/importpluginnoderenderer.tsx","./src/entrypoints/importpage/importworkflow.tsx","./src/entrypoints/importpage/inner.tsx","./src/entrypoints/importpage/resolutionsform.tsx","./src/entrypoints/importpage/buildgraphfromexportdoc.ts","./src/entrypoints/importpage/buildimportdoc.ts","./src/entrypoints/importpage/importschema.ts","./src/entrypoints/importpage/index.tsx","./src/entrypoints/importpage/userecipeloader.ts","./src/entrypoints/importpage/conflictsmanager/conflictscontext.ts","./src/entrypoints/importpage/conflictsmanager/itemtypeconflict.tsx","./src/entrypoints/importpage/conflictsmanager/pluginconflict.tsx","./src/entrypoints/importpage/conflictsmanager/buildconflicts.ts","./src/entrypoints/importpage/conflictsmanager/index.tsx","./src/shared/constants/graph.ts","./src/shared/hooks/usecmaclient.ts","./src/shared/hooks/useconflictsbuilder.ts","./src/shared/hooks/useexportallhandler.ts","./src/shared/hooks/useexportselection.ts","./src/shared/hooks/useprojectschema.ts","./src/shared/hooks/useschemaexporttask.ts","./src/shared/tasks/uselongtask.ts","./src/types/lodash-es.d.ts","./src/utils/projectschema.ts","./src/utils/createcmaclient.ts","./src/utils/debug.ts","./src/utils/downloadjson.ts","./src/utils/emojiagnosticsorter.ts","./src/utils/icons.tsx","./src/utils/isdefined.ts","./src/utils/render.tsx","./src/utils/types.ts","./src/utils/datocms/appearance.ts","./src/utils/datocms/fieldtypeinfo.ts","./src/utils/datocms/schema.ts","./src/utils/graph/analysis.ts","./src/utils/graph/buildgraph.ts","./src/utils/graph/buildhierarchynodes.ts","./src/utils/graph/dependencies.ts","./src/utils/graph/edges.ts","./src/utils/graph/nodes.ts","./src/utils/graph/rebuildgraphwithpositionsfromhierarchy.ts","./src/utils/graph/sort.ts","./src/utils/graph/types.ts","./src/utils/schema/exportschemasource.ts","./src/utils/schema/ischemasource.ts","./src/utils/schema/projectschemasource.ts"],"version":"5.7.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/blankslate.tsx","./src/components/exportlandingpanel.tsx","./src/components/exportselectionpanel.tsx","./src/components/field.tsx","./src/components/fieldedgerenderer.tsx","./src/components/graphcanvas.tsx","./src/components/itemtypenoderenderer.tsx","./src/components/pluginnoderenderer.tsx","./src/components/progressoverlay.tsx","./src/components/progressstallnotice.tsx","./src/components/taskoverlaystack.tsx","./src/components/taskprogressoverlay.tsx","./src/components/bezier.ts","./src/components/schemaoverview/collapsible.tsx","./src/components/schemaoverview/selectedentitycontext.tsx","./src/entrypoints/config/index.tsx","./src/entrypoints/exporthome/index.tsx","./src/entrypoints/exportpage/dependencyactionspanel.tsx","./src/entrypoints/exportpage/entitiestoexportcontext.ts","./src/entrypoints/exportpage/exportitemtypenoderenderer.tsx","./src/entrypoints/exportpage/exportpluginnoderenderer.tsx","./src/entrypoints/exportpage/exportschema.ts","./src/entrypoints/exportpage/exportschemaoverview.tsx","./src/entrypoints/exportpage/inner.tsx","./src/entrypoints/exportpage/buildexportdoc.ts","./src/entrypoints/exportpage/buildgraphfromschema.ts","./src/entrypoints/exportpage/index.tsx","./src/entrypoints/exportpage/useexportgraph.ts","./src/entrypoints/importpage/filedropzone.tsx","./src/entrypoints/importpage/graphentitiescontext.tsx","./src/entrypoints/importpage/importitemtypenoderenderer.tsx","./src/entrypoints/importpage/importpluginnoderenderer.tsx","./src/entrypoints/importpage/importworkflow.tsx","./src/entrypoints/importpage/inner.tsx","./src/entrypoints/importpage/resolutionsform.tsx","./src/entrypoints/importpage/buildgraphfromexportdoc.ts","./src/entrypoints/importpage/buildimportdoc.ts","./src/entrypoints/importpage/importschema.ts","./src/entrypoints/importpage/index.tsx","./src/entrypoints/importpage/userecipeloader.ts","./src/entrypoints/importpage/conflictsmanager/conflictscontext.ts","./src/entrypoints/importpage/conflictsmanager/itemtypeconflict.tsx","./src/entrypoints/importpage/conflictsmanager/pluginconflict.tsx","./src/entrypoints/importpage/conflictsmanager/buildconflicts.ts","./src/entrypoints/importpage/conflictsmanager/index.tsx","./src/shared/constants/graph.ts","./src/shared/hooks/usecmaclient.ts","./src/shared/hooks/useconflictsbuilder.ts","./src/shared/hooks/useexportallhandler.ts","./src/shared/hooks/useexportselection.ts","./src/shared/hooks/useprojectschema.ts","./src/shared/hooks/useschemaexporttask.ts","./src/shared/tasks/uselongtask.ts","./src/types/lodash-es.d.ts","./src/utils/projectschema.ts","./src/utils/createcmaclient.ts","./src/utils/debug.ts","./src/utils/downloadjson.ts","./src/utils/emojiagnosticsorter.ts","./src/utils/icons.tsx","./src/utils/isdefined.ts","./src/utils/render.tsx","./src/utils/types.ts","./src/utils/datocms/appearance.ts","./src/utils/datocms/fieldtypeinfo.ts","./src/utils/datocms/schema.ts","./src/utils/graph/analysis.ts","./src/utils/graph/buildgraph.ts","./src/utils/graph/buildhierarchynodes.ts","./src/utils/graph/dependencies.ts","./src/utils/graph/edges.ts","./src/utils/graph/nodes.ts","./src/utils/graph/rebuildgraphwithpositionsfromhierarchy.ts","./src/utils/graph/sort.ts","./src/utils/graph/types.ts","./src/utils/schema/exportschemasource.ts","./src/utils/schema/ischemasource.ts","./src/utils/schema/projectschemasource.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/import-export-schema/tsconfig.node.tsbuildinfo b/import-export-schema/tsconfig.node.tsbuildinfo index 0440098c..62c7bf92 100644 --- a/import-export-schema/tsconfig.node.tsbuildinfo +++ b/import-export-schema/tsconfig.node.tsbuildinfo @@ -1 +1 @@ -{"root":["./vite.config.ts"],"version":"5.7.3"} \ No newline at end of file +{"root":["./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file From 3c84f364c5d161c7ed3a31e167df66b7e971f730 Mon Sep 17 00:00:00 2001 From: Marcelo Finamor Vieira Date: Mon, 10 Nov 2025 18:40:32 +0100 Subject: [PATCH 36/36] Update version to 0.1.20 and improve validation logic in ResolutionsForm and ConflictsManager components --- import-export-schema/package.json | 2 +- .../ImportPage/ConflictsManager/index.tsx | 9 ++- .../ImportPage/ResolutionsForm.tsx | 60 +++++++++++++++---- 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/import-export-schema/package.json b/import-export-schema/package.json index 3de968cb..12cae243 100644 --- a/import-export-schema/package.json +++ b/import-export-schema/package.json @@ -3,7 +3,7 @@ "description": "Export one or multiple models/block models as JSON files for reimport into another DatoCMS project.", "homepage": "https://github.com/datocms/plugins", "private": false, - "version": "0.1.19", + "version": "0.1.20", "author": "Stefano Verna ", "type": "module", "keywords": [ diff --git a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx index b49c4db5..ab9260ba 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ConflictsManager/index.tsx @@ -296,10 +296,15 @@ export default function ConflictsManager({ unresolvedBlockConflicts || unresolvedPluginConflicts; + // When there are no conflicts at all, do not block the CTA on form + // validation state — there is nothing to validate and the button should + // be immediately clickable. Only gate on form validity/validation when + // actual conflicts exist. const proceedDisabled = - submitting || validating || !valid || hasUnresolvedConflicts; + submitting || + (hasConflicts && (validating || !valid || hasUnresolvedConflicts)); const proceedTooltip = - hasUnresolvedConflicts || !valid + hasConflicts && (hasUnresolvedConflicts || !valid) ? 'Select how to resolve the conflicts before proceeding' : undefined; diff --git a/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx b/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx index 403c0a80..72709ce7 100644 --- a/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx +++ b/import-export-schema/src/entrypoints/ImportPage/ResolutionsForm.tsx @@ -141,49 +141,87 @@ export default function ResolutionsForm({ schema, children, onSubmit }: Props) { return ( initialValues={initialValues} - validate={async (values) => { + validate={(values) => { const errors: Record = {}; if (!conflicts) { return {}; } - const projectItemTypes = await schema.getAllItemTypes(); - const itemTypesByName = keyBy(projectItemTypes, 'attributes.name'); - const itemTypesByApiKey = keyBy(projectItemTypes, 'attributes.api_key'); + const pluginIds = Object.keys(conflicts.plugins); + const itemTypeIds = Object.keys(conflicts.itemTypes); - for (const pluginId of Object.keys(conflicts.plugins)) { + // No conflicts at all → nothing to validate; return synchronously. + if (pluginIds.length === 0 && itemTypeIds.length === 0) { + return {}; + } + + // Synchronous required checks for strategies across plugins/item types. + for (const pluginId of pluginIds) { const fieldPrefix = `plugin-${pluginId}`; if (!get(values, [fieldPrefix, 'strategy'])) { set(errors, [fieldPrefix, 'strategy'], 'Required!'); } } - for (const itemTypeId of Object.keys(conflicts.itemTypes)) { + let hasRename = false; + for (const itemTypeId of itemTypeIds) { const fieldPrefix = `itemType-${itemTypeId}`; const strategy = get(values, [fieldPrefix, 'strategy']); if (!strategy) { set(errors, [fieldPrefix, 'strategy'], 'Required!'); } if (strategy === 'rename') { + hasRename = true; const name = get(values, [fieldPrefix, 'name']); if (!name) { set(errors, [fieldPrefix, 'name'], 'Required!'); - } else if (name in itemTypesByName) { - set(errors, [fieldPrefix, 'name'], 'Already used in project!'); } const apiKey = get(values, [fieldPrefix, 'apiKey']); if (!apiKey) { set(errors, [fieldPrefix, 'apiKey'], 'Required!'); } else if (!isValidApiKey(apiKey)) { set(errors, [fieldPrefix, 'apiKey'], 'Invalid format'); - } else if (apiKey in itemTypesByApiKey) { - set(errors, [fieldPrefix, 'apiKey'], 'Already used in project!'); } } } - return errors; + // If there are no rename validations to check against the project + // (or there were only required/format errors), return synchronously to + // avoid toggling Final Form's `validating` flag. + if (!hasRename) { + return errors; + } + + // Only now perform the async lookup needed to check for collisions + // against existing project item types. + return (async () => { + const projectItemTypes = await schema.getAllItemTypes(); + const itemTypesByName = keyBy(projectItemTypes, 'attributes.name'); + const itemTypesByApiKey = keyBy( + projectItemTypes, + 'attributes.api_key', + ); + + for (const itemTypeId of itemTypeIds) { + const fieldPrefix = `itemType-${itemTypeId}`; + const strategy = get(values, [fieldPrefix, 'strategy']); + if (strategy !== 'rename') continue; + + const name = get(values, [fieldPrefix, 'name']); + if (name && name in itemTypesByName) { + set(errors, [fieldPrefix, 'name'], 'Already used in project!'); + } + const apiKey = get(values, [fieldPrefix, 'apiKey']); + if (apiKey) { + if (apiKey in itemTypesByApiKey) { + set(errors, [fieldPrefix, 'apiKey'], 'Already used in project!'); + } + } + } + + return errors; + })(); }} onSubmit={handleSubmit} >