diff --git a/NOTICE b/NOTICE index d0376f0ab76..ef3b91bca04 100644 --- a/NOTICE +++ b/NOTICE @@ -9,7 +9,7 @@ The following 3rd-party software packages may be used by or distributed with goo Date generated: 2026-8-26 -Revision ID: 801e4736307fbb28cf5d9d0a6566fc45d596c003 +Revision ID: 1f91513cf1dd868d5b0de66dfc1ea4e579f8a2d5 ================================================================================ ================================================================================ diff --git a/common/changes/@gooddata/sdk-ui-all/GDP-3344-report-builder-objects_2026-08-25-00-00.json b/common/changes/@gooddata/sdk-ui-all/GDP-3344-report-builder-objects_2026-08-25-00-00.json new file mode 100644 index 00000000000..a97aa5944ad --- /dev/null +++ b/common/changes/@gooddata/sdk-ui-all/GDP-3344-report-builder-objects_2026-08-25-00-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "comment": "sdk-model, sdk-backend-spi: Add reportPageLayout, reportTemplate and report model objects with built-in page layouts and the workspace reports SPI", + "type": "patch", + "packageName": "@gooddata/sdk-ui-all" + } + ], + "packageName": "@gooddata/sdk-ui-all" +} diff --git a/common/changes/@gooddata/sdk-ui-all/ine-cq-2853-catalog-computed-attributes_2026-08-21-14-00-00.json b/common/changes/@gooddata/sdk-ui-all/ine-cq-2853-catalog-computed-attributes_2026-08-21-14-00-00.json new file mode 100644 index 00000000000..292656beae4 --- /dev/null +++ b/common/changes/@gooddata/sdk-ui-all/ine-cq-2853-catalog-computed-attributes_2026-08-21-14-00-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@gooddata/sdk-ui-all", + "comment": "sdk-ui-catalog: computed attributes can be created, edited and deleted from the Analytics Catalog via the YAML editor, behind the enableComputedAttributes feature flag.", + "type": "none" + } + ], + "packageName": "@gooddata/sdk-ui-all" +} diff --git a/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2542-pin-types-stringify-object_2026-08-26-07-43.json b/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2542-pin-types-stringify-object_2026-08-26-07-43.json new file mode 100644 index 00000000000..db0d1dddc7b --- /dev/null +++ b/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2542-pin-types-stringify-object_2026-08-26-07-43.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@gooddata/sdk-ui-all", + "comment": "sdk-model: Pin the @types/stringify-object dependency to the exact version 3.3.1 instead of the ^3.2.0 range.", + "type": "none" + } + ], + "packageName": "@gooddata/sdk-ui-all" +} diff --git a/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2544-pin-types-tinycolor2_2026-08-26-07-43.json b/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2544-pin-types-tinycolor2_2026-08-26-07-43.json new file mode 100644 index 00000000000..403611fdb99 --- /dev/null +++ b/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2544-pin-types-tinycolor2_2026-08-26-07-43.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@gooddata/sdk-ui-all", + "comment": "sdk-ui-kit: Pin the @types/tinycolor2 dependency to the exact version 1.4.6 instead of the ^1.4.2 range.", + "type": "none" + } + ], + "packageName": "@gooddata/sdk-ui-all" +} diff --git a/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2603-pin-full-icu_2026-08-26-07-42.json b/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2603-pin-full-icu_2026-08-26-07-42.json new file mode 100644 index 00000000000..e902b6758cd --- /dev/null +++ b/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2603-pin-full-icu_2026-08-26-07-42.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@gooddata/sdk-ui-all", + "comment": "sdk-ui, sdk-ui-charts, sdk-ui-filters, sdk-ui-pivot, sdk-ui-ext: Pin the full-icu dependency to the exact version 1.6.0 instead of the ^1.3.0 range.", + "type": "none" + } + ], + "packageName": "@gooddata/sdk-ui-all" +} diff --git a/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2617-pin-json-schema-to-typescript_2026-08-26-07-35.json b/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2617-pin-json-schema-to-typescript_2026-08-26-07-35.json new file mode 100644 index 00000000000..552cb2cce5b --- /dev/null +++ b/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2617-pin-json-schema-to-typescript_2026-08-26-07-35.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@gooddata/sdk-ui-all", + "comment": "sdk-code-schemas: Pin the json-schema-to-typescript dependency to the exact version 13.1.2 instead of the ^13.0.1 range.", + "type": "none" + } + ], + "packageName": "@gooddata/sdk-ui-all" +} diff --git a/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2626-pin-kefir_2026-08-26-07-35.json b/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2626-pin-kefir_2026-08-26-07-35.json new file mode 100644 index 00000000000..2e54a9029da --- /dev/null +++ b/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2626-pin-kefir_2026-08-26-07-35.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@gooddata/sdk-ui-all", + "comment": "sdk-ui-kit: Pin the kefir dependency to the exact version 3.8.8 instead of the ^3.8.8 range.", + "type": "none" + } + ], + "packageName": "@gooddata/sdk-ui-all" +} diff --git a/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2633-pin-mkdirp_2026-08-26-07-38.json b/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2633-pin-mkdirp_2026-08-26-07-38.json new file mode 100644 index 00000000000..4af1cc3077d --- /dev/null +++ b/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2633-pin-mkdirp_2026-08-26-07-38.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@gooddata/sdk-ui-all", + "comment": "api-client-tiger, i18n-toolkit: Pin the mkdirp dependency to the exact version 1.0.4 instead of the ^1.0.4 range.", + "type": "none" + } + ], + "packageName": "@gooddata/sdk-ui-all" +} diff --git a/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2697-pin-svgo_2026-08-26-07-34.json b/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2697-pin-svgo_2026-08-26-07-34.json new file mode 100644 index 00000000000..ee72cb74a3d --- /dev/null +++ b/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2697-pin-svgo_2026-08-26-07-34.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@gooddata/sdk-ui-all", + "comment": "sdk-ui-gen-ai, sdk-ui-dashboard, sdk-ui-kit, sdk-ui-ext: Pin the svgo dependency to the exact version 2.8.3 instead of the ^2.8.0 range.", + "type": "none" + } + ], + "packageName": "@gooddata/sdk-ui-all" +} diff --git a/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2705-pin-tsd_2026-08-26-07-35.json b/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2705-pin-tsd_2026-08-26-07-35.json new file mode 100644 index 00000000000..755a542123c --- /dev/null +++ b/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2705-pin-tsd_2026-08-26-07-35.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@gooddata/sdk-ui-all", + "comment": "sdk-ui: Pin the tsd dependency to the exact version 0.32.0 instead of the ^0.32.0 range.", + "type": "none" + } + ], + "packageName": "@gooddata/sdk-ui-all" +} diff --git a/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2710-pin-validate-npm-package-name_2026-08-26-07-30.json b/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2710-pin-validate-npm-package-name_2026-08-26-07-30.json new file mode 100644 index 00000000000..5364a21f4b5 --- /dev/null +++ b/common/changes/@gooddata/sdk-ui-all/mnaj-claude-ci-worker-result-stl-2710-pin-validate-npm-package-name_2026-08-26-07-30.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@gooddata/sdk-ui-all", + "comment": "plugin-toolkit, app-toolkit: Pin the validate-npm-package-name dependency to the exact version 5.0.1 instead of the ^5.0.0 range.", + "type": "none" + } + ], + "packageName": "@gooddata/sdk-ui-all" +} diff --git a/common/changes/@gooddata/sdk-ui-all/sha_master_1_2026-08-26-12-30-00.json b/common/changes/@gooddata/sdk-ui-all/sha_master_1_2026-08-26-12-30-00.json new file mode 100644 index 00000000000..bf8b83cd424 --- /dev/null +++ b/common/changes/@gooddata/sdk-ui-all/sha_master_1_2026-08-26-12-30-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@gooddata/sdk-ui-all", + "comment": "sdk-ui-gen-ai: Introduce slot-based customization for Gen AI assistant.", + "type": "none" + } + ], + "packageName": "@gooddata/sdk-ui-all" +} diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 5cd4dd37526..7199880bc88 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -175,7 +175,7 @@ importers: ../../examples/sdk-interactive-examples: devDependencies: validate-npm-package-name: - specifier: ^5.0.0 + specifier: 5.0.1 version: 5.0.1 ../../examples/sdk-interactive-examples/examples-template: @@ -299,7 +299,7 @@ importers: specifier: 0.15.0 version: 0.15.0 process: - specifier: ^0.11.10 + specifier: 0.11.10 version: 0.11.10 rolldown: specifier: 1.0.3 @@ -435,7 +435,7 @@ importers: specifier: 0.15.0 version: 0.15.0 process: - specifier: ^0.11.10 + specifier: 0.11.10 version: 0.11.10 rolldown: specifier: 1.0.3 @@ -571,7 +571,7 @@ importers: specifier: 0.15.0 version: 0.15.0 process: - specifier: ^0.11.10 + specifier: 0.11.10 version: 0.11.10 rolldown: specifier: 1.0.3 @@ -707,7 +707,7 @@ importers: specifier: 0.15.0 version: 0.15.0 process: - specifier: ^0.11.10 + specifier: 0.11.10 version: 0.11.10 rolldown: specifier: 1.0.3 @@ -843,7 +843,7 @@ importers: specifier: 0.15.0 version: 0.15.0 process: - specifier: ^0.11.10 + specifier: 0.11.10 version: 0.11.10 rolldown: specifier: 1.0.3 @@ -979,7 +979,7 @@ importers: specifier: 0.15.0 version: 0.15.0 process: - specifier: ^0.11.10 + specifier: 0.11.10 version: 0.11.10 rolldown: specifier: 1.0.3 @@ -1115,7 +1115,7 @@ importers: specifier: 0.15.0 version: 0.15.0 process: - specifier: ^0.11.10 + specifier: 0.11.10 version: 0.11.10 rolldown: specifier: 1.0.3 @@ -1251,7 +1251,7 @@ importers: specifier: 0.15.0 version: 0.15.0 process: - specifier: ^0.11.10 + specifier: 0.11.10 version: 0.11.10 rolldown: specifier: 1.0.3 @@ -1396,7 +1396,7 @@ importers: specifier: 0.15.0 version: 0.15.0 process: - specifier: ^0.11.10 + specifier: 0.11.10 version: 0.11.10 rolldown: specifier: 1.0.3 @@ -1532,7 +1532,7 @@ importers: specifier: 0.15.0 version: 0.15.0 process: - specifier: ^0.11.10 + specifier: 0.11.10 version: 0.11.10 rolldown: specifier: 1.0.3 @@ -1668,7 +1668,7 @@ importers: specifier: 0.15.0 version: 0.15.0 process: - specifier: ^0.11.10 + specifier: 0.11.10 version: 0.11.10 rolldown: specifier: 1.0.3 @@ -1804,7 +1804,7 @@ importers: specifier: 0.15.0 version: 0.15.0 process: - specifier: ^0.11.10 + specifier: 0.11.10 version: 0.11.10 rolldown: specifier: 1.0.3 @@ -1940,7 +1940,7 @@ importers: specifier: 0.15.0 version: 0.15.0 process: - specifier: ^0.11.10 + specifier: 0.11.10 version: 0.11.10 rolldown: specifier: 1.0.3 @@ -2076,7 +2076,7 @@ importers: specifier: 0.15.0 version: 0.15.0 process: - specifier: ^0.11.10 + specifier: 0.11.10 version: 0.11.10 rolldown: specifier: 1.0.3 @@ -2116,13 +2116,13 @@ importers: specifier: workspace:* version: link:../../tools/oxlint-config '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 version: 7.58.12(@types/node@24.12.0) '@openapitools/openapi-generator-cli': - specifier: ^2.6.0 + specifier: 2.40.1 version: 2.40.1 '@types/lodash-es': specifier: ^4.17.12 @@ -2170,10 +2170,10 @@ importers: specifier: 3.0.6 version: 3.0.6(eslint@9.39.5(jiti@2.6.1)) fix-esm-import-path: - specifier: ^1.4.0 + specifier: 1.10.3 version: 1.10.3 mkdirp: - specifier: ^1.0.4 + specifier: 1.0.4 version: 1.0.4 npm-run-all: specifier: ^4.1.5 @@ -2240,7 +2240,7 @@ importers: specifier: workspace:* version: link:../../tools/reference-workspace '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -2249,7 +2249,7 @@ importers: specifier: ^4.17.12 version: 4.17.12 '@types/spark-md5': - specifier: ^3.0.1 + specifier: 3.0.5 version: 3.0.5 '@typescript-eslint/eslint-plugin': specifier: 8.58.0 @@ -2343,7 +2343,7 @@ importers: specifier: workspace:* version: link:../../tools/reference-workspace '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -2428,7 +2428,7 @@ importers: specifier: workspace:* version: link:../../tools/oxlint-config '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -2567,7 +2567,7 @@ importers: specifier: workspace:* version: link:../../tools/reference-workspace '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -2576,7 +2576,7 @@ importers: specifier: ^4.17.12 version: 4.17.12 '@types/spark-md5': - specifier: ^3.0.1 + specifier: 3.0.5 version: 3.0.5 '@typescript-eslint/eslint-plugin': specifier: 8.58.0 @@ -2766,7 +2766,7 @@ importers: specifier: 3.0.6 version: 3.0.6(eslint@9.39.5(jiti@2.6.1)) json-schema-to-typescript: - specifier: ^13.0.1 + specifier: 13.1.2 version: 13.1.2 lodash-es: specifier: ^4.17.23 @@ -2806,7 +2806,7 @@ importers: specifier: workspace:* version: link:../../tools/oxlint-config '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -2903,7 +2903,7 @@ importers: specifier: workspace:* version: link:../../tools/oxlint-config '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -2997,7 +2997,7 @@ importers: specifier: workspace:* version: link:../../tools/oxlint-config '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -3006,10 +3006,10 @@ importers: specifier: ^4.17.12 version: 4.17.12 '@types/spark-md5': - specifier: ^3.0.1 + specifier: 3.0.5 version: 3.0.5 '@types/stringify-object': - specifier: ^3.2.0 + specifier: 3.3.1 version: 3.3.1 '@typescript-eslint/eslint-plugin': specifier: 8.58.0 @@ -3085,7 +3085,7 @@ importers: specifier: workspace:* version: link:../../tools/oxlint-config '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -3212,7 +3212,7 @@ importers: specifier: workspace:* version: link:../../tools/stylelint-config '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -3233,7 +3233,7 @@ importers: specifier: 24.12.0 version: 24.12.0 '@types/raf': - specifier: ^3.4.0 + specifier: 3.4.3 version: 3.4.3 '@types/react': specifier: 19.1.11 @@ -3290,7 +3290,7 @@ importers: specifier: 3.0.6 version: 3.0.6(eslint@9.39.5(jiti@2.6.1)) full-icu: - specifier: ^1.3.0 + specifier: 1.6.0 version: 1.6.0 happy-dom: specifier: 20.9.0 @@ -3320,7 +3320,7 @@ importers: specifier: ^16.26.1 version: 16.26.1(typescript@5.9.3) tsd: - specifier: ^0.32.0 + specifier: 0.32.0 version: 0.32.0 typescript: specifier: 5.9.3 @@ -3369,7 +3369,7 @@ importers: specifier: workspace:* version: link:../../tools/oxlint-config '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -3463,7 +3463,7 @@ importers: specifier: workspace:* version: link:../sdk-ui-theme-provider '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -3665,7 +3665,7 @@ importers: specifier: workspace:* version: link:../../tools/stylelint-config '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -3680,7 +3680,7 @@ importers: specifier: ^4.17.12 version: 4.17.12 '@types/raf': - specifier: ^3.4.0 + specifier: 3.4.3 version: 3.4.3 '@types/react': specifier: 19.1.11 @@ -3873,7 +3873,7 @@ importers: specifier: workspace:* version: link:../../tools/stylelint-config '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -3888,7 +3888,7 @@ importers: specifier: ^3.3.1 version: 3.3.7(@types/react@19.1.11) '@types/js-yaml': - specifier: ^4.0.9 + specifier: 4.0.9 version: 4.0.9 '@types/lodash-es': specifier: ^4.17.12 @@ -3897,7 +3897,7 @@ importers: specifier: 24.12.0 version: 24.12.0 '@types/raf': - specifier: ^3.4.0 + specifier: 3.4.3 version: 3.4.3 '@types/react': specifier: 19.1.11 @@ -3957,7 +3957,7 @@ importers: specifier: 3.0.6 version: 3.0.6(eslint@9.39.5(jiti@2.6.1)) full-icu: - specifier: ^1.3.0 + specifier: 1.6.0 version: 1.6.0 happy-dom: specifier: 20.9.0 @@ -4141,7 +4141,7 @@ importers: specifier: workspace:* version: link:../../tools/stylelint-config '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -4162,7 +4162,7 @@ importers: specifier: 24.12.0 version: 24.12.0 '@types/raf': - specifier: ^3.4.0 + specifier: 3.4.3 version: 3.4.3 '@types/react': specifier: 19.1.11 @@ -4171,7 +4171,7 @@ importers: specifier: 19.1.7 version: 19.1.7(@types/react@19.1.11) '@types/react-lines-ellipsis': - specifier: ^0.15.6 + specifier: 0.15.6 version: 0.15.6 '@types/react-measure': specifier: 2.0.12 @@ -4258,7 +4258,7 @@ importers: specifier: ^16.26.1 version: 16.26.1(typescript@5.9.3) svgo: - specifier: ^2.8.0 + specifier: 2.8.3 version: 2.8.3 typescript: specifier: 5.9.3 @@ -4415,7 +4415,7 @@ importers: specifier: workspace:* version: link:../../tools/stylelint-config '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -4433,7 +4433,7 @@ importers: specifier: ^3.1.4 version: 3.1.9 '@types/js-yaml': - specifier: ^4.0.9 + specifier: 4.0.9 version: 4.0.9 '@types/lodash-es': specifier: ^4.17.12 @@ -4442,7 +4442,7 @@ importers: specifier: 24.12.0 version: 24.12.0 '@types/raf': - specifier: ^3.4.0 + specifier: 3.4.3 version: 3.4.3 '@types/react': specifier: 19.1.11 @@ -4451,7 +4451,7 @@ importers: specifier: 19.1.7 version: 19.1.7(@types/react@19.1.11) '@types/react-lines-ellipsis': - specifier: ^0.15.6 + specifier: 0.15.6 version: 0.15.6 '@types/react-measure': specifier: 2.0.12 @@ -4505,7 +4505,7 @@ importers: specifier: 3.0.6 version: 3.0.6(eslint@9.39.5(jiti@2.6.1)) full-icu: - specifier: ^1.3.0 + specifier: 1.6.0 version: 1.6.0 happy-dom: specifier: 20.9.0 @@ -4538,7 +4538,7 @@ importers: specifier: ^16.26.1 version: 16.26.1(typescript@5.9.3) svgo: - specifier: ^2.8.0 + specifier: 2.8.3 version: 2.8.3 typescript: specifier: 5.9.3 @@ -4635,7 +4635,7 @@ importers: specifier: workspace:* version: link:../../tools/stylelint-config '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -4653,7 +4653,7 @@ importers: specifier: 24.12.0 version: 24.12.0 '@types/raf': - specifier: ^3.4.0 + specifier: 3.4.3 version: 3.4.3 '@types/react': specifier: 19.1.11 @@ -4710,7 +4710,7 @@ importers: specifier: 3.0.6 version: 3.0.6(eslint@9.39.5(jiti@2.6.1)) full-icu: - specifier: ^1.3.0 + specifier: 1.6.0 version: 1.6.0 happy-dom: specifier: 20.9.0 @@ -4879,7 +4879,7 @@ importers: specifier: workspace:* version: link:../../tools/stylelint-config '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -4897,7 +4897,7 @@ importers: specifier: 24.12.0 version: 24.12.0 '@types/raf': - specifier: ^3.4.0 + specifier: 3.4.3 version: 3.4.3 '@types/react': specifier: 19.1.11 @@ -4987,7 +4987,7 @@ importers: specifier: ^16.26.1 version: 16.26.1(typescript@5.9.3) svgo: - specifier: ^2.8.0 + specifier: 2.8.3 version: 2.8.3 typescript: specifier: 5.9.3 @@ -5066,7 +5066,7 @@ importers: specifier: workspace:* version: link:../../tools/stylelint-config '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -5084,7 +5084,7 @@ importers: specifier: ^4.17.12 version: 4.17.12 '@types/mapbox-gl': - specifier: ^2.6.0 + specifier: 2.7.21 version: 2.7.21 '@types/maplibre-gl': specifier: ^1.14.0 @@ -5349,7 +5349,7 @@ importers: specifier: workspace:* version: link:../../tools/stylelint-config '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -5367,7 +5367,7 @@ importers: specifier: ^3.1.4 version: 3.1.9 '@types/kefir': - specifier: ^3.8.5 + specifier: 3.8.11 version: 3.8.11 '@types/lodash-es': specifier: ^4.17.12 @@ -5376,7 +5376,7 @@ importers: specifier: 24.12.0 version: 24.12.0 '@types/raf': - specifier: ^3.4.0 + specifier: 3.4.3 version: 3.4.3 '@types/react': specifier: 19.1.11 @@ -5391,7 +5391,7 @@ importers: specifier: 4.0.7 version: 4.0.7 '@types/tinycolor2': - specifier: ^1.4.2 + specifier: 1.4.6 version: 1.4.6 '@typescript-eslint/eslint-plugin': specifier: 8.58.0 @@ -5445,7 +5445,7 @@ importers: specifier: 20.9.0 version: 20.9.0 kefir: - specifier: ^3.8.8 + specifier: 3.8.8 version: 3.8.8 npm-run-all: specifier: ^4.1.5 @@ -5475,7 +5475,7 @@ importers: specifier: ^16.26.1 version: 16.26.1(typescript@5.9.3) svgo: - specifier: ^2.8.0 + specifier: 2.8.3 version: 2.8.3 typescript: specifier: 5.9.3 @@ -5527,7 +5527,7 @@ importers: specifier: workspace:* version: link:../../tools/oxlint-config '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -5539,7 +5539,7 @@ importers: specifier: 24.12.0 version: 24.12.0 '@types/raf': - specifier: ^3.4.0 + specifier: 3.4.3 version: 3.4.3 '@types/react': specifier: 19.1.11 @@ -5690,7 +5690,7 @@ importers: specifier: workspace:* version: link:../../tools/stylelint-config '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -5708,7 +5708,7 @@ importers: specifier: 24.12.0 version: 24.12.0 '@types/raf': - specifier: ^3.4.0 + specifier: 3.4.3 version: 3.4.3 '@types/react': specifier: 19.1.11 @@ -5765,7 +5765,7 @@ importers: specifier: 3.0.6 version: 3.0.6(eslint@9.39.5(jiti@2.6.1)) full-icu: - specifier: ^1.3.0 + specifier: 1.6.0 version: 1.6.0 happy-dom: specifier: 20.9.0 @@ -5844,7 +5844,7 @@ importers: specifier: workspace:* version: link:../../tools/oxlint-config '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -5989,7 +5989,7 @@ importers: specifier: workspace:* version: link:../../tools/oxlint-config '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -6158,7 +6158,7 @@ importers: specifier: workspace:* version: link:../../tools/stylelint-config '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -6173,7 +6173,7 @@ importers: specifier: ^4.17.12 version: 4.17.12 '@types/raf': - specifier: ^3.4.0 + specifier: 3.4.3 version: 3.4.3 '@types/react': specifier: 19.1.11 @@ -6632,7 +6632,7 @@ importers: specifier: 19.1.7 version: 19.1.7(@types/react@19.1.11) '@types/spark-md5': - specifier: ^3.0.1 + specifier: 3.0.5 version: 3.0.5 '@typescript-eslint/eslint-plugin': specifier: 8.58.0 @@ -6827,7 +6827,7 @@ importers: specifier: 19.1.7 version: 19.1.7(@types/react@19.1.11) '@types/spark-md5': - specifier: ^3.0.1 + specifier: 3.0.5 version: 3.0.5 '@typescript-eslint/eslint-plugin': specifier: 8.58.0 @@ -6996,7 +6996,7 @@ importers: specifier: workspace:* version: link:../sdk-backend-mockingbird '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -7014,7 +7014,7 @@ importers: specifier: 24.12.0 version: 24.12.0 '@types/raf': - specifier: ^3.4.0 + specifier: 3.4.3 version: 3.4.3 '@types/react': specifier: 19.1.11 @@ -7156,7 +7156,7 @@ importers: specifier: workspace:* version: link:../../tools/stylelint-config '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -7337,7 +7337,7 @@ importers: specifier: workspace:* version: link:../sdk-backend-mockingbird '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -7349,7 +7349,7 @@ importers: specifier: 24.12.0 version: 24.12.0 '@types/raf': - specifier: ^3.4.0 + specifier: 3.4.3 version: 3.4.3 '@types/react': specifier: 19.1.11 @@ -7430,7 +7430,7 @@ importers: specifier: 1.0.3 version: 1.0.3 rollup: - specifier: ^4.51.0 + specifier: 4.62.3 version: 4.62.3 typescript: specifier: 5.9.3 @@ -7464,7 +7464,7 @@ importers: specifier: workspace:* version: link:../../tools/oxlint-config '@microsoft/api-documenter': - specifier: ^7.17.0 + specifier: 7.30.10 version: 7.30.10(@types/node@24.12.0) '@microsoft/api-extractor': specifier: ^7.55.2 @@ -7835,7 +7835,7 @@ importers: specifier: 2.8.1 version: 2.8.1 validate-npm-package-name: - specifier: ^5.0.0 + specifier: 5.0.1 version: 5.0.1 webpack-cli: specifier: 7.0.3 @@ -7857,7 +7857,7 @@ importers: specifier: ^6.0.2 version: 6.0.6 '@types/fs-extra': - specifier: ^9.0.13 + specifier: 9.0.13 version: 9.0.13 '@types/inquirer': specifier: ^9.0.3 @@ -8141,7 +8141,7 @@ importers: specifier: 7.29.7 version: 7.29.7(@babel/core@7.28.4) '@babel/preset-react': - specifier: ^7.23.3 + specifier: 7.29.7 version: 7.29.7(@babel/core@7.28.4) '@gooddata/catalog-export': specifier: workspace:* @@ -8198,7 +8198,7 @@ importers: specifier: 24.12.0 version: 24.12.0 '@types/raf': - specifier: ^3.4.0 + specifier: 3.4.3 version: 3.4.3 '@types/react': specifier: 19.1.11 @@ -8216,10 +8216,10 @@ importers: specifier: 7.0.0-dev.20260202.1 version: 7.0.0-dev.20260202.1 babel-loader: - specifier: ^8.0.5 + specifier: 8.4.1 version: 8.4.1(@babel/core@7.28.4)(webpack@5.107.2) case-sensitive-paths-webpack-plugin: - specifier: ^2.4.0 + specifier: 2.4.0 version: 2.4.0 css-loader: specifier: 6.11.0 @@ -8231,10 +8231,10 @@ importers: specifier: 16.4.5 version: 16.4.5 dotenv-webpack: - specifier: ^7.0.2 + specifier: 7.1.1 version: 7.1.1(webpack@5.107.2) esbuild-loader: - specifier: ^4.0.2 + specifier: 4.5.0 version: 4.5.0(webpack@5.107.2) eslint: specifier: ^9.39.2 @@ -8291,13 +8291,13 @@ importers: specifier: 7.1.11 version: 7.1.11(react@19.1.1)(typescript@5.9.3) source-map-loader: - specifier: ^4.0.0 + specifier: 4.0.2 version: 4.0.2(webpack@5.107.2) style-loader: specifier: 3.3.4 version: 3.3.4(webpack@5.107.2) ts-loader: - specifier: ^8.3.0 + specifier: 8.4.0 version: 8.4.0(typescript@5.9.3)(webpack@5.107.2) typescript: specifier: 5.9.3 @@ -8411,7 +8411,7 @@ importers: specifier: 5.9.3 version: 5.9.3 vite-node: - specifier: ^5.2.0 + specifier: 5.3.0 version: 5.3.0(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.70.0)(terser@5.49.0)(yaml@2.8.3) ../../tools/i18n-toolkit: @@ -8502,7 +8502,7 @@ importers: specifier: 3.0.6 version: 3.0.6(eslint@9.39.5(jiti@2.6.1)) mkdirp: - specifier: ^1.0.4 + specifier: 1.0.4 version: 1.0.4 npm-run-all: specifier: ^4.1.5 @@ -8740,7 +8740,7 @@ importers: specifier: 5.9.3 version: 5.9.3 vite-node: - specifier: ^5.2.0 + specifier: 5.3.0 version: 5.3.0(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.33.0)(sass@1.70.0)(terser@5.49.0)(yaml@2.8.3) ../../tools/plugin-toolkit: @@ -8809,7 +8809,7 @@ importers: specifier: 2.8.1 version: 2.8.1 validate-npm-package-name: - specifier: ^5.0.0 + specifier: 5.0.1 version: 5.0.1 devDependencies: '@gooddata/eslint-config': @@ -8825,7 +8825,7 @@ importers: specifier: ^6.0.2 version: 6.0.6 '@types/fs-extra': - specifier: ^9.0.13 + specifier: 9.0.13 version: 9.0.13 '@types/inquirer': specifier: ^9.0.3 @@ -8967,7 +8967,7 @@ importers: specifier: 8.58.0 version: 8.58.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) case-sensitive-paths-webpack-plugin: - specifier: ^2.4.0 + specifier: 2.4.0 version: 2.4.0 css-loader: specifier: 6.11.0 @@ -8979,7 +8979,7 @@ importers: specifier: 16.4.5 version: 16.4.5 esbuild-loader: - specifier: ^4.0.2 + specifier: 4.5.0 version: 4.5.0(webpack@5.107.2) eslint: specifier: ^9.39.2 @@ -9024,16 +9024,16 @@ importers: specifier: 0.15.0 version: 0.15.0 process: - specifier: ^0.11.10 + specifier: 0.11.10 version: 0.11.10 source-map-loader: - specifier: ^4.0.0 + specifier: 4.0.2 version: 4.0.2(webpack@5.107.2) style-loader: specifier: 3.3.4 version: 3.3.4(webpack@5.107.2) ts-loader: - specifier: ^8.3.0 + specifier: 8.4.0 version: 8.4.0(typescript@5.9.3)(webpack@5.107.2) typescript: specifier: 5.9.3 diff --git a/common/config/rush/version-policies.json b/common/config/rush/version-policies.json index a07bb99e12c..8386c7348fe 100644 --- a/common/config/rush/version-policies.json +++ b/common/config/rush/version-policies.json @@ -2,14 +2,14 @@ { "definitionName": "lockStepVersion", "policyName": "sdk", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "nextBump": "prerelease", "mainProject": "@gooddata/sdk-ui-all" }, { "definitionName": "lockStepVersion", "policyName": "sdk-not-npm-publish", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "nextBump": "prerelease", "mainProject": "@gooddata/sdk-ui-all" } diff --git a/docs/content/en/latest/references/embed_ai_assistant/_index.md b/docs/content/en/latest/references/embed_ai_assistant/_index.md index 72100a57324..1dee1c25fad 100644 --- a/docs/content/en/latest/references/embed_ai_assistant/_index.md +++ b/docs/content/en/latest/references/embed_ai_assistant/_index.md @@ -76,30 +76,29 @@ const App = () => { ### Props -| Name | Type | Default | Description | -| ------------------------------ | --------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| locale | ILocale | "en-US" | Specifies the locale for internationalization | -| backend | IAnalyticalBackend | - | Backend instance. Falls back to BackendProvider context if not specified | -| workspace | string | - | Workspace ID. Falls back to WorkspaceProvider context if not specified | -| colorPalette | IColorPalette | - | Color palette used for rendering the visualizations. If not provided, the default color palette will be used | -| catalogItems | CatalogItem[] | - | Catalog items used for autocompletion. If not provided - will be lazy-loaded when needed | -| settings | IUserWorkspaceSettings | - | Workspace settings used by the assistant UI | -| eventHandlers | ChatEventHandler[] | - | Event handlers for user interactions with the chat UI | -| onLinkClick | (LinkHandlerEvent) => void | - | Handle user clicks on the catalog items mentioned in chat. | -| allowNativeLinks | boolean | false | Whether to allow native links in chat messages. If false, `onLinkClick` handler will be fired when clicking on links | -| disableManage | boolean | false | This will disable manage permissions for the user even if the user has them defined. | -| disableAnalyze | boolean | false | This will disable analyze permissions for the user even if the user has them defined. | -| disableFullControl | boolean | false | This will disable full control permissions for the user even if the user has them defined. | -| objectTypes | GenAIObjectType[] | - | Restricts object types used by assistant search and suggestions. | -| includeTags | string[] | - | Includes only tagged metadata objects when assistant resolves relevant content. | -| excludeTags | string[] | - | Excludes tagged metadata objects when assistant resolves relevant content. | -| onDispatcher | (dispatch: EnhancedStore["dispatch"]) => void | - | Dispatcher callback for assistant actions and state changes. | -| dashboardSelector | DashboardSelectorEvaluator | - | Selector that is used to automatically build ambient context for the chat. If it is provided, the chat will automatically load the dashboards and related data from it. | -| LandingScreenComponentProvider | () => ComponentType | - | Factory for providing a custom initial assistant experience component. When omitted, the default landing screen with quick questions is displayed. | -| DisclaimerComponentProvider | () => ComponentType \| null | - | Factory for providing a custom disclaimer component shown in the assistant UI. Return `null` to hide disclaimer rendering. | -| className | string | - | Additional class name applied to the root assistant element. | -| mode | "docked" \| "fullscreen" | "docked" | Display mode of the assistant. Adapts its internal layout to a compact docked container or to a wide fullscreen one. Does not resize the component. | -| isPreview | boolean | false | Internal preview mode. Uses workspace preview agent and preview conversations. Toggling resets assistant state. | +| Name | Type | Default | Description | +| ------------------ | --------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| locale | ILocale | "en-US" | Specifies the locale for internationalization | +| backend | IAnalyticalBackend | - | Backend instance. Falls back to BackendProvider context if not specified | +| workspace | string | - | Workspace ID. Falls back to WorkspaceProvider context if not specified | +| colorPalette | IColorPalette | - | Color palette used for rendering the visualizations. If not provided, the default color palette will be used | +| catalogItems | CatalogItem[] | - | Catalog items used for autocompletion. If not provided - will be lazy-loaded when needed | +| settings | IUserWorkspaceSettings | - | Workspace settings used by the assistant UI | +| eventHandlers | ChatEventHandler[] | - | Event handlers for user interactions with the chat UI | +| onLinkClick | (LinkHandlerEvent) => void | - | Handle user clicks on the catalog items mentioned in chat. | +| allowNativeLinks | boolean | false | Whether to allow native links in chat messages. If false, `onLinkClick` handler will be fired when clicking on links | +| disableManage | boolean | false | This will disable manage permissions for the user even if the user has them defined. | +| disableAnalyze | boolean | false | This will disable analyze permissions for the user even if the user has them defined. | +| disableFullControl | boolean | false | This will disable full control permissions for the user even if the user has them defined. | +| objectTypes | GenAIObjectType[] | - | Restricts object types used by assistant search and suggestions. | +| includeTags | string[] | - | Includes only tagged metadata objects when assistant resolves relevant content. | +| excludeTags | string[] | - | Excludes tagged metadata objects when assistant resolves relevant content. | +| onDispatcher | (dispatch: EnhancedStore["dispatch"]) => void | - | Dispatcher callback for assistant actions and state changes. | +| dashboardSelector | DashboardSelectorEvaluator | - | Selector that is used to automatically build ambient context for the chat. If it is provided, the chat will automatically load the dashboards and related data from it. | +| slots | IGenAIAssistantSlots | - | Customizations for the Gen AI assistant. | +| className | string | - | Additional class name applied to the root assistant element. | +| mode | "docked" \| "fullscreen" | "docked" | Display mode of the assistant. Adapts its internal layout to a compact docked container or to a wide fullscreen one. Does not resize the component. | +| isPreview | boolean | false | Internal preview mode. Uses workspace preview agent and preview conversations. Toggling resets assistant state. | ### Display mode @@ -444,8 +443,8 @@ The initial assistant experience defines what users see before they send their f ### Customizing the initial assistant experience -To replace the default initial experience, provide the `LandingScreenComponentProvider` prop. -This prop accepts a factory function that returns a React component type, which is rendered as the initial assistant experience. The provider must return a component type, not JSX. +To replace the default initial experience, provide the `slots.LandingScreen` prop. +This prop accepts a React component type, which is rendered as the initial assistant experience. ```tsx import { GenAIAssistant } from "@gooddata/sdk-ui-gen-ai"; @@ -458,7 +457,13 @@ const CustomLandingScreen = () => ( ); -export const App = () => CustomLandingScreen} />; +export const App = () => ( + +); ``` The package exports building blocks used by the default initial assistant experience. @@ -472,18 +477,19 @@ Available components: - `DefaultLandingQuestion` To extend the default experience (for example, to customize the suggested questions), wrap -`DefaultLandingScreen` in your own component and provide custom children: +the `Default` component provided in the slot props in your own component and provide custom children: ```tsx import { - DefaultLandingScreen, DefaultLandingQuestion, DefaultLandingTitle, DefaultLandingTitleAscent, + type IGenAIAssistantLandingScreenProps, } from "@gooddata/sdk-ui-gen-ai"; +import { type ISlotProps } from "@gooddata/sdk-ui-kit"; -const CustomDefaultLanding = () => ( - +const CustomDefaultLanding = ({ Default, defaultProps }: ISlotProps) => ( + Ask AI to explore your workspace
@@ -494,10 +500,16 @@ const CustomDefaultLanding = () => ( answer="Here is the revenue by product for 2024" /> -
+ ); -export const App = () => CustomDefaultLanding} />; +export const App = () => ( + +); ``` You can also reuse individual building blocks, such as `DefaultLandingQuestion`, inside a fully custom layout. @@ -530,6 +542,40 @@ const CustomLandingScreen = () => ( Use these building blocks to mix default styling and accessibility features with your own branding, while still benefiting from the automated assistant interactions. +### Customizing the disclaimer + +You can also customize or hide the disclaimer rendered below the chat input using `slots.Disclaimer`. + +```tsx +import { GenAIAssistant } from "@gooddata/sdk-ui-gen-ai"; + +const CustomDisclaimer = () => ( +
+ Please note that AI-generated content may be inaccurate. +
+); + +export const App = () => ( + +); +``` + +To hide the disclaimer entirely, return `null`: + +```tsx +export const App = () => ( + null, + }} + /> +); +``` + ## Resetting the chat thread To reset the chat thread, use the `reset` method on the ChatThread interface. diff --git a/examples/playground/package.json b/examples/playground/package.json index f970c912caa..95804675595 100644 --- a/examples/playground/package.json +++ b/examples/playground/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/playground", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "private": true, "description": "GoodData.UI SDK - Developer Playground", "license": "LicenseRef-LICENSE", diff --git a/examples/sdk-interactive-examples/examples-template/package.json b/examples/sdk-interactive-examples/examples-template/package.json index ea412e767c4..3e8f5d5eafd 100644 --- a/examples/sdk-interactive-examples/examples-template/package.json +++ b/examples/sdk-interactive-examples/examples-template/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-interactive-examples-template", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "private": false, "description": "GoodData interactive example template", "license": "LicenseRef-LICENSE", @@ -66,7 +66,7 @@ "oxfmt": "0.52.0", "oxlint": "1.51.0", "oxlint-tsgolint": "0.15.0", - "process": "^0.11.10", + "process": "0.11.10", "rolldown": "1.0.3", "typescript": "5.9.3", "vite": "8.0.16", diff --git a/examples/sdk-interactive-examples/examples/example-attributefilter/package.json b/examples/sdk-interactive-examples/examples/example-attributefilter/package.json index 0a0d20328c1..f14268c6cda 100644 --- a/examples/sdk-interactive-examples/examples/example-attributefilter/package.json +++ b/examples/sdk-interactive-examples/examples/example-attributefilter/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-interactive-example-attributefilter", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "private": false, "description": "This example demonstrates how to use the AttributeFilter component to filter data in a visualization.", "license": "LicenseRef-LICENSE", @@ -66,7 +66,7 @@ "oxfmt": "0.52.0", "oxlint": "1.51.0", "oxlint-tsgolint": "0.15.0", - "process": "^0.11.10", + "process": "0.11.10", "rolldown": "1.0.3", "typescript": "5.9.3", "vite": "8.0.16", diff --git a/examples/sdk-interactive-examples/examples/example-chartconfig/package.json b/examples/sdk-interactive-examples/examples/example-chartconfig/package.json index bc1e31d1123..936f4a57074 100644 --- a/examples/sdk-interactive-examples/examples/example-chartconfig/package.json +++ b/examples/sdk-interactive-examples/examples/example-chartconfig/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-interactive-example-chartconfig", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "private": false, "description": "This interactive example demonstrates how to manipulate the chart config.", "license": "LicenseRef-LICENSE", @@ -66,7 +66,7 @@ "oxfmt": "0.52.0", "oxlint": "1.51.0", "oxlint-tsgolint": "0.15.0", - "process": "^0.11.10", + "process": "0.11.10", "rolldown": "1.0.3", "typescript": "5.9.3", "vite": "8.0.16", diff --git a/examples/sdk-interactive-examples/examples/example-columnchart/package.json b/examples/sdk-interactive-examples/examples/example-columnchart/package.json index 858f612fe06..bfeec027369 100644 --- a/examples/sdk-interactive-examples/examples/example-columnchart/package.json +++ b/examples/sdk-interactive-examples/examples/example-columnchart/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-interactive-example-columnchart", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "private": false, "description": "This example demonstrates the usage of the ColumnChart component with the viewBy and stackBy properties.", "license": "LicenseRef-LICENSE", @@ -66,7 +66,7 @@ "oxfmt": "0.52.0", "oxlint": "1.51.0", "oxlint-tsgolint": "0.15.0", - "process": "^0.11.10", + "process": "0.11.10", "rolldown": "1.0.3", "typescript": "5.9.3", "vite": "8.0.16", diff --git a/examples/sdk-interactive-examples/examples/example-combochart/package.json b/examples/sdk-interactive-examples/examples/example-combochart/package.json index a0d027012bb..8cafc1d2ed2 100644 --- a/examples/sdk-interactive-examples/examples/example-combochart/package.json +++ b/examples/sdk-interactive-examples/examples/example-combochart/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-interactive-example-combochart", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "private": false, "description": "Example demonstrates ComboChart secondaryMeasures definition. ", "license": "LicenseRef-LICENSE", @@ -66,7 +66,7 @@ "oxfmt": "0.52.0", "oxlint": "1.51.0", "oxlint-tsgolint": "0.15.0", - "process": "^0.11.10", + "process": "0.11.10", "rolldown": "1.0.3", "typescript": "5.9.3", "vite": "8.0.16", diff --git a/examples/sdk-interactive-examples/examples/example-dashboard/package.json b/examples/sdk-interactive-examples/examples/example-dashboard/package.json index 48aeb1c52c2..1339a7ae4f8 100644 --- a/examples/sdk-interactive-examples/examples/example-dashboard/package.json +++ b/examples/sdk-interactive-examples/examples/example-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-interactive-example-dashboard", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "private": false, "description": "This example shows how to use the Dashboard component.", "license": "LicenseRef-LICENSE", @@ -66,7 +66,7 @@ "oxfmt": "0.52.0", "oxlint": "1.51.0", "oxlint-tsgolint": "0.15.0", - "process": "^0.11.10", + "process": "0.11.10", "rolldown": "1.0.3", "typescript": "5.9.3", "vite": "8.0.16", diff --git a/examples/sdk-interactive-examples/examples/example-datefilter/package.json b/examples/sdk-interactive-examples/examples/example-datefilter/package.json index 999b56c3672..b6bc29076bf 100644 --- a/examples/sdk-interactive-examples/examples/example-datefilter/package.json +++ b/examples/sdk-interactive-examples/examples/example-datefilter/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-interactive-example-datefilter", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "private": false, "description": "Example demonstrates usage of Date Filter component.", "license": "LicenseRef-LICENSE", @@ -66,7 +66,7 @@ "oxfmt": "0.52.0", "oxlint": "1.51.0", "oxlint-tsgolint": "0.15.0", - "process": "^0.11.10", + "process": "0.11.10", "rolldown": "1.0.3", "typescript": "5.9.3", "vite": "8.0.16", diff --git a/examples/sdk-interactive-examples/examples/example-dependentfilters/package.json b/examples/sdk-interactive-examples/examples/example-dependentfilters/package.json index c2989e4ad71..5a752dd1e0a 100644 --- a/examples/sdk-interactive-examples/examples/example-dependentfilters/package.json +++ b/examples/sdk-interactive-examples/examples/example-dependentfilters/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-interactive-example-dependentfilters", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "private": false, "description": "This example demonstrates how to use multiple attribute filters linked together to filter data in a visualization.", "license": "LicenseRef-LICENSE", @@ -66,7 +66,7 @@ "oxfmt": "0.52.0", "oxlint": "1.51.0", "oxlint-tsgolint": "0.15.0", - "process": "^0.11.10", + "process": "0.11.10", "rolldown": "1.0.3", "typescript": "5.9.3", "vite": "8.0.16", diff --git a/examples/sdk-interactive-examples/examples/example-execute/package.json b/examples/sdk-interactive-examples/examples/example-execute/package.json index 8b7c9cb1a71..13634aee846 100644 --- a/examples/sdk-interactive-examples/examples/example-execute/package.json +++ b/examples/sdk-interactive-examples/examples/example-execute/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-interactive-example-execute", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "private": false, "description": "This example demonstrates using Execute component and build custom visualization.", "license": "LicenseRef-LICENSE", @@ -69,7 +69,7 @@ "oxfmt": "0.52.0", "oxlint": "1.51.0", "oxlint-tsgolint": "0.15.0", - "process": "^0.11.10", + "process": "0.11.10", "rolldown": "1.0.3", "typescript": "5.9.3", "vite": "8.0.16", diff --git a/examples/sdk-interactive-examples/examples/example-granularity/package.json b/examples/sdk-interactive-examples/examples/example-granularity/package.json index c3fb227bab7..efe53ec0a06 100644 --- a/examples/sdk-interactive-examples/examples/example-granularity/package.json +++ b/examples/sdk-interactive-examples/examples/example-granularity/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-interactive-example-granularity", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "private": false, "description": "This example exmplains DateFilter granularity ", "license": "LicenseRef-LICENSE", @@ -66,7 +66,7 @@ "oxfmt": "0.52.0", "oxlint": "1.51.0", "oxlint-tsgolint": "0.15.0", - "process": "^0.11.10", + "process": "0.11.10", "rolldown": "1.0.3", "typescript": "5.9.3", "vite": "8.0.16", diff --git a/examples/sdk-interactive-examples/examples/example-headline/package.json b/examples/sdk-interactive-examples/examples/example-headline/package.json index 690f8499f2e..3c4a0000812 100644 --- a/examples/sdk-interactive-examples/examples/example-headline/package.json +++ b/examples/sdk-interactive-examples/examples/example-headline/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-interactive-example-headline", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "private": false, "description": "This example shows how to use the Headline component.", "license": "LicenseRef-LICENSE", @@ -66,7 +66,7 @@ "oxfmt": "0.52.0", "oxlint": "1.51.0", "oxlint-tsgolint": "0.15.0", - "process": "^0.11.10", + "process": "0.11.10", "rolldown": "1.0.3", "typescript": "5.9.3", "vite": "8.0.16", diff --git a/examples/sdk-interactive-examples/examples/example-pivottable/package.json b/examples/sdk-interactive-examples/examples/example-pivottable/package.json index 8820f1c5ba8..2e859a182c8 100644 --- a/examples/sdk-interactive-examples/examples/example-pivottable/package.json +++ b/examples/sdk-interactive-examples/examples/example-pivottable/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-interactive-example-pivottable", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "private": false, "description": "Basic PivotTable manipulation.", "license": "LicenseRef-LICENSE", @@ -66,7 +66,7 @@ "oxfmt": "0.52.0", "oxlint": "1.51.0", "oxlint-tsgolint": "0.15.0", - "process": "^0.11.10", + "process": "0.11.10", "rolldown": "1.0.3", "typescript": "5.9.3", "vite": "8.0.16", diff --git a/examples/sdk-interactive-examples/examples/example-relativedatefilter/package.json b/examples/sdk-interactive-examples/examples/example-relativedatefilter/package.json index 44b8186803f..bb9516cc71a 100644 --- a/examples/sdk-interactive-examples/examples/example-relativedatefilter/package.json +++ b/examples/sdk-interactive-examples/examples/example-relativedatefilter/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-interactive-example-relativedatefilter", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "private": false, "description": "Example demonstrates how to set relative DateFilter for visualization.", "license": "LicenseRef-LICENSE", @@ -66,7 +66,7 @@ "oxfmt": "0.52.0", "oxlint": "1.51.0", "oxlint-tsgolint": "0.15.0", - "process": "^0.11.10", + "process": "0.11.10", "rolldown": "1.0.3", "typescript": "5.9.3", "vite": "8.0.16", diff --git a/examples/sdk-interactive-examples/examples/example-repeater/package.json b/examples/sdk-interactive-examples/examples/example-repeater/package.json index 7a84e4a5849..d5bafcd4ab8 100644 --- a/examples/sdk-interactive-examples/examples/example-repeater/package.json +++ b/examples/sdk-interactive-examples/examples/example-repeater/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-interactive-example-repeater", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "private": false, "description": "This example demonstrates how to use Repeater component.", "license": "LicenseRef-LICENSE", @@ -66,7 +66,7 @@ "oxfmt": "0.52.0", "oxlint": "1.51.0", "oxlint-tsgolint": "0.15.0", - "process": "^0.11.10", + "process": "0.11.10", "rolldown": "1.0.3", "typescript": "5.9.3", "vite": "8.0.16", diff --git a/examples/sdk-interactive-examples/package.json b/examples/sdk-interactive-examples/package.json index ad144d93fde..1018404d383 100644 --- a/examples/sdk-interactive-examples/package.json +++ b/examples/sdk-interactive-examples/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-interactive-examples", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "private": false, "description": "GoodData React interactive examples", "license": "LicenseRef-LICENSE", @@ -16,6 +16,6 @@ "validate": "node ./scripts/validateExamples.mjs" }, "devDependencies": { - "validate-npm-package-name": "^5.0.0" + "validate-npm-package-name": "5.0.1" } } diff --git a/libs/api-client-tiger/package.json b/libs/api-client-tiger/package.json index 1aa5063187d..45f6a2d7e2e 100644 --- a/libs/api-client-tiger/package.json +++ b/libs/api-client-tiger/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/api-client-tiger", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "API Client for GoodData Cloud and GoodData.CN", "license": "MIT", "author": "GoodData", @@ -73,9 +73,9 @@ "devDependencies": { "@gooddata/eslint-config": "workspace:*", "@gooddata/oxlint-config": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", - "@openapitools/openapi-generator-cli": "^2.6.0", + "@openapitools/openapi-generator-cli": "2.40.1", "@types/lodash-es": "^4.17.12", "@typescript-eslint/eslint-plugin": "8.58.0", "@typescript-eslint/parser": "8.58.0", @@ -91,8 +91,8 @@ "eslint-plugin-jsdoc": "62.1.0", "eslint-plugin-no-barrel-files": "1.2.2", "eslint-plugin-sonarjs": "3.0.6", - "fix-esm-import-path": "^1.4.0", - "mkdirp": "^1.0.4", + "fix-esm-import-path": "1.10.3", + "mkdirp": "1.0.4", "npm-run-all": "^4.1.5", "oxfmt": "0.52.0", "oxlint": "1.51.0", diff --git a/libs/sdk-backend-base/api/sdk-backend-base.api.md b/libs/sdk-backend-base/api/sdk-backend-base.api.md index 29b140930ba..ffb95bacc06 100644 --- a/libs/sdk-backend-base/api/sdk-backend-base.api.md +++ b/libs/sdk-backend-base/api/sdk-backend-base.api.md @@ -138,6 +138,12 @@ import { IPostProcessing } from '@gooddata/sdk-model'; import { IPreparedExecution } from '@gooddata/sdk-backend-spi'; import { IPreparedExecutionOptions } from '@gooddata/sdk-backend-spi'; import { IRawExportCustomOverrides } from '@gooddata/sdk-backend-spi'; +import { IReport } from '@gooddata/sdk-model'; +import { IReportDefinition } from '@gooddata/sdk-model'; +import { IReportPageLayout } from '@gooddata/sdk-model'; +import { IReportPageLayoutDefinition } from '@gooddata/sdk-model'; +import { IReportTemplate } from '@gooddata/sdk-model'; +import { IReportTemplateDefinition } from '@gooddata/sdk-model'; import { IRequestCorrelationMetadata } from '@gooddata/sdk-backend-spi'; import { IResultHeader } from '@gooddata/sdk-model'; import { IScheduledMail } from '@gooddata/sdk-model'; @@ -169,6 +175,7 @@ import { IWorkspaceExportTemplatesService } from '@gooddata/sdk-backend-spi'; import { IWorkspaceFactsService } from '@gooddata/sdk-backend-spi'; import { IWorkspaceInsightsService } from '@gooddata/sdk-backend-spi'; import { IWorkspaceMeasuresService } from '@gooddata/sdk-backend-spi'; +import { IWorkspaceReportsService } from '@gooddata/sdk-backend-spi'; import { IWorkspaceSettings } from '@gooddata/sdk-backend-spi'; import { IWorkspaceSettingsService } from '@gooddata/sdk-backend-spi'; import { KpiDrillDefinition } from '@gooddata/sdk-model'; @@ -825,6 +832,43 @@ export abstract class DecoratedWorkspaceInsightsService implements IWorkspaceIns readonly workspace: string; } +// @alpha +export abstract class DecoratedWorkspaceReportsService implements IWorkspaceReportsService { + protected constructor(decorated: IWorkspaceReportsService); + // (undocumented) + createReport(report: IReportDefinition): Promise; + // (undocumented) + createReportPageLayout(page: IReportPageLayoutDefinition): Promise; + // (undocumented) + createReportTemplate(template: IReportTemplateDefinition): Promise; + // (undocumented) + protected readonly decorated: IWorkspaceReportsService; + // (undocumented) + deleteReport(ref: ObjRef): Promise; + // (undocumented) + deleteReportPageLayout(ref: ObjRef): Promise; + // (undocumented) + deleteReportTemplate(ref: ObjRef): Promise; + // (undocumented) + getReport(ref: ObjRef): Promise; + // (undocumented) + getReportPageLayout(ref: ObjRef): Promise; + // (undocumented) + getReportPageLayouts(): Promise; + // (undocumented) + getReports(): Promise; + // (undocumented) + getReportTemplate(ref: ObjRef): Promise; + // (undocumented) + getReportTemplates(): Promise; + // (undocumented) + updateReport(report: IReport): Promise; + // (undocumented) + updateReportPageLayout(page: IReportPageLayout): Promise; + // (undocumented) + updateReportTemplate(template: IReportTemplate): Promise; +} + // @alpha (undocumented) export abstract class DecoratedWorkspaceSettingsService implements IWorkspaceSettingsService { protected constructor(decorated: IWorkspaceSettingsService); @@ -924,6 +968,7 @@ export type DecoratorFactories = { geo?: GeoDecoratorFactory; organizationExportTemplates?: OrganizationExportTemplatesDecoratorFactory; workspaceExportTemplates?: WorkspaceExportTemplatesDecoratorFactory; + workspaceReports?: WorkspaceReportsDecoratorFactory; }; // @internal (undocumented) @@ -1162,6 +1207,41 @@ export class InMemoryPaging implements IPagedResource { readonly totalCount: number; } +// @alpha +export class InMemoryWorkspaceReportsService implements IWorkspaceReportsService { + constructor(persistence?: IWorkspaceReportsPersistence | undefined); + // (undocumented) + createReport(report: IReportDefinition): Promise; + // (undocumented) + createReportPageLayout(layout: IReportPageLayoutDefinition): Promise; + // (undocumented) + createReportTemplate(template: IReportTemplateDefinition): Promise; + // (undocumented) + deleteReport(ref: ObjRef): Promise; + // (undocumented) + deleteReportPageLayout(ref: ObjRef): Promise; + // (undocumented) + deleteReportTemplate(ref: ObjRef): Promise; + // (undocumented) + getReport(ref: ObjRef): Promise; + // (undocumented) + getReportPageLayout(ref: ObjRef): Promise; + // (undocumented) + getReportPageLayouts(): Promise; + // (undocumented) + getReports(): Promise; + // (undocumented) + getReportTemplate(ref: ObjRef): Promise; + // (undocumented) + getReportTemplates(): Promise; + // (undocumented) + updateReport(report: IReport): Promise; + // (undocumented) + updateReportPageLayout(layout: IReportPageLayout): Promise; + // (undocumented) + updateReportTemplate(template: IReportTemplate): Promise; +} + // @internal (undocumented) export interface INormalizerOptions { keepRemovableProperties?: boolean; @@ -1225,6 +1305,14 @@ export interface IWidgetBaseBuilder extends IBuilder { uri(valueOrUpdateCallback: ValueOrUpdateCallback): this; } +// @alpha +export interface IWorkspaceReportsPersistence { + // (undocumented) + load(): string | null; + // (undocumented) + save(value: string): void; +} + // @beta export interface IWorkspaceSettingsConfiguration { commonSettingsWrapper?: CommonSettingsWrapper; @@ -1530,6 +1618,9 @@ export type WorkspaceCatalogWrapper = (catalog: IWorkspaceCatalog) => IWorkspace // @alpha (undocumented) export type WorkspaceExportTemplatesDecoratorFactory = (exportTemplates: IWorkspaceExportTemplatesService, workspace: string) => IWorkspaceExportTemplatesService; +// @alpha (undocumented) +export type WorkspaceReportsDecoratorFactory = (reports: IWorkspaceReportsService, workspace: string) => IWorkspaceReportsService; + // @alpha (undocumented) export type WorkspaceSettingsDecoratorFactory = (settings: IWorkspaceSettingsService, workspace: string) => IWorkspaceSettingsService; diff --git a/libs/sdk-backend-base/package.json b/libs/sdk-backend-base/package.json index c1c4dc972e0..af704850df9 100644 --- a/libs/sdk-backend-base/package.json +++ b/libs/sdk-backend-base/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-backend-base", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData.UI SDK - Base for backend implementations", "license": "MIT", "author": "GoodData", @@ -56,10 +56,10 @@ "@gooddata/eslint-config": "workspace:*", "@gooddata/oxlint-config": "workspace:*", "@gooddata/reference-workspace": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@types/lodash-es": "^4.17.12", - "@types/spark-md5": "^3.0.1", + "@types/spark-md5": "3.0.5", "@typescript-eslint/eslint-plugin": "8.58.0", "@typescript-eslint/parser": "8.58.0", "@typescript/native-preview": "7.0.0-dev.20260202.1", diff --git a/libs/sdk-backend-base/src/customBackend/workspace.ts b/libs/sdk-backend-base/src/customBackend/workspace.ts index 4d4ad44db3f..dd10c6555de 100644 --- a/libs/sdk-backend-base/src/customBackend/workspace.ts +++ b/libs/sdk-backend-base/src/customBackend/workspace.ts @@ -27,6 +27,7 @@ import { type IWorkspaceObjectPermissionsService, type IWorkspaceParametersService, type IWorkspacePermissionsService, + type IWorkspaceReportsService, type IWorkspaceSettingsService, type IWorkspaceStylingService, type IWorkspaceUserGroupsQuery, @@ -180,4 +181,8 @@ export class CustomWorkspace implements IAnalyticalWorkspace { public exportTemplates(): IWorkspaceExportTemplatesService { throw new NotSupported("export templates are not supported"); } + + public reports(): IWorkspaceReportsService { + throw new NotSupported("reports are not supported"); + } } diff --git a/libs/sdk-backend-base/src/decoratedBackend/analyticalWorkspace.ts b/libs/sdk-backend-base/src/decoratedBackend/analyticalWorkspace.ts index 2f6c7e6071d..f4bc36b8134 100644 --- a/libs/sdk-backend-base/src/decoratedBackend/analyticalWorkspace.ts +++ b/libs/sdk-backend-base/src/decoratedBackend/analyticalWorkspace.ts @@ -28,6 +28,7 @@ import { type IWorkspaceObjectPermissionsService, type IWorkspaceParametersService, type IWorkspacePermissionsService, + type IWorkspaceReportsService, type IWorkspaceSettingsService, type IWorkspaceStylingService, type IWorkspaceUserGroupsQuery, @@ -236,4 +237,14 @@ export class AnalyticalWorkspaceDecorator implements IAnalyticalWorkspace { return this.decorated.exportTemplates(); } + + public reports(): IWorkspaceReportsService { + const { workspaceReports } = this.factories; + + if (workspaceReports) { + return workspaceReports(this.decorated.reports(), this.workspace); + } + + return this.decorated.reports(); + } } diff --git a/libs/sdk-backend-base/src/decoratedBackend/types.ts b/libs/sdk-backend-base/src/decoratedBackend/types.ts index 7041e1ce5ce..133335827aa 100644 --- a/libs/sdk-backend-base/src/decoratedBackend/types.ts +++ b/libs/sdk-backend-base/src/decoratedBackend/types.ts @@ -14,6 +14,7 @@ import { type IWorkspaceFactsService, type IWorkspaceInsightsService, type IWorkspaceMeasuresService, + type IWorkspaceReportsService, type IWorkspaceSettingsService, } from "@gooddata/sdk-backend-spi"; @@ -119,6 +120,14 @@ export type WorkspaceExportTemplatesDecoratorFactory = ( workspace: string, ) => IWorkspaceExportTemplatesService; +/** + * @alpha + */ +export type WorkspaceReportsDecoratorFactory = ( + reports: IWorkspaceReportsService, + workspace: string, +) => IWorkspaceReportsService; + /** * Provides factory functions for the different decorators (currently only supports execution * decorator). Input to each factory function is the original implementation from the wrapped backend, output @@ -141,4 +150,5 @@ export type DecoratorFactories = { geo?: GeoDecoratorFactory; organizationExportTemplates?: OrganizationExportTemplatesDecoratorFactory; workspaceExportTemplates?: WorkspaceExportTemplatesDecoratorFactory; + workspaceReports?: WorkspaceReportsDecoratorFactory; }; diff --git a/libs/sdk-backend-base/src/decoratedBackend/workspaceReports.ts b/libs/sdk-backend-base/src/decoratedBackend/workspaceReports.ts new file mode 100644 index 00000000000..f8057e45d59 --- /dev/null +++ b/libs/sdk-backend-base/src/decoratedBackend/workspaceReports.ts @@ -0,0 +1,82 @@ +// (C) 2026 GoodData Corporation + +import { type IWorkspaceReportsService } from "@gooddata/sdk-backend-spi"; +import { + type IReport, + type IReportDefinition, + type IReportPageLayout, + type IReportPageLayoutDefinition, + type IReportTemplate, + type IReportTemplateDefinition, + type ObjRef, +} from "@gooddata/sdk-model"; + +/** + * Base class for report-service decorators. Delegates every method of the decorated + * `IWorkspaceReportsService`; subclasses override the methods they customize. + * + * @alpha + */ +export abstract class DecoratedWorkspaceReportsService implements IWorkspaceReportsService { + protected constructor(protected readonly decorated: IWorkspaceReportsService) {} + + public getReportPageLayouts(): Promise { + return this.decorated.getReportPageLayouts(); + } + + public getReportPageLayout(ref: ObjRef): Promise { + return this.decorated.getReportPageLayout(ref); + } + + public createReportPageLayout(page: IReportPageLayoutDefinition): Promise { + return this.decorated.createReportPageLayout(page); + } + + public updateReportPageLayout(page: IReportPageLayout): Promise { + return this.decorated.updateReportPageLayout(page); + } + + public deleteReportPageLayout(ref: ObjRef): Promise { + return this.decorated.deleteReportPageLayout(ref); + } + + public getReportTemplates(): Promise { + return this.decorated.getReportTemplates(); + } + + public getReportTemplate(ref: ObjRef): Promise { + return this.decorated.getReportTemplate(ref); + } + + public createReportTemplate(template: IReportTemplateDefinition): Promise { + return this.decorated.createReportTemplate(template); + } + + public updateReportTemplate(template: IReportTemplate): Promise { + return this.decorated.updateReportTemplate(template); + } + + public deleteReportTemplate(ref: ObjRef): Promise { + return this.decorated.deleteReportTemplate(ref); + } + + public getReports(): Promise { + return this.decorated.getReports(); + } + + public getReport(ref: ObjRef): Promise { + return this.decorated.getReport(ref); + } + + public createReport(report: IReportDefinition): Promise { + return this.decorated.createReport(report); + } + + public updateReport(report: IReport): Promise { + return this.decorated.updateReport(report); + } + + public deleteReport(ref: ObjRef): Promise { + return this.decorated.deleteReport(ref); + } +} diff --git a/libs/sdk-backend-base/src/dummyBackend/InMemoryWorkspaceReportsService.test.ts b/libs/sdk-backend-base/src/dummyBackend/InMemoryWorkspaceReportsService.test.ts new file mode 100644 index 00000000000..0b1a8dd581f --- /dev/null +++ b/libs/sdk-backend-base/src/dummyBackend/InMemoryWorkspaceReportsService.test.ts @@ -0,0 +1,276 @@ +// (C) 2026 GoodData Corporation + +import { describe, expect, it } from "vitest"; + +import { BuiltInReportPageLayouts, idRef, newAdHocReportDefinition } from "@gooddata/sdk-model"; + +import { InMemoryWorkspaceReportsService } from "./InMemoryWorkspaceReportsService.js"; + +describe("InMemoryWorkspaceReportsService", () => { + it("serves built-in pages", async () => { + const service = new InMemoryWorkspaceReportsService(); + + const pages = await service.getReportPageLayouts(); + + expect(pages).toHaveLength(BuiltInReportPageLayouts.length); + expect(pages.every((page) => page.isBuiltIn)).toBe(true); + await expect(service.getReportPageLayout(BuiltInReportPageLayouts[0]!.ref)).resolves.toBe( + BuiltInReportPageLayouts[0], + ); + }); + + it("rejects update and delete of built-in pages", async () => { + const service = new InMemoryWorkspaceReportsService(); + const builtIn = BuiltInReportPageLayouts[0]!; + + expect(() => service.deleteReportPageLayout(builtIn.ref)).toThrow(/built-in/); + expect(() => service.updateReportPageLayout(builtIn)).toThrow(/built-in/); + }); + + it("round-trips a custom page", async () => { + const service = new InMemoryWorkspaceReportsService(); + + const created = await service.createReportPageLayout({ + type: "reportPageLayout", + title: "Custom", + content: { version: "1", layout: { type: "slotRef", slotId: "a" }, slots: [] }, + }); + expect(created.ref).toBeDefined(); + + const pages = await service.getReportPageLayouts(); + expect(pages).toHaveLength(BuiltInReportPageLayouts.length + 1); + + await service.deleteReportPageLayout(created.ref); + await expect(service.getReportPageLayouts()).resolves.toHaveLength(BuiltInReportPageLayouts.length); + }); + + it("round-trips templates and reports", async () => { + const service = new InMemoryWorkspaceReportsService(); + + const template = await service.createReportTemplate({ + type: "reportTemplate", + title: "Template", + content: { version: "1", pages: [] }, + }); + const report = await service.createReport( + newAdHocReportDefinition({ + title: "Report", + periodStart: "2026-01-01", + periodEnd: "2026-03-31", + }), + ); + + await expect(service.getReportTemplate(template.ref)).resolves.toEqual(template); + await expect(service.getReport(report.ref)).resolves.toEqual(report); + + await service.deleteReportTemplate(template.ref); + await service.deleteReport(report.ref); + await expect(service.getReportTemplates()).resolves.toEqual([]); + await expect(service.getReports()).resolves.toEqual([]); + }); + + it("resolves typed and untyped identifier refs to the same object", async () => { + const service = new InMemoryWorkspaceReportsService(); + + const created = await service.createReport( + newAdHocReportDefinition({ + title: "Report", + periodStart: "2026-01-01", + periodEnd: "2026-03-31", + }), + ); + const untypedRef = idRef((created.ref as { identifier: string }).identifier); + + await expect(service.getReport(untypedRef)).resolves.toEqual(created); + await service.deleteReport(untypedRef); + expect(() => service.getReport(created.ref)).toThrow(/does not exist/); + }); + + it("rejects creating an object whose ref already exists", async () => { + const service = new InMemoryWorkspaceReportsService(); + + const definition = newAdHocReportDefinition({ + title: "Report", + periodStart: "2026-01-01", + periodEnd: "2026-03-31", + }); + const created = await service.createReport(definition); + + expect(() => service.createReport({ ...definition, ref: created.ref })).toThrow(/already exists/); + expect(() => + service.createReportPageLayout({ + type: "reportPageLayout", + title: "Clash", + ref: BuiltInReportPageLayouts[0]!.ref, + content: { version: "1", layout: { type: "slotRef", slotId: "a" }, slots: [] }, + }), + ).toThrow(/built-in/); + }); + + it("detaches stored state from caller-held objects", async () => { + const service = new InMemoryWorkspaceReportsService(); + + const definition = newAdHocReportDefinition({ + title: "Report", + periodStart: "2026-01-01", + periodEnd: "2026-03-31", + }); + const created = await service.createReport(definition); + + created.content.pages.push({ + localIdentifier: "mutated", + layout: { type: "slotRef", slotId: "a" }, + slots: [], + }); + + const fetched = await service.getReport(created.ref); + expect(fetched.content.pages).toEqual([]); + }); + + it("rehydrates state from the persistence hook and saves after mutations", async () => { + let stored: string | null = null; + const persistence = { + load: () => stored, + save: (value: string) => { + stored = value; + }, + }; + + const service = new InMemoryWorkspaceReportsService(persistence); + const created = await service.createReport( + newAdHocReportDefinition({ + title: "Persisted", + periodStart: "2026-01-01", + periodEnd: "2026-03-31", + }), + ); + expect(stored).not.toBeNull(); + + const rehydrated = new InMemoryWorkspaceReportsService(persistence); + await expect(rehydrated.getReport(created.ref)).resolves.toEqual(created); + + await rehydrated.deleteReport(created.ref); + const afterDelete = new InMemoryWorkspaceReportsService(persistence); + expect(() => afterDelete.getReport(created.ref)).toThrow(/does not exist/); + }); + + it("reads persisted state through on every operation", async () => { + let stored: string | null = null; + const persistence = { + load: () => stored, + save: (value: string) => { + stored = value; + }, + }; + + const first = new InMemoryWorkspaceReportsService(persistence); + const second = new InMemoryWorkspaceReportsService(persistence); + + const fromFirst = await first.createReport( + newAdHocReportDefinition({ title: "A", periodStart: "2026-01-01", periodEnd: "2026-01-31" }), + ); + const fromSecond = await second.createReport( + newAdHocReportDefinition({ title: "B", periodStart: "2026-01-01", periodEnd: "2026-01-31" }), + ); + + const titles = (await first.getReports()).map((report) => report.title); + expect(titles).toEqual(expect.arrayContaining(["A", "B"])); + expect(fromFirst.ref).not.toEqual(fromSecond.ref); + }); + + it("keeps working when the storage throws", async () => { + const service = new InMemoryWorkspaceReportsService({ + load: () => { + throw new Error("storage blocked"); + }, + save: () => { + throw new Error("quota exceeded"); + }, + }); + + const created = await service.createReport( + newAdHocReportDefinition({ + title: "Report", + periodStart: "2026-01-01", + periodEnd: "2026-01-31", + }), + ); + await expect(service.getReport(created.ref)).resolves.toEqual(created); + }); + + it("keeps mutations after a save failure instead of losing them to read-through", async () => { + const service = new InMemoryWorkspaceReportsService({ + load: () => null, + save: () => { + throw new Error("quota exceeded"); + }, + }); + + const created = await service.createReport( + newAdHocReportDefinition({ + title: "Report", + periodStart: "2026-01-01", + periodEnd: "2026-01-31", + }), + ); + + await expect(service.getReport(created.ref)).resolves.toEqual(created); + await expect(service.getReports()).resolves.toHaveLength(1); + }); + + it("ignores persisted state of an unexpected shape", async () => { + const service = new InMemoryWorkspaceReportsService({ + load: () => JSON.stringify({ pageLayouts: {}, reports: 42 }), + save: () => {}, + }); + + await expect(service.getReports()).resolves.toEqual([]); + await expect(service.getReportPageLayouts()).resolves.toHaveLength(BuiltInReportPageLayouts.length); + }); + + it("keeps current state when a persisted entry is malformed", async () => { + let stored: string | null = null; + const persistence = { + load: () => stored, + save: (value: string) => { + stored = value; + }, + }; + const service = new InMemoryWorkspaceReportsService(persistence); + const created = await service.createReport( + newAdHocReportDefinition({ + title: "Report", + periodStart: "2026-01-01", + periodEnd: "2026-01-31", + }), + ); + + stored = JSON.stringify({ pageLayouts: [], templates: [], reports: [{}] }); + + await expect(service.getReport(created.ref)).resolves.toEqual(created); + }); + + it("starts empty on corrupted persisted state", () => { + const service = new InMemoryWorkspaceReportsService({ + load: () => "not-json{", + save: () => {}, + }); + expect(() => service.getReport(idRef("missing"))).toThrow(/does not exist/); + }); + + it("throws on unknown refs and locked objects", async () => { + const service = new InMemoryWorkspaceReportsService(); + + expect(() => service.getReport(idRef("missing"))).toThrow(/does not exist/); + + const created = await service.createReport( + newAdHocReportDefinition({ + title: "Locked", + periodStart: "2026-01-01", + periodEnd: "2026-01-31", + }), + ); + await service.updateReport({ ...created, isLocked: true }); + expect(() => service.deleteReport(created.ref)).toThrow(/locked/); + }); +}); diff --git a/libs/sdk-backend-base/src/dummyBackend/InMemoryWorkspaceReportsService.ts b/libs/sdk-backend-base/src/dummyBackend/InMemoryWorkspaceReportsService.ts new file mode 100644 index 00000000000..55d22148b25 --- /dev/null +++ b/libs/sdk-backend-base/src/dummyBackend/InMemoryWorkspaceReportsService.ts @@ -0,0 +1,321 @@ +// (C) 2026 GoodData Corporation + +import { type IWorkspaceReportsService, UnexpectedError } from "@gooddata/sdk-backend-spi"; +import { + BuiltInReportPageLayouts, + type IReport, + type IReportDefinition, + type IReportPageLayout, + type IReportPageLayoutDefinition, + type IReportTemplate, + type IReportTemplateDefinition, + type ObjRef, + areObjRefsEqual, + idRef, + isIdentifierRef, + isObjRef, + objRefToString, +} from "@gooddata/sdk-model"; + +// Key ignores the identifier ref's optional object type (typed and untyped identifier +// refs are equivalent under areObjRefsEqual) but keeps the ref kind, so an identifier +// and a URI with the same string stay distinct. +function refKey(ref: ObjRef): string { + return isIdentifierRef(ref) ? `id:${ref.identifier}` : `uri:${ref.uri}`; +} + +// JSON round-trip detaches stored state from caller-held objects on both the +// write and the read side, the way a real backend's serialization does. +function deepClone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function decode(value: T[] | undefined): Map { + const decoded = new Map(); + if (!Array.isArray(value)) { + return decoded; + } + value.forEach((entry) => { + if (!isObjRef(entry?.ref)) { + throw new Error("Persisted report entry has no object reference."); + } + decoded.set(refKey(entry.ref), entry); + }); + return decoded; +} + +function replace(target: Map, source: Map): void { + target.clear(); + source.forEach((value, key) => target.set(key, value)); +} + +/** + * Storage hook for {@link InMemoryWorkspaceReportsService}: where the serialized service + * state is loaded from on construction and saved to after every mutation. + * + * @alpha + */ +export interface IWorkspaceReportsPersistence { + load(): string | null; + save(value: string): void; +} + +interface IPersistedReportsState { + pageLayouts: IReportPageLayout[]; + templates: IReportTemplate[]; + reports: IReport[]; + sequence: number; +} + +/** + * In-memory implementation of the reports service. + * + * @remarks + * Serves the built-in report page layouts and stores created layouts, templates and reports in memory. + * Without a persistence hook the state lives only as long as the instance (dummy backend, tests); + * with one (e.g. localStorage) the state is rehydrated on construction and saved after every mutation. + * + * @alpha + */ +export class InMemoryWorkspaceReportsService implements IWorkspaceReportsService { + private readonly pageLayouts = new Map(); + private readonly templates = new Map(); + private readonly reports = new Map(); + private sequence = 0; + // Set when the storage rejected a write. Read-through would then restore the older + // persisted snapshot and silently discard the mutation, so the in-memory state + // becomes authoritative from that point on. + private persistenceDisabled = false; + + constructor(private readonly persistence?: IWorkspaceReportsPersistence) { + this.hydrate(); + } + + // Persisted storage is read through on every operation, so no instance can act on a + // stale snapshot and overwrite state written by another instance, tab or process. + // Storage failures (sandboxed embeds, exhausted quota, corrupted value) degrade to + // the in-memory state rather than propagating to the caller. + private hydrate(): void { + if (!this.persistence || this.persistenceDisabled) { + return; + } + try { + const serialized = this.persistence.load(); + if (!serialized) { + return; + } + const state = JSON.parse(serialized) as IPersistedReportsState; + // Decode into fresh maps first: a malformed entry must not leave the service + // with half-cleared state, so the swap happens only once everything decoded. + const pageLayouts = decode(state?.pageLayouts); + const templates = decode(state?.templates); + const reports = decode(state?.reports); + + replace(this.pageLayouts, pageLayouts); + replace(this.templates, templates); + replace(this.reports, reports); + this.sequence = Math.max(this.sequence, typeof state?.sequence === "number" ? state.sequence : 0); + } catch { + // unreadable, malformed or schema-incompatible state — keep the in-memory state + } + } + + private persist(): void { + if (!this.persistence || this.persistenceDisabled) { + return; + } + const state: IPersistedReportsState = { + pageLayouts: [...this.pageLayouts.values()], + templates: [...this.templates.values()], + reports: [...this.reports.values()], + sequence: this.sequence, + }; + try { + this.persistence.save(JSON.stringify(state)); + } catch { + this.persistenceDisabled = true; + } + } + + private newRef( + type: "reportPageLayout" | "reportTemplate" | "report", + store: Map, + ): ObjRef { + let ref: ObjRef; + do { + this.sequence += 1; + ref = idRef(`${type}_${this.sequence}`, type); + } while (store.has(refKey(ref))); + return ref; + } + + private findBuiltInPageLayout(ref: ObjRef): IReportPageLayout | undefined { + return BuiltInReportPageLayouts.find((layout) => areObjRefsEqual(layout.ref, ref)); + } + + public getReportPageLayouts(): Promise { + this.hydrate(); + return Promise.resolve([...BuiltInReportPageLayouts, ...deepClone([...this.pageLayouts.values()])]); + } + + public getReportPageLayout(ref: ObjRef): Promise { + this.hydrate(); + const builtIn = this.findBuiltInPageLayout(ref); + if (builtIn) { + return Promise.resolve(builtIn); + } + const layout = this.pageLayouts.get(refKey(ref)); + if (!layout) { + throw new UnexpectedError(`Report page layout "${objRefToString(ref)}" does not exist.`); + } + return Promise.resolve(deepClone(layout)); + } + + public createReportPageLayout(layout: IReportPageLayoutDefinition): Promise { + this.hydrate(); + if (layout.ref && this.findBuiltInPageLayout(layout.ref)) { + throw new UnexpectedError( + `Report page layout "${objRefToString(layout.ref)}" is built-in and cannot be replaced.`, + ); + } + const created: IReportPageLayout = { + ...deepClone(layout), + ref: layout.ref ?? this.newRef("reportPageLayout", this.pageLayouts), + }; + this.store(this.pageLayouts, created, "Report page layout"); + this.persist(); + return Promise.resolve(deepClone(created)); + } + + public updateReportPageLayout(layout: IReportPageLayout): Promise { + this.hydrate(); + this.assertPageLayoutEditable(layout.ref); + const updated: IReportPageLayout = { ...deepClone(layout), isBuiltIn: undefined }; + this.pageLayouts.set(refKey(layout.ref), updated); + this.persist(); + return Promise.resolve(deepClone(updated)); + } + + public deleteReportPageLayout(ref: ObjRef): Promise { + this.hydrate(); + this.assertPageLayoutEditable(ref); + this.pageLayouts.delete(refKey(ref)); + this.persist(); + return Promise.resolve(); + } + + private assertPageLayoutEditable(ref: ObjRef): void { + if (this.findBuiltInPageLayout(ref)) { + throw new UnexpectedError( + `Report page layout "${objRefToString(ref)}" is built-in and cannot be changed.`, + ); + } + this.assertExistsAndUnlocked(this.pageLayouts, ref, "Report page layout"); + } + + public getReportTemplates(): Promise { + this.hydrate(); + return Promise.resolve(deepClone([...this.templates.values()])); + } + + public getReportTemplate(ref: ObjRef): Promise { + this.hydrate(); + const template = this.templates.get(refKey(ref)); + if (!template) { + throw new UnexpectedError(`Report template "${objRefToString(ref)}" does not exist.`); + } + return Promise.resolve(deepClone(template)); + } + + public createReportTemplate(template: IReportTemplateDefinition): Promise { + this.hydrate(); + const created: IReportTemplate = { + ...deepClone(template), + ref: template.ref ?? this.newRef("reportTemplate", this.templates), + }; + this.store(this.templates, created, "Report template"); + this.persist(); + return Promise.resolve(deepClone(created)); + } + + public updateReportTemplate(template: IReportTemplate): Promise { + this.hydrate(); + this.assertExistsAndUnlocked(this.templates, template.ref, "Report template"); + const updated = deepClone(template); + this.templates.set(refKey(template.ref), updated); + this.persist(); + return Promise.resolve(deepClone(updated)); + } + + public deleteReportTemplate(ref: ObjRef): Promise { + this.hydrate(); + this.assertExistsAndUnlocked(this.templates, ref, "Report template"); + this.templates.delete(refKey(ref)); + this.persist(); + return Promise.resolve(); + } + + public getReports(): Promise { + this.hydrate(); + return Promise.resolve(deepClone([...this.reports.values()])); + } + + public getReport(ref: ObjRef): Promise { + this.hydrate(); + const report = this.reports.get(refKey(ref)); + if (!report) { + throw new UnexpectedError(`Report "${objRefToString(ref)}" does not exist.`); + } + return Promise.resolve(deepClone(report)); + } + + public createReport(report: IReportDefinition): Promise { + this.hydrate(); + const created: IReport = { + ...deepClone(report), + ref: report.ref ?? this.newRef("report", this.reports), + }; + this.store(this.reports, created, "Report"); + this.persist(); + return Promise.resolve(deepClone(created)); + } + + public updateReport(report: IReport): Promise { + this.hydrate(); + this.assertExistsAndUnlocked(this.reports, report.ref, "Report"); + const updated = deepClone(report); + this.reports.set(refKey(report.ref), updated); + this.persist(); + return Promise.resolve(deepClone(updated)); + } + + public deleteReport(ref: ObjRef): Promise { + this.hydrate(); + this.assertExistsAndUnlocked(this.reports, ref, "Report"); + this.reports.delete(refKey(ref)); + this.persist(); + return Promise.resolve(); + } + + private store(store: Map, object: T, what: string): void { + const key = refKey(object.ref); + if (store.has(key)) { + throw new UnexpectedError(`${what} "${objRefToString(object.ref)}" already exists.`); + } + store.set(key, object); + } + + private assertExistsAndUnlocked( + store: Map, + ref: ObjRef, + what: string, + ): void { + const existing = store.get(refKey(ref)); + if (!existing) { + throw new UnexpectedError(`${what} "${objRefToString(ref)}" does not exist.`); + } + if (existing.isLocked) { + throw new UnexpectedError(`${what} "${objRefToString(ref)}" is locked.`); + } + } +} diff --git a/libs/sdk-backend-base/src/dummyBackend/index.ts b/libs/sdk-backend-base/src/dummyBackend/index.ts index e524fa696c2..05c6844274b 100644 --- a/libs/sdk-backend-base/src/dummyBackend/index.ts +++ b/libs/sdk-backend-base/src/dummyBackend/index.ts @@ -130,6 +130,7 @@ import { type IWorkspaceObjectPermissionsService, type IWorkspaceParametersService, type IWorkspacePermissionsService, + type IWorkspaceReportsService, type IWorkspaceSettings, type IWorkspaceSettingsService, type IWorkspaceStylingService, @@ -236,6 +237,7 @@ import { DummyAnalyticsCatalogService } from "./DummyAnalyticsCatalogService.js" import { DummyChatConversations, DummyGenAIChatThread } from "./DummyGenAIChatThread.js"; import { DummySemanticQualityService } from "./DummySemanticQualityService.js"; import { DummySemanticSearchQueryBuilder } from "./DummySemanticSearch.js"; +import { InMemoryWorkspaceReportsService } from "./InMemoryWorkspaceReportsService.js"; /** * @internal @@ -276,6 +278,7 @@ export const defaultDummyBackendConfig: DummyBackendConfig = { * @internal */ export function dummyBackend(config: DummyBackendConfig = defaultDummyBackendConfig): IAnalyticalBackend { + const reportsServices = new Map(); const noopBackend: IAnalyticalBackend = { capabilities: { canCalculateTotals: true, @@ -310,7 +313,12 @@ export function dummyBackend(config: DummyBackendConfig = defaultDummyBackendCon throw new NotSupported("not supported"); }, workspace(id: string): IAnalyticalWorkspace { - return dummyWorkspace(id, config); + let reportsService = reportsServices.get(id); + if (!reportsService) { + reportsService = new InMemoryWorkspaceReportsService(); + reportsServices.set(id, reportsService); + } + return dummyWorkspace(id, config, reportsService); }, entitlements(): IEntitlements { throw new NotSupported("not supported"); @@ -455,7 +463,11 @@ export function dummyDataView( // Internals // -function dummyWorkspace(workspace: string, config: DummyBackendConfig): IAnalyticalWorkspace { +function dummyWorkspace( + workspace: string, + config: DummyBackendConfig, + reportsService: InMemoryWorkspaceReportsService, +): IAnalyticalWorkspace { return { workspace, async getDescriptor(): Promise { @@ -593,6 +605,9 @@ function dummyWorkspace(workspace: string, config: DummyBackendConfig): IAnalyti deleteExportTemplate: () => Promise.resolve(), }; }, + reports(): IWorkspaceReportsService { + return reportsService; + }, }; } diff --git a/libs/sdk-backend-base/src/index.ts b/libs/sdk-backend-base/src/index.ts index c5f313a8c8a..ea51f2141d2 100644 --- a/libs/sdk-backend-base/src/index.ts +++ b/libs/sdk-backend-base/src/index.ts @@ -40,6 +40,7 @@ export type { GeoDecoratorFactory, OrganizationExportTemplatesDecoratorFactory, WorkspaceExportTemplatesDecoratorFactory, + WorkspaceReportsDecoratorFactory, } from "./decoratedBackend/types.js"; export { @@ -65,6 +66,13 @@ export { DecoratedOrganizationExportTemplatesService } from "./decoratedBackend/ export { DecoratedWorkspaceExportTemplatesService } from "./decoratedBackend/workspaceExportTemplates.js"; +export { DecoratedWorkspaceReportsService } from "./decoratedBackend/workspaceReports.js"; + +export { + type IWorkspaceReportsPersistence, + InMemoryWorkspaceReportsService, +} from "./dummyBackend/InMemoryWorkspaceReportsService.js"; + export { DecoratedWorkspaceSettingsService } from "./decoratedBackend/workspaceSettings.js"; export { type AnalyticalBackendCallbacks, withEventing } from "./eventingBackend/index.js"; diff --git a/libs/sdk-backend-mockingbird/package.json b/libs/sdk-backend-mockingbird/package.json index ccce91adf62..58510282e85 100644 --- a/libs/sdk-backend-mockingbird/package.json +++ b/libs/sdk-backend-mockingbird/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-backend-mockingbird", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "Mock GoodData Backend SPI implementation", "license": "MIT", "author": "GoodData", @@ -53,7 +53,7 @@ "@gooddata/eslint-config": "workspace:*", "@gooddata/oxlint-config": "workspace:*", "@gooddata/reference-workspace": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@types/lodash-es": "^4.17.12", "@typescript-eslint/eslint-plugin": "8.58.0", diff --git a/libs/sdk-backend-mockingbird/src/recordedBackend/index.ts b/libs/sdk-backend-mockingbird/src/recordedBackend/index.ts index 64115d5ab77..5daf895bd94 100644 --- a/libs/sdk-backend-mockingbird/src/recordedBackend/index.ts +++ b/libs/sdk-backend-mockingbird/src/recordedBackend/index.ts @@ -58,6 +58,7 @@ import { type IWorkspaceObjectPermissionsService, type IWorkspaceParametersService, type IWorkspacePermissionsService, + type IWorkspaceReportsService, type IWorkspaceSettings, type IWorkspaceSettingsService, type IWorkspaceStylingService, @@ -65,9 +66,11 @@ import { type IWorkspaceUsersQuery, type IWorkspacesQueryFactory, NotSupported, + UnexpectedResponseError, type ValidationContext, } from "@gooddata/sdk-backend-spi"; import { + BuiltInReportPageLayouts, type IColorPalette, type IColorPaletteDefinition, type IColorPaletteMetadataObject, @@ -83,6 +86,7 @@ import { type IUser, type IWorkspacePermissions, type ObjRef, + areObjRefsEqual, idRef, isIdentifierRef, } from "@gooddata/sdk-model"; @@ -561,6 +565,51 @@ function recordedWorkspace( deleteExportTemplate: () => Promise.resolve(), }; }, + reports(): IWorkspaceReportsService { + return { + getReportPageLayouts: () => Promise.resolve([...BuiltInReportPageLayouts]), + getReportPageLayout: (ref) => { + const page = BuiltInReportPageLayouts.find((candidate) => + areObjRefsEqual(candidate.ref, ref), + ); + if (!page) { + throw new UnexpectedResponseError("Report page not found", 404, {}); + } + return Promise.resolve(page); + }, + createReportPageLayout: (page) => + Promise.resolve({ ...page, ref: idRef("recordedReportPage") }), + updateReportPageLayout: (page) => { + if ( + BuiltInReportPageLayouts.some((candidate) => areObjRefsEqual(candidate.ref, page.ref)) + ) { + throw new NotSupported("Built-in report page layouts cannot be updated."); + } + return Promise.resolve(page); + }, + deleteReportPageLayout: (ref) => { + if (BuiltInReportPageLayouts.some((candidate) => areObjRefsEqual(candidate.ref, ref))) { + throw new NotSupported("Built-in report page layouts cannot be deleted."); + } + return Promise.resolve(); + }, + getReportTemplates: () => Promise.resolve([]), + getReportTemplate: () => { + throw new UnexpectedResponseError("Report template not found", 404, {}); + }, + createReportTemplate: (template) => + Promise.resolve({ ...template, ref: idRef("recordedReportTemplate") }), + updateReportTemplate: (template) => Promise.resolve(template), + deleteReportTemplate: () => Promise.resolve(), + getReports: () => Promise.resolve([]), + getReport: () => { + throw new UnexpectedResponseError("Report not found", 404, {}); + }, + createReport: (report) => Promise.resolve({ ...report, ref: idRef("recordedReport") }), + updateReport: (report) => Promise.resolve(report), + deleteReport: () => Promise.resolve(), + }; + }, }; } diff --git a/libs/sdk-backend-spi/api/sdk-backend-spi.api.md b/libs/sdk-backend-spi/api/sdk-backend-spi.api.md index b2b422321b4..0331b5932a5 100644 --- a/libs/sdk-backend-spi/api/sdk-backend-spi.api.md +++ b/libs/sdk-backend-spi/api/sdk-backend-spi.api.md @@ -139,6 +139,12 @@ import { IOrganizationUserGroup } from '@gooddata/sdk-model'; import { IParameterMetadataObject } from '@gooddata/sdk-model'; import { IParameterMetadataObjectDefinition } from '@gooddata/sdk-model'; import { IRelativeDateFilter } from '@gooddata/sdk-model'; +import { IReport } from '@gooddata/sdk-model'; +import { IReportDefinition } from '@gooddata/sdk-model'; +import { IReportPageLayout } from '@gooddata/sdk-model'; +import { IReportPageLayoutDefinition } from '@gooddata/sdk-model'; +import { IReportTemplate } from '@gooddata/sdk-model'; +import { IReportTemplateDefinition } from '@gooddata/sdk-model'; import { IResultHeader } from '@gooddata/sdk-model'; import { IResultWarning } from '@gooddata/sdk-model'; import type { IScheduledMail } from '@gooddata/sdk-model'; @@ -404,6 +410,8 @@ export interface IAnalyticalWorkspace { parameters(): IWorkspaceParametersService; permissions(): IWorkspacePermissionsService; references(): IReferencesService; + // @alpha + reports(): IWorkspaceReportsService; settings(): IWorkspaceSettingsService; styling(): IWorkspaceStylingService; updateDescriptor(descriptor: IWorkspaceDescriptorUpdate): Promise; @@ -1100,6 +1108,14 @@ export interface ICommentExpressionToken { value: string; } +// @public +export interface IComputedAttributeReferencing { + // (undocumented) + insights?: IInsight[]; + // (undocumented) + measures?: IMetadataObject[]; +} + // @public export interface IComputedAttributesQuery { query(): Promise; @@ -2911,6 +2927,7 @@ export interface IWorkspaceComputedAttributesService { deleteComputedAttribute(ref: ObjRef): Promise; getComputedAttribute(ref: ObjRef, options?: IGetComputedAttributeOptions): Promise; getComputedAttributeExpressionTokens(ref: ObjRef): Promise; + getComputedAttributeReferencingObjects(ref: ObjRef): Promise; getComputedAttributesQuery(): IComputedAttributesQuery; updateComputedAttribute(computedAttribute: IComputedAttributeMetadataObject): Promise; updateComputedAttributeMeta(computedAttribute: Partial & IMetadataObjectIdentity): Promise; @@ -3117,6 +3134,25 @@ export interface IWorkspacePermissionsService { getPermissionsForCurrentUser(): Promise; } +// @alpha +export interface IWorkspaceReportsService { + createReport(report: IReportDefinition): Promise; + createReportPageLayout(page: IReportPageLayoutDefinition): Promise; + createReportTemplate(template: IReportTemplateDefinition): Promise; + deleteReport(ref: ObjRef): Promise; + deleteReportPageLayout(ref: ObjRef): Promise; + deleteReportTemplate(ref: ObjRef): Promise; + getReport(ref: ObjRef): Promise; + getReportPageLayout(ref: ObjRef): Promise; + getReportPageLayouts(): Promise; + getReports(): Promise; + getReportTemplate(ref: ObjRef): Promise; + getReportTemplates(): Promise; + updateReport(report: IReport): Promise; + updateReportPageLayout(page: IReportPageLayout): Promise; + updateReportTemplate(template: IReportTemplate): Promise; +} + export { IWorkspaceSettings } // @public diff --git a/libs/sdk-backend-spi/package.json b/libs/sdk-backend-spi/package.json index 9ce69e70968..759bf9ac969 100644 --- a/libs/sdk-backend-spi/package.json +++ b/libs/sdk-backend-spi/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-backend-spi", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData Backend SPI abstraction interfaces", "license": "MIT", "author": "GoodData", @@ -48,7 +48,7 @@ "devDependencies": { "@gooddata/eslint-config": "workspace:*", "@gooddata/oxlint-config": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@types/lodash-es": "^4.17.12", "@typescript-eslint/eslint-plugin": "8.58.0", diff --git a/libs/sdk-backend-spi/src/index.ts b/libs/sdk-backend-spi/src/index.ts index 959df011e6e..71ffb316028 100644 --- a/libs/sdk-backend-spi/src/index.ts +++ b/libs/sdk-backend-spi/src/index.ts @@ -99,6 +99,7 @@ export type { } from "./workspace/exportDefinitions/index.js"; export type { IWorkspaceExportTemplatesService } from "./workspace/exportTemplates/index.js"; +export type { IWorkspaceReportsService } from "./workspace/reports/index.js"; export { type IElementsQueryFactory, @@ -319,6 +320,7 @@ export type { IGetComputedAttributeOptions, IComputedAttributesQuery, IComputedAttributesQueryResult, + IComputedAttributeReferencing, } from "./workspace/computedAttributes/index.js"; export type { diff --git a/libs/sdk-backend-spi/src/workspace/computedAttributes/index.ts b/libs/sdk-backend-spi/src/workspace/computedAttributes/index.ts index c630dca6a07..e7fae3bccce 100644 --- a/libs/sdk-backend-spi/src/workspace/computedAttributes/index.ts +++ b/libs/sdk-backend-spi/src/workspace/computedAttributes/index.ts @@ -3,6 +3,8 @@ import type { IComputedAttributeMetadataObject, IComputedAttributeMetadataObjectDefinition, + IInsight, + IMetadataObject, IMetadataObjectBase, IMetadataObjectIdentity, ObjRef, @@ -14,6 +16,22 @@ import type { IPagedResource } from "../../common/paging.js"; import type { QueryMethod } from "../../common/query.js"; import type { IMeasureExpressionToken } from "../measures/measure.js"; +/** + * Contains information about objects that may be referencing a computed attribute. See + * {@link IWorkspaceComputedAttributesService.getComputedAttributeReferencingObjects}. + * + * @remarks + * Insights that group by the computed attribute are the referents that exist today. Measures are + * also reported; a metric cannot reference a computed attribute yet, so that list is empty until + * the backend supports it. + * + * @public + */ +export interface IComputedAttributeReferencing { + measures?: IMetadataObject[]; + insights?: IInsight[]; +} + /** * Options for getting a computed attribute. * @@ -95,6 +113,14 @@ export interface IWorkspaceComputedAttributesService { */ getComputedAttributeExpressionTokens(ref: ObjRef): Promise; + /** + * Get objects that reference the computed attribute. + * + * @param ref - ref of the computed attribute + * @returns promise of the referencing objects + */ + getComputedAttributeReferencingObjects(ref: ObjRef): Promise; + /** * Computed attributes query factory. * diff --git a/libs/sdk-backend-spi/src/workspace/index.ts b/libs/sdk-backend-spi/src/workspace/index.ts index 497b7018bc6..b5c7de4dc4e 100644 --- a/libs/sdk-backend-spi/src/workspace/index.ts +++ b/libs/sdk-backend-spi/src/workspace/index.ts @@ -26,6 +26,7 @@ import { type IWorkspaceObjectPermissionsService } from "./objectPermissions/ind import { type IWorkspaceParametersService } from "./parameters/index.js"; import { type IWorkspacePermissionsService } from "./permissions/index.js"; import { type IReferencesService } from "./references/index.js"; +import { type IWorkspaceReportsService } from "./reports/index.js"; import { type IWorkspaceSettingsService } from "./settings/index.js"; import { type IWorkspaceStylingService } from "./styling/index.js"; import { type IWorkspaceUserGroupsQuery } from "./userGroups/index.js"; @@ -217,6 +218,13 @@ export interface IAnalyticalWorkspace { * @beta */ exportTemplates(): IWorkspaceExportTemplatesService; + + /** + * Returns service that can be used to manage report page layouts, report templates and reports. + * + * @alpha + */ + reports(): IWorkspaceReportsService; } /** diff --git a/libs/sdk-backend-spi/src/workspace/reports/index.ts b/libs/sdk-backend-spi/src/workspace/reports/index.ts new file mode 100644 index 00000000000..14f1ead1632 --- /dev/null +++ b/libs/sdk-backend-spi/src/workspace/reports/index.ts @@ -0,0 +1,110 @@ +// (C) 2026 GoodData Corporation + +import { + type IReport, + type IReportDefinition, + type IReportPageLayout, + type IReportPageLayoutDefinition, + type IReportTemplate, + type IReportTemplateDefinition, + type ObjRef, +} from "@gooddata/sdk-model"; + +/** + * Service for managing report page layouts, report templates and reports. + * + * @remarks + * Report page layouts returned by this service include the built-in layouts shipped with the + * product ({@link @gooddata/sdk-model#BuiltInReportPageLayouts}). Built-ins carry + * `isBuiltIn: true`, are never persisted on the backend, and reject update and delete. + * + * @alpha + */ +export interface IWorkspaceReportsService { + // + // Report page layouts + // + + /** + * Get the list of report page layouts available in the workspace, built-in layouts first. + */ + getReportPageLayouts(): Promise; + + /** + * Get a single report page layout by its reference. + */ + getReportPageLayout(ref: ObjRef): Promise; + + /** + * Create a new report page layout. + */ + createReportPageLayout(page: IReportPageLayoutDefinition): Promise; + + /** + * Update an existing report page layout. Rejects built-in and locked layouts. + */ + updateReportPageLayout(page: IReportPageLayout): Promise; + + /** + * Delete an existing report page layout. Rejects built-in and locked layouts. + */ + deleteReportPageLayout(ref: ObjRef): Promise; + + // + // Report templates + // + + /** + * Get the list of report templates available in the workspace. + */ + getReportTemplates(): Promise; + + /** + * Get a single report template by its reference. + */ + getReportTemplate(ref: ObjRef): Promise; + + /** + * Create a new report template. + */ + createReportTemplate(template: IReportTemplateDefinition): Promise; + + /** + * Update an existing report template. Rejects locked templates. + */ + updateReportTemplate(template: IReportTemplate): Promise; + + /** + * Delete an existing report template. + */ + deleteReportTemplate(ref: ObjRef): Promise; + + // + // Reports + // + + /** + * Get the list of reports available in the workspace. + */ + getReports(): Promise; + + /** + * Get a single report by its reference. + */ + getReport(ref: ObjRef): Promise; + + /** + * Create a new report. + */ + createReport(report: IReportDefinition): Promise; + + /** + * Update an existing report. Rejects locked reports. + */ + updateReport(report: IReport): Promise; + + /** + * Delete an existing report. + */ + deleteReport(ref: ObjRef): Promise; +} diff --git a/libs/sdk-backend-tiger/api/sdk-backend-tiger.api.md b/libs/sdk-backend-tiger/api/sdk-backend-tiger.api.md index f54216390da..7851b2f5dda 100644 --- a/libs/sdk-backend-tiger/api/sdk-backend-tiger.api.md +++ b/libs/sdk-backend-tiger/api/sdk-backend-tiger.api.md @@ -440,6 +440,9 @@ export const objectTypeToTigerIdType: { memoryItem: TigerObjectType; notificationChannel: TigerObjectType; parameter: TigerObjectType; + report: TigerObjectType; + reportPageLayout: TigerObjectType; + reportTemplate: TigerObjectType; theme: TigerObjectType; user: TigerObjectType; userDataFilter: TigerObjectType; diff --git a/libs/sdk-backend-tiger/package.json b/libs/sdk-backend-tiger/package.json index d39f27fffa2..572f71397e1 100644 --- a/libs/sdk-backend-tiger/package.json +++ b/libs/sdk-backend-tiger/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-backend-tiger", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData Backend SPI implementation for GoodData Cloud and GoodData.CN", "license": "MIT", "author": "GoodData", @@ -77,10 +77,10 @@ "@gooddata/fixtures": "3.7.0", "@gooddata/oxlint-config": "workspace:*", "@gooddata/reference-workspace": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@types/lodash-es": "^4.17.12", - "@types/spark-md5": "^3.0.1", + "@types/spark-md5": "3.0.5", "@typescript-eslint/eslint-plugin": "8.58.0", "@typescript-eslint/parser": "8.58.0", "@typescript/native-preview": "7.0.0-dev.20260202.1", diff --git a/libs/sdk-backend-tiger/src/backend/workspace/computedAttributes/index.ts b/libs/sdk-backend-tiger/src/backend/workspace/computedAttributes/index.ts index 8855720b44f..7f93a37849c 100644 --- a/libs/sdk-backend-tiger/src/backend/workspace/computedAttributes/index.ts +++ b/libs/sdk-backend-tiger/src/backend/workspace/computedAttributes/index.ts @@ -1,13 +1,17 @@ // (C) 2026 GoodData Corporation +import { MetadataUtilities } from "@gooddata/api-client-tiger"; import { EntitiesApi_CreateEntityComputedAttributes, EntitiesApi_DeleteEntityComputedAttributes, + EntitiesApi_GetAllEntitiesMetrics, + EntitiesApi_GetAllEntitiesVisualizationObjects, EntitiesApi_GetEntityComputedAttributes, EntitiesApi_PatchEntityComputedAttributes, EntitiesApi_UpdateEntityComputedAttributes, } from "@gooddata/api-client-tiger/endpoints/entitiesObjects"; import type { + IComputedAttributeReferencing, IGetComputedAttributeOptions, IMeasureExpressionToken, IWorkspaceComputedAttributesService, @@ -22,6 +26,8 @@ import { } from "@gooddata/sdk-model"; import { convertComputedAttributeFromBackend } from "../../../convertors/fromBackend/ComputedAttributeConverter.js"; +import { visualizationObjectsItemToInsight } from "../../../convertors/fromBackend/InsightConverter.js"; +import { convertMetricFromBackend } from "../../../convertors/fromBackend/MetricConverter.js"; import { convertComputedAttributeToBackend } from "../../../convertors/toBackend/ComputedAttributeConverter.js"; import { type TigerAuthenticatedCallGuard } from "../../../types/index.js"; import { objRefToIdentifier } from "../../../utils/api.js"; @@ -165,6 +171,42 @@ export class TigerWorkspaceComputedAttributes implements IWorkspaceComputedAttri ); } + public async getComputedAttributeReferencingObjects(ref: ObjRef): Promise { + const id = objRefToIdentifier(ref, this.authCall); + + // A visualization grouping by a computed attribute is the referent that exists today. The + // backend allows the delete; the catalog refuses it and lists these titles instead. + const insights = this.authCall((client) => + MetadataUtilities.getAllPagesOf(client, EntitiesApi_GetAllEntitiesVisualizationObjects, { + workspaceId: this.workspace, + // return only visualizationObjects that have a link to the given computed attribute + filter: `computedAttributes.id==${id}`, + }) + .then(MetadataUtilities.mergeEntitiesResults) + .then((visualizationObjects) => + visualizationObjects.data.map((visualizationObject) => + visualizationObjectsItemToInsight(visualizationObject, visualizationObjects.included), + ), + ), + ); + + // Reports nothing until a metric can reference a computed attribute (pending backend support). + const measures = this.authCall((client) => + MetadataUtilities.getAllPagesOf(client, EntitiesApi_GetAllEntitiesMetrics, { + workspaceId: this.workspace, + include: ["computedAttributes"], + filter: `computedAttributes.id==${id}`, + }) + .then(MetadataUtilities.mergeEntitiesResults) + .then((metrics) => + metrics.data.map((metric) => convertMetricFromBackend(metric, metrics.included)), + ), + ); + + const [insightList, measureList] = await Promise.all([insights, measures]); + return { insights: insightList, measures: measureList }; + } + public getComputedAttributesQuery(): ComputedAttributesQuery { return new ComputedAttributesQuery(this.authCall, { workspaceId: this.workspace }); } diff --git a/libs/sdk-backend-tiger/src/backend/workspace/index.ts b/libs/sdk-backend-tiger/src/backend/workspace/index.ts index bcad70f1fcf..bc99f6e0876 100644 --- a/libs/sdk-backend-tiger/src/backend/workspace/index.ts +++ b/libs/sdk-backend-tiger/src/backend/workspace/index.ts @@ -33,6 +33,7 @@ import { type IWorkspaceObjectPermissionsService, type IWorkspaceParametersService, type IWorkspacePermissionsService, + type IWorkspaceReportsService, type IWorkspaceSettingsService, type IWorkspaceStylingService, type IWorkspaceUserGroupsQuery, @@ -74,6 +75,7 @@ import { TigerWorkspaceObjectPermissionsService } from "./objectPermissions/inde import { TigerWorkspaceParameters } from "./parameters/index.js"; import { TigerWorkspacePermissionsFactory } from "./permissions/index.js"; import { TigerReferencesService } from "./references/index.js"; +import { getTigerWorkspaceReportsService } from "./reports.js"; import { TigerWorkspaceSettings } from "./settings/index.js"; import { TigerWorkspaceStyling } from "./styling/index.js"; import { TigerWorkspaceUsersQuery } from "./users/index.js"; @@ -251,4 +253,8 @@ export class TigerWorkspace implements IAnalyticalWorkspace { public exportTemplates(): IWorkspaceExportTemplatesService { return new WorkspaceExportTemplatesService(this.authCall, this.workspace); } + + public reports(): IWorkspaceReportsService { + return getTigerWorkspaceReportsService(this.workspace); + } } diff --git a/libs/sdk-backend-tiger/src/backend/workspace/reports.ts b/libs/sdk-backend-tiger/src/backend/workspace/reports.ts new file mode 100644 index 00000000000..393833bec2e --- /dev/null +++ b/libs/sdk-backend-tiger/src/backend/workspace/reports.ts @@ -0,0 +1,53 @@ +// (C) 2026 GoodData Corporation + +import { + type IWorkspaceReportsPersistence, + InMemoryWorkspaceReportsService, +} from "@gooddata/sdk-backend-base"; + +function localStoragePersistence(workspace: string): IWorkspaceReportsPersistence | undefined { + // Reading the accessor itself throws a SecurityError in opaque-origin sandboxed + // frames and under storage-blocking privacy settings, so the probe is guarded too. + let storage: Storage; + try { + if (typeof localStorage === "undefined") { + return undefined; + } + storage = localStorage; + } catch { + return undefined; + } + + const key = `gd.reports.${workspace}`; + return { + load: () => storage.getItem(key), + save: (value) => storage.setItem(key, value), + }; +} + +/** + * Temporary implementation used until the backend entities (reportPageLayout, reportTemplate, + * report) exist: serves the built-in report page layouts and persists created objects to + * localStorage, scoped per workspace, so a UI built on the SPI works across page reloads. + * Falls back to plain in-memory state where localStorage is unavailable. + */ +export class TigerWorkspaceReportsService extends InMemoryWorkspaceReportsService { + constructor(workspace: string) { + super(localStoragePersistence(workspace)); + } +} + +// One service instance per workspace for the process lifetime. With localStorage the +// service reads persisted state through on every operation, so instances converge even +// across tabs; the cache only matters where no storage exists (node), and there the +// state is per-process scratch data for development, not tenant data. +const servicesByWorkspace = new Map(); + +export function getTigerWorkspaceReportsService(workspace: string): TigerWorkspaceReportsService { + let service = servicesByWorkspace.get(workspace); + if (!service) { + service = new TigerWorkspaceReportsService(workspace); + servicesByWorkspace.set(workspace, service); + } + return service; +} diff --git a/libs/sdk-backend-tiger/src/convertors/ExportDefinitionsConverter.toBackend.test.ts b/libs/sdk-backend-tiger/src/convertors/ExportDefinitionsConverter.toBackend.test.ts index d81bacd0116..4fe77400330 100644 --- a/libs/sdk-backend-tiger/src/convertors/ExportDefinitionsConverter.toBackend.test.ts +++ b/libs/sdk-backend-tiger/src/convertors/ExportDefinitionsConverter.toBackend.test.ts @@ -733,14 +733,6 @@ describe("ExportDefinitionsConverter toBackend", () => { expect(result.executionSettings?.timezone).toBe(TIMEZONE); }); - it("keeps the execution-config timezone over the stored override in raw export request", () => { - const result = convertToRawExportRequest( - { ...widgetRequest, format: "CSV_RAW" }, - { ...widgetExecution, executionConfig: { timezone: "America/New_York" } }, - ); - expect(result.executionSettings?.timezone).toBe("America/New_York"); - }); - it("sends the timezone in standalone export definition payloads", () => { const dashboardResult = convertExportDefinitionRequestPayload( dashboardRequest, diff --git a/libs/sdk-backend-tiger/src/convertors/toBackend/ExportDefinitionsConverter.ts b/libs/sdk-backend-tiger/src/convertors/toBackend/ExportDefinitionsConverter.ts index 64fd18c03f6..725d70ed4ec 100644 --- a/libs/sdk-backend-tiger/src/convertors/toBackend/ExportDefinitionsConverter.ts +++ b/libs/sdk-backend-tiger/src/convertors/toBackend/ExportDefinitionsConverter.ts @@ -139,8 +139,7 @@ export const convertToRawExportRequest = ( (value) => value === undefined || (Array.isArray(value) && value.length === 0), ); - // The execution-config timezone (already the effective dashboard timezone) wins over the stored override. - const timezone = settings.timezone ?? exportRequest.timezoneId; + const timezone = exportRequest.timezoneId; const settingsWithTimezone = { ...settings, ...(timezone ? { timezone } : {}) }; const executionSettings = isEmpty(settingsWithTimezone) ? undefined : settingsWithTimezone; const delimiter = exportRequest.settings?.delimiter; diff --git a/libs/sdk-code-convertors/package.json b/libs/sdk-code-convertors/package.json index 54b3a7544e6..233c8790194 100644 --- a/libs/sdk-code-convertors/package.json +++ b/libs/sdk-code-convertors/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-code-convertors", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData AAC declarative converters", "license": "MIT", "author": "GoodData", diff --git a/libs/sdk-code-convertors/python/pyproject.toml b/libs/sdk-code-convertors/python/pyproject.toml index 81a77f16e4e..8f1bcc919bc 100644 --- a/libs/sdk-code-convertors/python/pyproject.toml +++ b/libs/sdk-code-convertors/python/pyproject.toml @@ -2,7 +2,7 @@ [project] name = "gooddata-code-convertors" -version = "11.54.0a6" +version = "11.54.0a7" description = "GoodData AAC YAML / Declarative API code converters (WASM-powered)" readme = "README.md" license = { file = "LICENSE" } diff --git a/libs/sdk-code-schemas/package.json b/libs/sdk-code-schemas/package.json index 5317c3dc9d7..7a5a8f862ca 100644 --- a/libs/sdk-code-schemas/package.json +++ b/libs/sdk-code-schemas/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-code-schemas", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData AAC JSON Schema types and compiled schemas", "license": "MIT", "author": "GoodData", @@ -64,7 +64,7 @@ "eslint-plugin-jsdoc": "62.1.0", "eslint-plugin-no-barrel-files": "1.2.2", "eslint-plugin-sonarjs": "3.0.6", - "json-schema-to-typescript": "^13.0.1", + "json-schema-to-typescript": "13.1.2", "lodash-es": "^4.17.23", "npm-run-all": "^4.1.5", "oxfmt": "0.52.0", diff --git a/libs/sdk-e2e-utils/package.json b/libs/sdk-e2e-utils/package.json index 6baa197d414..970cf44af89 100644 --- a/libs/sdk-e2e-utils/package.json +++ b/libs/sdk-e2e-utils/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-e2e-utils", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData utility functions for Playwright E2E tests", "license": "MIT", "author": "GoodData", @@ -42,7 +42,7 @@ "devDependencies": { "@gooddata/eslint-config": "workspace:*", "@gooddata/oxlint-config": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@types/node": "24.12.0", "@typescript-eslint/eslint-plugin": "8.58.0", diff --git a/libs/sdk-embedding/package.json b/libs/sdk-embedding/package.json index f58929adf7a..75da08725a1 100644 --- a/libs/sdk-embedding/package.json +++ b/libs/sdk-embedding/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-embedding", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData Embedding APIs", "license": "MIT", "author": "GoodData", @@ -53,7 +53,7 @@ "devDependencies": { "@gooddata/eslint-config": "workspace:*", "@gooddata/oxlint-config": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@types/lodash-es": "^4.17.12", "@typescript-eslint/eslint-plugin": "8.58.0", diff --git a/libs/sdk-model/api/sdk-model.api.md b/libs/sdk-model/api/sdk-model.api.md index 938372e96a2..56d924f4461 100644 --- a/libs/sdk-model/api/sdk-model.api.md +++ b/libs/sdk-model/api/sdk-model.api.md @@ -320,6 +320,21 @@ export function bucketsTotals(buckets: IBucket[]): ITotal[]; // @public export function bucketTotals(bucket: IBucket): ITotal[]; +// @alpha +export const BuiltInReportPageLayoutCover: IReportPageLayout; + +// @alpha +export const BuiltInReportPageLayouts: readonly IReportPageLayout[]; + +// @alpha +export const BuiltInReportPageLayoutSection: IReportPageLayout; + +// @alpha +export const BuiltInReportPageLayoutViz6TextLeft: IReportPageLayout; + +// @alpha +export const BuiltInReportSlotTypes: string[]; + // @alpha export const BuiltInWidgetTypes: string[]; @@ -947,6 +962,9 @@ export function getParameterAllowedValueTitle(allowedValue: IParameterAllowedVal // @alpha export function getParameterValueTitle(definition: IParameterDefinition, value: ParameterValue): string; +// @alpha +export function getReportTextPlaceholders(text: string): string[]; + // @alpha export function getSelectedElementsCount(filter: IDashboardAttributeFilter): number; @@ -4766,6 +4784,207 @@ export interface IRemotePluggableApplicationsRegistryV1 { uiModule?: IRemotePluggableApplicationModule; } +// @alpha +export interface IReport extends IReportDefinition, IAuditableDates, IAuditableUsers { + isLocked?: boolean; + // (undocumented) + ref: ObjRef; +} + +// @alpha +export interface IReportAiTextSource { + content?: string; + generatedAt?: string; + prompt: string; + // (undocumented) + type: "ai"; +} + +// @alpha +export interface IReportBase { + content: IReportContent; + // (undocumented) + description?: string; + periodEnd: ReportDateString; + periodStart: ReportDateString; + // (undocumented) + tags?: string[]; + // (undocumented) + title: string; + variableValues?: Record; +} + +// @alpha +export interface IReportContent { + filters?: FilterContextItem[]; + pages: IReportContentPage[]; + variables?: IReportVariableDefinition[]; + version: "1"; +} + +// @alpha +export interface IReportContentPage extends IReportPageBody { + localIdentifier: string; +} + +// @alpha +export interface IReportDefinition extends IReportBase { + ref?: ObjRef; + // (undocumented) + type: "report"; +} + +// @alpha +export interface IReportImageSlot extends IReportSlotBase { + // (undocumented) + altText?: string; + fit?: "contain" | "cover" | "fill"; + source?: ReportImageSource; + // (undocumented) + type: "image"; +} + +// @alpha +export interface IReportLayoutNodeBase { + weight?: number; +} + +// @alpha +export interface IReportLayoutSection extends IReportLayoutNodeBase { + // (undocumented) + children: ReportPageLayoutNode[]; + direction: "row" | "column"; + // (undocumented) + type: "section"; +} + +// @alpha +export interface IReportLayoutSlotRef extends IReportLayoutNodeBase { + slotId: string; + // (undocumented) + type: "slotRef"; +} + +// @alpha +export interface IReportPageBody { + filters?: FilterContextItem[]; + kind?: "cover" | "section" | "content"; + layout: ReportPageLayoutNode; + slots: ReportSlot[]; +} + +// @alpha +export interface IReportPageBodyValidationIssue { + // (undocumented) + message: string; + // (undocumented) + severity: "error" | "warning"; +} + +// @alpha +export interface IReportPageLayout extends IReportPageLayoutDefinition, IAuditableDates, IAuditableUsers { + isBuiltIn?: boolean; + isLocked?: boolean; + // (undocumented) + ref: ObjRef; +} + +// @alpha +export interface IReportPageLayoutContent extends IReportPageBody { + version: "1"; +} + +// @alpha +export interface IReportPageLayoutDefinition { + // (undocumented) + content: IReportPageLayoutContent; + // (undocumented) + description?: string; + ref?: ObjRef; + // (undocumented) + tags?: string[]; + // (undocumented) + title: string; + // (undocumented) + type: "reportPageLayout"; +} + +// @alpha +export interface IReportSlotBase { + localIdentifier: string; + placeholder?: IReportSlotPlaceholder; + // (undocumented) + type: string; +} + +// @alpha +export interface IReportSlotPlaceholder { + hint?: string; + required?: boolean; +} + +// @alpha +export interface IReportStaticTextSource { + content: string; + // (undocumented) + type: "static"; +} + +// @alpha +export interface IReportTemplate extends IReportTemplateDefinition, IAuditableDates, IAuditableUsers { + isLocked?: boolean; + // (undocumented) + ref: ObjRef; +} + +// @alpha +export interface IReportTemplateDefinition { + // (undocumented) + content: IReportContent; + // (undocumented) + description?: string; + ref?: ObjRef; + // (undocumented) + tags?: string[]; + // (undocumented) + title: string; + // (undocumented) + type: "reportTemplate"; +} + +// @alpha +export interface IReportTextSlot extends IReportSlotBase { + // (undocumented) + kind: ReportTextSlotKind; + source?: ReportTextSource; + // (undocumented) + type: "text"; +} + +// @alpha +export interface IReportVariableDefinition { + defaultValue?: string; + // (undocumented) + description?: string; + name: string; + title?: string; +} + +// @alpha +export interface IReportVisualizationSlot extends IReportSlotBase { + dateDataSet?: ObjRef; + filters?: FilterContextItem[]; + ignoredFilters?: IDashboardFilterReference[]; + ignoreReportPeriod?: boolean; + insight?: ObjRef; + properties?: VisualizationProperties; + // (undocumented) + showTitle?: boolean; + title?: string; + // (undocumented) + type: "visualization"; +} + // @public export interface IResultAttributeHeader { // (undocumented) @@ -5639,6 +5858,48 @@ export function isRelativeUpperBoundedDateFilterBody(obj: unknown): obj is IRela // @alpha export function isRemotePluggableApplicationRegistryItem(app: PluggableApplicationRegistryItem): app is RemotePluggableApplicationRegistryItem; +// @alpha +export function isReport(obj: unknown): obj is IReport; + +// @alpha +export function isReportContentV1(obj: unknown): obj is IReportContent; + +// @alpha +export function isReportDefinition(obj: unknown): obj is IReportDefinition; + +// @alpha +export function isReportImageSlot(obj: unknown): obj is IReportImageSlot; + +// @alpha +export function isReportLayoutSection(obj: unknown): obj is IReportLayoutSection; + +// @alpha +export function isReportLayoutSlotRef(obj: unknown): obj is IReportLayoutSlotRef; + +// @alpha +export function isReportPageLayout(obj: unknown): obj is IReportPageLayout; + +// @alpha +export function isReportPageLayoutContentV1(obj: unknown): obj is IReportPageLayoutContent; + +// @alpha +export function isReportPageLayoutDefinition(obj: unknown): obj is IReportPageLayoutDefinition; + +// @alpha +export function isReportSlot(obj: unknown): obj is ReportSlot; + +// @alpha +export function isReportTemplate(obj: unknown): obj is IReportTemplate; + +// @alpha +export function isReportTemplateDefinition(obj: unknown): obj is IReportTemplateDefinition; + +// @alpha +export function isReportTextSlot(obj: unknown): obj is IReportTextSlot; + +// @alpha +export function isReportVisualizationSlot(obj: unknown): obj is IReportVisualizationSlot; + // @public export function isResultAttributeHeader(obj: unknown): obj is IResultAttributeHeader; @@ -6864,6 +7125,14 @@ export function newAbsoluteDashboardDateFilter(from: DateString, to: DateString, // @public export function newAbsoluteDateFilter(dateDataSet: ObjRef | Identifier, from: string, to: string, localIdentifier?: string, emptyValueHandling?: EmptyValues): IAbsoluteDateFilter; +// @alpha +export function newAdHocReportDefinition(options: { + title: string; + periodStart: ReportDateString; + periodEnd: ReportDateString; + pages?: IReportContentPage[]; +}, modifications?: Partial>): IReportDefinition; + // @alpha export function newAllTimeDashboardDateFilter(dataSet?: ObjRef, localIdentifier?: string, emptyValueHandling?: EmptyValues): IDashboardDateFilter; @@ -6960,6 +7229,25 @@ export function newRelativeDashboardDateFilter(granularity: DateFilterGranularit // @public export function newRelativeDateFilter(dateDataSet: ObjRef | Identifier, granularity: DateAttributeGranularity, from: number, to: number, localIdentifier?: string, boundedFilter?: IUpperBoundedFilter | ILowerBoundedFilter, emptyValueHandling?: EmptyValues): IRelativeDateFilter; +// @alpha +export function newReportContent(pages: IReportContentPage[], modifications?: Partial>): IReportContent; + +// @alpha +export function newReportContentPageFromLayout(page: IReportPageLayout | IReportPageLayoutDefinition, localIdentifier?: string): IReportContentPage; + +// @alpha +export function newReportDefinitionFromTemplate(template: IReportTemplate | IReportTemplateDefinition, options: { + title: string; + periodStart: ReportDateString; + periodEnd: ReportDateString; +}, modifications?: Partial>): IReportDefinition; + +// @alpha +export function newReportPageLayoutDefinition(title: string, body: IReportPageBody, modifications?: Partial>): IReportPageLayoutDefinition; + +// @alpha +export function newReportTemplateDefinition(title: string, content: IReportContent, modifications?: Partial>): IReportTemplateDefinition; + // @public export function newTotal(type: TotalType, measureOrId: IMeasure | Identifier, attributeOrId: IAttribute | Identifier, alias?: string): ITotal; @@ -6991,7 +7279,7 @@ export type ObjectOrigin = "ALL" | "PARENTS" | "NATIVE"; export type ObjectPermissionsObjectKind = "attribute" | "fact" | "label" | "measure"; // @public -export type ObjectType = "measure" | "fact" | "attribute" | "computedAttribute" | "displayForm" | "dataSet" | "tag" | "insight" | "variable" | "analyticalDashboard" | "theme" | "colorPalette" | "workspaceTheme" | "workspaceColorPalette" | "filterContext" | "dashboardPlugin" | "attributeHierarchy" | "user" | "userGroup" | "dateHierarchyTemplate" | "dateAttributeHierarchy" | "exportDefinition" | "automation" | "filterView" | "workspaceDataFilter" | "workspaceDataFilterSetting" | "userDataFilter" | "notificationChannel" | "memoryItem" | "parameter"; +export type ObjectType = "measure" | "fact" | "attribute" | "computedAttribute" | "displayForm" | "dataSet" | "tag" | "insight" | "variable" | "analyticalDashboard" | "theme" | "colorPalette" | "workspaceTheme" | "workspaceColorPalette" | "filterContext" | "dashboardPlugin" | "attributeHierarchy" | "user" | "userGroup" | "dateHierarchyTemplate" | "dateAttributeHierarchy" | "exportDefinition" | "automation" | "filterView" | "workspaceDataFilter" | "workspaceDataFilterSetting" | "userDataFilter" | "notificationChannel" | "memoryItem" | "parameter" | "reportPageLayout" | "reportTemplate" | "report"; // @public export type ObjRef = UriRef | IdentifierRef; @@ -7156,6 +7444,39 @@ export type RemotePluggableApplicationRegistryItem = IRemotePluggableApplication // @alpha export type RemotePluggableApplicationsRegistry = IRemotePluggableApplicationsRegistryV1; +// @alpha +export type ReportBuiltInVariable = "reportTitle" | "periodStart" | "periodEnd" | "workspaceName" | "generatedAt" | "pageNumber" | "totalPages" | "logo"; + +// @alpha +export const ReportBuiltInVariables: ReportBuiltInVariable[]; + +// @alpha +export function reportContentPage(reportOrTemplate: IReport | IReportDefinition | IReportTemplate | IReportTemplateDefinition, pageLocalIdentifier: string): IReportContentPage | undefined; + +// @alpha +export type ReportDateString = string; + +// @alpha +export type ReportImageSource = { + type: "url"; + url: string; +} | { + type: "asset"; + ref: ObjRef; +}; + +// @alpha +export type ReportPageLayoutNode = IReportLayoutSection | IReportLayoutSlotRef; + +// @alpha +export type ReportSlot = IReportVisualizationSlot | IReportTextSlot | IReportImageSlot; + +// @alpha +export type ReportTextSlotKind = "title" | "subtitle" | "sectionTitle" | "description" | "summary" | "body" | "custom"; + +// @alpha +export type ReportTextSource = IReportStaticTextSource | IReportAiTextSource; + // @alpha export type RequiredEntitlements = Condition>; +// @alpha +export function resolveReportTextPlaceholders(text: string, values: Record): string; + // @alpha export function resolveTimezoneId(timezoneId: DashboardTimezoneId | undefined): string | undefined; @@ -7309,6 +7633,9 @@ export type UserDataFilterDefinition = IUserDataFilterDefinition | IUserGroupDat // @public export function userFullName(user: IUser): string | undefined; +// @alpha +export function validateReportPageBody(body: IReportPageBody): IReportPageBodyValidationIssue[]; + // @internal export class VirtualArithmeticMeasureBuilder extends ArithmeticMeasureBuilder { // (undocumented) diff --git a/libs/sdk-model/package.json b/libs/sdk-model/package.json index 6067a82ac4c..e2ffd783129 100644 --- a/libs/sdk-model/package.json +++ b/libs/sdk-model/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-model", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData Model definitions used by UI components and Backend SPI", "license": "MIT", "author": "GoodData", @@ -55,11 +55,11 @@ "devDependencies": { "@gooddata/eslint-config": "workspace:*", "@gooddata/oxlint-config": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@types/lodash-es": "^4.17.12", - "@types/spark-md5": "^3.0.1", - "@types/stringify-object": "^3.2.0", + "@types/spark-md5": "3.0.5", + "@types/stringify-object": "3.3.1", "@typescript-eslint/eslint-plugin": "8.58.0", "@typescript-eslint/parser": "8.58.0", "@typescript/native-preview": "7.0.0-dev.20260202.1", diff --git a/libs/sdk-model/src/index.ts b/libs/sdk-model/src/index.ts index 63b5f0000f2..d064a205847 100644 --- a/libs/sdk-model/src/index.ts +++ b/libs/sdk-model/src/index.ts @@ -1473,6 +1473,86 @@ export { MAX_AGENT_INSTRUCTIONS, } from "./agentSkill/index.js"; +export { + type ReportDateString, + type ReportBuiltInVariable, + type IReportVariableDefinition, + ReportBuiltInVariables, + getReportTextPlaceholders, + resolveReportTextPlaceholders, +} from "./reports/variables.js"; + +export { + type ReportPageLayoutNode, + type IReportLayoutNodeBase, + type IReportLayoutSection, + type IReportLayoutSlotRef, + isReportLayoutSection, + isReportLayoutSlotRef, +} from "./reports/layout.js"; + +export { + type IReportSlotPlaceholder, + type IReportSlotBase, + type IReportVisualizationSlot, + type ReportTextSlotKind, + type IReportStaticTextSource, + type IReportAiTextSource, + type ReportTextSource, + type IReportTextSlot, + type ReportImageSource, + type IReportImageSlot, + type ReportSlot, + BuiltInReportSlotTypes, + isReportVisualizationSlot, + isReportTextSlot, + isReportImageSlot, + isReportSlot, +} from "./reports/slot.js"; + +export { + type IReportPageBody, + type IReportPageLayoutContent, + type IReportPageLayoutDefinition, + type IReportPageLayout, + type IReportPageBodyValidationIssue, + isReportPageLayoutDefinition, + isReportPageLayout, + isReportPageLayoutContentV1, + validateReportPageBody, +} from "./reports/pageLayout.js"; + +export { type IReportContentPage, type IReportContent, isReportContentV1 } from "./reports/content.js"; + +export { + type IReportTemplateDefinition, + type IReportTemplate, + type IReportBase, + type IReportDefinition, + type IReport, + isReportTemplateDefinition, + isReportTemplate, + isReportDefinition, + isReport, +} from "./reports/report.js"; + +export { + newReportPageLayoutDefinition, + newReportContentPageFromLayout, + newReportContent, + newReportTemplateDefinition, + newReportDefinitionFromTemplate, + newAdHocReportDefinition, + reportContentPage, +} from "./reports/factory.js"; + +export { + BuiltInReportPageLayoutCover, + BuiltInReportPageLayoutSection, + BuiltInReportPageLayoutViz6TextLeft, + BuiltInReportPageLayouts, +} from "./reports/builtinPageLayouts.js"; + export { type ISemanticQualityReport, type ISemanticQualityIssue, diff --git a/libs/sdk-model/src/objRef/index.ts b/libs/sdk-model/src/objRef/index.ts index 7ad72e09644..fcf9417a81a 100644 --- a/libs/sdk-model/src/objRef/index.ts +++ b/libs/sdk-model/src/objRef/index.ts @@ -66,7 +66,10 @@ export type ObjectType = | "userDataFilter" | "notificationChannel" | "memoryItem" - | "parameter"; + | "parameter" + | "reportPageLayout" + | "reportTemplate" + | "report"; /** * Model object reference using object's unique identifier. diff --git a/libs/sdk-model/src/reports/builtinPageLayouts.ts b/libs/sdk-model/src/reports/builtinPageLayouts.ts new file mode 100644 index 00000000000..39b00f275b9 --- /dev/null +++ b/libs/sdk-model/src/reports/builtinPageLayouts.ts @@ -0,0 +1,259 @@ +// (C) 2026 GoodData Corporation + +import { idRef } from "../objRef/factory.js"; + +import { type ReportPageLayoutNode } from "./layout.js"; +import { type IReportPageBody, type IReportPageLayout } from "./pageLayout.js"; +import { type IReportImageSlot, type IReportTextSlot, type IReportVisualizationSlot } from "./slot.js"; + +const textSlot = ( + localIdentifier: string, + kind: IReportTextSlot["kind"], + hint: string, + content?: string, +): IReportTextSlot => ({ + type: "text", + localIdentifier, + kind, + ...(content === undefined ? { placeholder: { hint } } : { source: { type: "static", content } }), +}); + +const vizSlot = (localIdentifier: string): IReportVisualizationSlot => ({ + type: "visualization", + localIdentifier, + showTitle: true, + placeholder: { hint: "Add a visualization" }, +}); + +const logoSlot = (localIdentifier: string): IReportImageSlot => ({ + type: "image", + localIdentifier, + source: { type: "url", url: "{{logo}}" }, + fit: "contain", + altText: "Logo", +}); + +const slot = (slotId: string, weight?: number): ReportPageLayoutNode => ({ + type: "slotRef", + slotId, + ...(weight === undefined ? {} : { weight }), +}); + +const row = (children: ReportPageLayoutNode[], weight?: number): ReportPageLayoutNode => ({ + type: "section", + direction: "row", + children, + ...(weight === undefined ? {} : { weight }), +}); + +const column = (children: ReportPageLayoutNode[], weight?: number): ReportPageLayoutNode => ({ + type: "section", + direction: "column", + children, + ...(weight === undefined ? {} : { weight }), +}); + +const footerSlots = () => [ + logoSlot("footerLogo"), + textSlot("footerPageNumber", "custom", "", "{{pageNumber}} / {{totalPages}}"), +]; + +const footerRow = () => row([slot("footerLogo", 1), slot("footerPageNumber", 8)], 1); + +interface IContentPageSpec { + vizRows: string[][]; + summary?: boolean; + gridWeights?: number[]; +} + +const vizGrid = ({ vizRows, gridWeights }: IContentPageSpec, weight: number): ReportPageLayoutNode => + vizRows.length === 1 + ? row( + vizRows[0]!.map((id, index) => slot(id, gridWeights?.[index])), + weight, + ) + : column( + vizRows.map((ids) => row(ids.map((id) => slot(id)))), + weight, + ); + +const contentPage = (spec: IContentPageSpec): IReportPageBody => { + const { vizRows, summary } = spec; + const body = summary ? row([vizGrid(spec, 2), slot("summary", 1)], 9) : vizGrid(spec, 9); + + return { + kind: "content", + layout: column([slot("pageTitle", 2), body, footerRow()]), + slots: [ + textSlot("pageTitle", "title", "Page title"), + ...vizRows.flat().map(vizSlot), + ...(summary ? [textSlot("summary", "summary", "Add a summary")] : []), + ...footerSlots(), + ], + }; +}; + +const vizIds = (count: number, perRow: number): string[][] => { + const rows: string[][] = []; + for (let index = 0; index < count; index++) { + const rowIndex = Math.floor(index / perRow); + (rows[rowIndex] ??= []).push(`widget${index + 1}`); + } + return rows; +}; + +// Built-ins are shared singletons served by every backend implementation; +// freezing makes accidental mutation by a consumer throw instead of corrupting them. +function deepFreeze(value: T): T { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + Object.freeze(value); + for (const key of Object.keys(value)) { + deepFreeze((value as Record)[key]); + } + } + return value; +} + +const builtInPage = ( + id: string, + title: string, + description: string, + body: IReportPageBody, +): IReportPageLayout => + deepFreeze({ + type: "reportPageLayout", + ref: idRef(`builtin.reportPageLayout.${id}`, "reportPageLayout"), + title, + description, + content: { version: "1", ...body }, + isBuiltIn: true, + isLocked: true, + }); + +/** + * Built-in cover page: report title and subtitle. + * + * @alpha + */ +export const BuiltInReportPageLayoutCover: IReportPageLayout = builtInPage("cover", "Cover", "Title page", { + kind: "cover", + layout: column([slot("coverTitle", 2), slot("coverSubtitle", 1), footerRow()]), + slots: [ + textSlot("coverTitle", "title", "Report title", "{{reportTitle}}"), + textSlot("coverSubtitle", "subtitle", "Subtitle", "{{periodStart}} – {{periodEnd}}"), + ...footerSlots(), + ], +}); + +/** + * Built-in section divider page. + * + * @alpha + */ +export const BuiltInReportPageLayoutSection: IReportPageLayout = builtInPage( + "section", + "Section", + "Section divider page", + { + kind: "section", + layout: column([slot("sectionTitle", 9), footerRow()]), + slots: [textSlot("sectionTitle", "sectionTitle", "Section title"), ...footerSlots()], + }, +); + +const contentPages: [id: string, title: string, description: string, spec: IContentPageSpec][] = [ + ["viz1", "1 visualization", "Single full-width visualization", { vizRows: vizIds(1, 1) }], + [ + "viz1SummaryRight", + "1 visualization + summary", + "Single visualization with a summary column on the right", + { vizRows: vizIds(1, 1), summary: true }, + ], + ["viz2", "2 visualizations", "Two visualizations side by side", { vizRows: vizIds(2, 2) }], + [ + "viz2Wide", + "2 visualizations (wide left)", + "Two visualizations, the left one twice as wide", + { vizRows: vizIds(2, 2), gridWeights: [2, 1] }, + ], + [ + "viz2Narrow", + "2 visualizations (wide right)", + "Two visualizations, the right one twice as wide", + { vizRows: vizIds(2, 2), gridWeights: [1, 2] }, + ], + [ + "viz2SummaryRight", + "2 visualizations + summary", + "Two visualizations with a summary column on the right", + { vizRows: vizIds(2, 2), summary: true }, + ], + ["viz3", "3 visualizations", "Three visualizations side by side", { vizRows: vizIds(3, 3) }], + [ + "viz3SummaryRight", + "3 visualizations + summary", + "Three visualizations with a summary column on the right", + { vizRows: vizIds(3, 3), summary: true }, + ], + ["viz4", "4 visualizations", "Four visualizations in a 2x2 grid", { vizRows: vizIds(4, 2) }], + [ + "viz4SummaryRight", + "4 visualizations + summary", + "2x2 visualization grid with a summary column on the right", + { vizRows: vizIds(4, 2), summary: true }, + ], + ["viz6", "6 visualizations", "Six visualizations in a 2x3 grid", { vizRows: vizIds(6, 2) }], + [ + "viz6SummaryRight", + "6 visualizations + summary", + "3x2 visualization grid with a summary column on the right", + { vizRows: vizIds(6, 2), summary: true }, + ], +]; + +/** + * Built-in content page with six visualizations and a text column on the left. + * + * @alpha + */ +export const BuiltInReportPageLayoutViz6TextLeft: IReportPageLayout = builtInPage( + "viz6TextLeft", + "6 visualizations + text", + "Two rows of three visualizations with a text column on the left", + { + kind: "content", + layout: column([ + slot("pageTitle", 2), + column( + [ + row([slot("text1", 2), slot("widget1", 3), slot("widget2", 3), slot("widget3", 3)]), + row([slot("text2", 2), slot("widget4", 3), slot("widget5", 3), slot("widget6", 3)]), + ], + 9, + ), + footerRow(), + ]), + slots: [ + textSlot("pageTitle", "title", "Page title"), + textSlot("text1", "body", "Add a text"), + textSlot("text2", "body", "Add a text"), + ...["widget1", "widget2", "widget3", "widget4", "widget5", "widget6"].map(vizSlot), + ...footerSlots(), + ], + }, +); + +/** + * All built-in report pages served by the SPI. Built-ins are read-only: they cannot be + * edited or deleted, and they are never persisted on the backend. + * + * @alpha + */ +export const BuiltInReportPageLayouts: readonly IReportPageLayout[] = Object.freeze([ + BuiltInReportPageLayoutCover, + BuiltInReportPageLayoutSection, + ...contentPages.map(([id, title, description, spec]) => + builtInPage(id, title, description, contentPage(spec)), + ), + BuiltInReportPageLayoutViz6TextLeft, +]); diff --git a/libs/sdk-model/src/reports/content.ts b/libs/sdk-model/src/reports/content.ts new file mode 100644 index 00000000000..55c46e1607e --- /dev/null +++ b/libs/sdk-model/src/reports/content.ts @@ -0,0 +1,70 @@ +// (C) 2026 GoodData Corporation + +import { isEmpty } from "lodash-es"; + +import { type FilterContextItem } from "../dashboard/filterContext.js"; + +import { type IReportPageBody } from "./pageLayout.js"; +import { type IReportVariableDefinition } from "./variables.js"; + +/** + * Page instance embedded in template/report content: a deep clone of a page body + * plus its own identity. + * + * @remarks + * Cloning a {@link IReportPageLayout} into content assigns the localIdentifier and regenerates + * slot localIdentifiers (with the layout slotIds) so repeated use of one page stays unique. + * Deliberately carries NO reference back to the source page — later page edits never + * affect existing templates or reports. + * + * @alpha + */ +export interface IReportContentPage extends IReportPageBody { + /** + * Identifier unique within the content; stable across template-to-report copy and reordering. + */ + localIdentifier: string; +} + +/** + * Versioned content shared verbatim by report templates and reports. + * + * @alpha + */ +export interface IReportContent { + /** + * Content model version, for stored-content evolution. + */ + version: "1"; + + /** + * Ordered pages of the document. + */ + pages: IReportContentPage[]; + + /** + * Content-level default filters; pages and slots may extend or override them. + * The report period is NOT stored here — it is derived from periodStart/periodEnd + * at execution time. + */ + filters?: FilterContextItem[]; + + /** + * Custom variable declarations. Values live on the report + * ({@link IReportBase.variableValues}). + */ + variables?: IReportVariableDefinition[]; +} + +/** + * Type-guard testing whether the provided object is an instance of {@link IReportContent}. + * + * @alpha + */ +export function isReportContentV1(obj: unknown): obj is IReportContent { + return ( + !isEmpty(obj) && + (obj as IReportContent).version === "1" && + Array.isArray((obj as IReportContent).pages) + ); +} diff --git a/libs/sdk-model/src/reports/factory.ts b/libs/sdk-model/src/reports/factory.ts new file mode 100644 index 00000000000..f9da5dd71d0 --- /dev/null +++ b/libs/sdk-model/src/reports/factory.ts @@ -0,0 +1,192 @@ +// (C) 2026 GoodData Corporation + +import { type IReportContent, type IReportContentPage } from "./content.js"; +import { type ReportPageLayoutNode, isReportLayoutSection } from "./layout.js"; +import { + type IReportPageBody, + type IReportPageLayout, + type IReportPageLayoutContent, + type IReportPageLayoutDefinition, +} from "./pageLayout.js"; +import { + type IReport, + type IReportDefinition, + type IReportTemplate, + type IReportTemplateDefinition, +} from "./report.js"; +import { type ReportSlot } from "./slot.js"; +import { type ReportDateString } from "./variables.js"; + +function generateReportLocalId(prefix: string): string { + const unique = + typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? crypto.randomUUID().replace(/-/g, "").slice(0, 12) + : Math.random().toString(36).slice(2, 14); + return `${prefix}_${unique}`; +} + +function deepClone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function prefixLayoutSlotIds(node: ReportPageLayoutNode, prefix: string): ReportPageLayoutNode { + if (isReportLayoutSection(node)) { + return { + ...node, + children: node.children.map((child) => prefixLayoutSlotIds(child, prefix)), + }; + } + return { + ...node, + slotId: `${prefix}_${node.slotId}`, + }; +} + +/** + * Creates a new report page definition. + * + * @alpha + */ +export function newReportPageLayoutDefinition( + title: string, + body: IReportPageBody, + modifications?: Partial>, +): IReportPageLayoutDefinition { + const content: IReportPageLayoutContent = { version: "1", ...body }; + return { + type: "reportPageLayout", + title, + content, + ...modifications, + }; +} + +/** + * Clones a report page into a page instance embeddable in template/report content. + * + * @remarks + * The body is deep-copied and detached from the source page — no reference is kept. + * Slot localIdentifiers (and the layout slotIds pointing at them) are prefixed with the + * page-instance localIdentifier, so one page used repeatedly in the same content stays unique. + * + * @alpha + */ +export function newReportContentPageFromLayout( + page: IReportPageLayout | IReportPageLayoutDefinition, + localIdentifier: string = generateReportLocalId("page"), +): IReportContentPage { + const { version: _version, ...body } = deepClone(page.content); + return { + ...body, + localIdentifier, + layout: prefixLayoutSlotIds(body.layout, localIdentifier), + slots: body.slots.map( + (slot): ReportSlot => ({ + ...slot, + localIdentifier: `${localIdentifier}_${slot.localIdentifier}`, + }), + ), + }; +} + +/** + * Creates report content from page instances. + * + * @alpha + */ +export function newReportContent( + pages: IReportContentPage[], + modifications?: Partial>, +): IReportContent { + return { + version: "1", + pages, + ...modifications, + }; +} + +/** + * Creates a new report template definition. + * + * @alpha + */ +export function newReportTemplateDefinition( + title: string, + content: IReportContent, + modifications?: Partial>, +): IReportTemplateDefinition { + return { + type: "reportTemplate", + title, + content, + ...modifications, + }; +} + +/** + * Creates a report definition from a template. + * + * @remarks + * The template content is deep-copied; no reference to the template is kept, so the + * report stays frozen while the template evolves. + * + * @alpha + */ +export function newReportDefinitionFromTemplate( + template: IReportTemplate | IReportTemplateDefinition, + options: { + title: string; + periodStart: ReportDateString; + periodEnd: ReportDateString; + }, + modifications?: Partial< + Omit + >, +): IReportDefinition { + return { + type: "report", + title: options.title, + periodStart: options.periodStart, + periodEnd: options.periodEnd, + content: deepClone(template.content), + ...modifications, + }; +} + +/** + * Creates an ad-hoc report definition not based on any template. + * + * @alpha + */ +export function newAdHocReportDefinition( + options: { + title: string; + periodStart: ReportDateString; + periodEnd: ReportDateString; + pages?: IReportContentPage[]; + }, + modifications?: Partial< + Omit + >, +): IReportDefinition { + return { + type: "report", + title: options.title, + periodStart: options.periodStart, + periodEnd: options.periodEnd, + content: newReportContent(options.pages ?? []), + ...modifications, + }; +} + +/** + * Convenience accessor for a report or template page by its localIdentifier. + * + * @alpha + */ +export function reportContentPage( + reportOrTemplate: IReport | IReportDefinition | IReportTemplate | IReportTemplateDefinition, + pageLocalIdentifier: string, +): IReportContentPage | undefined { + return reportOrTemplate.content.pages.find((page) => page.localIdentifier === pageLocalIdentifier); +} diff --git a/libs/sdk-model/src/reports/layout.ts b/libs/sdk-model/src/reports/layout.ts new file mode 100644 index 00000000000..4f1a9899403 --- /dev/null +++ b/libs/sdk-model/src/reports/layout.ts @@ -0,0 +1,74 @@ +// (C) 2026 GoodData Corporation + +import { isEmpty } from "lodash-es"; + +/** + * Node of a report page layout: a recursive row/column split tree (flexbox semantics) + * over a fixed page. Leaves assign their area to slots; the tree carries only geometry, + * slot content lives in {@link IReportPageBody.slots}. + * + * @alpha + */ +export type ReportPageLayoutNode = IReportLayoutSection | IReportLayoutSlotRef; + +/** + * Fields common to all report layout nodes. + * + * @alpha + */ +export interface IReportLayoutNodeBase { + /** + * Fractional weight of this node inside its parent (flex-grow semantics). + * Defaults to 1. Example: sibling weights [2, 1] render a 2/3 + 1/3 split. + */ + weight?: number; +} + +/** + * A container splitting its area into children laid out along a direction. + * + * @alpha + */ +export interface IReportLayoutSection extends IReportLayoutNodeBase { + type: "section"; + + /** + * "row" lays children out horizontally, "column" vertically. + */ + direction: "row" | "column"; + + children: ReportPageLayoutNode[]; +} + +/** + * A leaf assigning its area to a slot. The slot fills the area completely. + * + * @alpha + */ +export interface IReportLayoutSlotRef extends IReportLayoutNodeBase { + type: "slotRef"; + + /** + * Local identifier of a slot in {@link IReportPageBody.slots}. + * A slotId with no matching slot renders as an empty area. + */ + slotId: string; +} + +/** + * Type-guard testing whether the provided object is an instance of {@link IReportLayoutSection}. + * + * @alpha + */ +export function isReportLayoutSection(obj: unknown): obj is IReportLayoutSection { + return !isEmpty(obj) && (obj as IReportLayoutSection).type === "section"; +} + +/** + * Type-guard testing whether the provided object is an instance of {@link IReportLayoutSlotRef}. + * + * @alpha + */ +export function isReportLayoutSlotRef(obj: unknown): obj is IReportLayoutSlotRef { + return !isEmpty(obj) && (obj as IReportLayoutSlotRef).type === "slotRef"; +} diff --git a/libs/sdk-model/src/reports/pageLayout.ts b/libs/sdk-model/src/reports/pageLayout.ts new file mode 100644 index 00000000000..8945c37e25a --- /dev/null +++ b/libs/sdk-model/src/reports/pageLayout.ts @@ -0,0 +1,200 @@ +// (C) 2026 GoodData Corporation + +import { isEmpty } from "lodash-es"; + +import { type IAuditableDates, type IAuditableUsers } from "../base/metadata.js"; +import { type FilterContextItem } from "../dashboard/filterContext.js"; +import { type ObjRef, isObjRef } from "../objRef/index.js"; + +import { type ReportPageLayoutNode, isReportLayoutSection, isReportLayoutSlotRef } from "./layout.js"; +import { type ReportSlot } from "./slot.js"; + +/** + * Body of a report page: geometry (flex split tree) plus the slots it places. + * + * @remarks + * This is both the content of the standalone {@link IReportPageLayout} object and the shape + * embedded in template/report content ({@link IReportContentPage}). + * + * There is no header/footer chrome: page title, description, footer, page numbers and + * logos are ordinary slots in the layout tree (text slots with `{{pageNumber}}`/`{{totalPages}}`, + * an image slot with `{{logo}}`). Every slot fills its layout area completely. + * + * @alpha + */ +export interface IReportPageBody { + /** + * Editor hint only (template galleries, default styling, AI context). + * Renderers must not branch layout logic on it — geometry always comes from `layout`. + */ + kind?: "cover" | "section" | "content"; + + /** + * Root of the page layout tree. + */ + layout: ReportPageLayoutNode; + + /** + * All slots referenced by the layout, flat, keyed by localIdentifier. + */ + slots: ReportSlot[]; + + /** + * Page-level filters: merged over content-level filters (a filter targeting the + * same object replaces the inherited one); slot filters apply on top. + */ + filters?: FilterContextItem[]; +} + +/** + * Stored content of the reportPage entity. + * + * @alpha + */ +export interface IReportPageLayoutContent extends IReportPageBody { + /** + * Content model version, for stored-content evolution. + */ + version: "1"; +} + +/** + * Payload for creating or updating a report page. + * + * @alpha + */ +export interface IReportPageLayoutDefinition { + type: "reportPageLayout"; + + /** + * Present when updating an existing page. + */ + ref?: ObjRef; + + title: string; + + description?: string; + + tags?: string[]; + + content: IReportPageLayoutContent; +} + +/** + * Reusable report page metadata object. + * + * @alpha + */ +export interface IReportPageLayout extends IReportPageLayoutDefinition, IAuditableDates, IAuditableUsers { + ref: ObjRef; + + /** + * When true, the object comes from a parent workspace and is not editable + * in the current workspace. + */ + isLocked?: boolean; + + /** + * Predefined page shipped with the product, populated by the SPI. Never sent to or + * stored on the backend; UI must disable deletion and editing of built-in pages. + */ + isBuiltIn?: boolean; +} + +/** + * Type-guard testing whether the provided object is an instance of {@link IReportPageLayoutDefinition}. + * + * @alpha + */ +export function isReportPageLayoutDefinition(obj: unknown): obj is IReportPageLayoutDefinition { + return !isEmpty(obj) && (obj as IReportPageLayoutDefinition).type === "reportPageLayout"; +} + +/** + * Type-guard testing whether the provided object is an instance of {@link IReportPageLayout}. + * + * @alpha + */ +export function isReportPageLayout(obj: unknown): obj is IReportPageLayout { + return isReportPageLayoutDefinition(obj) && isObjRef((obj as IReportPageLayout).ref); +} + +/** + * Type-guard testing whether the provided object is an instance of {@link IReportPageLayoutContent}. + * + * @alpha + */ +export function isReportPageLayoutContentV1(obj: unknown): obj is IReportPageLayoutContent { + return ( + !isEmpty(obj) && + (obj as IReportPageLayoutContent).version === "1" && + !isEmpty((obj as IReportPageLayoutContent).layout) && + Array.isArray((obj as IReportPageLayoutContent).slots) + ); +} + +/** + * Validation issue found in a report page body. + * + * @alpha + */ +export interface IReportPageBodyValidationIssue { + severity: "error" | "warning"; + message: string; +} + +/** + * Validates the structural invariants of a page body the type system cannot express: + * slot localIdentifiers are unique, layout weights are positive, every slot is placed + * by the layout, and every layout slotId resolves (unresolved ones are warnings — + * they render as empty areas). + * + * @alpha + */ +export function validateReportPageBody(body: IReportPageBody): IReportPageBodyValidationIssue[] { + const issues: IReportPageBodyValidationIssue[] = []; + + const slotIds = new Set(); + for (const slot of body.slots) { + if (slotIds.has(slot.localIdentifier)) { + issues.push({ + severity: "error", + message: `Duplicate slot localIdentifier "${slot.localIdentifier}".`, + }); + } + slotIds.add(slot.localIdentifier); + } + + const referencedIds = new Set(); + const visit = (node: ReportPageLayoutNode): void => { + if (node.weight !== undefined && !(node.weight > 0)) { + issues.push({ + severity: "error", + message: `Layout node weight must be positive, got ${node.weight}.`, + }); + } + if (isReportLayoutSlotRef(node)) { + referencedIds.add(node.slotId); + if (!slotIds.has(node.slotId)) { + issues.push({ + severity: "warning", + message: `Layout references slot "${node.slotId}" which has no definition; it renders empty.`, + }); + } + } else if (isReportLayoutSection(node)) { + node.children.forEach(visit); + } + }; + visit(body.layout); + + for (const slotId of slotIds) { + if (!referencedIds.has(slotId)) { + issues.push({ + severity: "warning", + message: `Slot "${slotId}" is not placed by the layout and never renders.`, + }); + } + } + + return issues; +} diff --git a/libs/sdk-model/src/reports/report.ts b/libs/sdk-model/src/reports/report.ts new file mode 100644 index 00000000000..a3f40266e8a --- /dev/null +++ b/libs/sdk-model/src/reports/report.ts @@ -0,0 +1,151 @@ +// (C) 2026 GoodData Corporation + +import { isEmpty } from "lodash-es"; + +import { type IAuditableDates, type IAuditableUsers } from "../base/metadata.js"; +import { type ObjRef, isObjRef } from "../objRef/index.js"; + +import { type IReportContent } from "./content.js"; +import { type ReportDateString } from "./variables.js"; + +/** + * Payload for creating or updating a report template. + * + * @alpha + */ +export interface IReportTemplateDefinition { + type: "reportTemplate"; + + /** + * Present when updating an existing template. + */ + ref?: ObjRef; + + title: string; + + description?: string; + + tags?: string[]; + + content: IReportContent; +} + +/** + * Report template metadata object. + * + * @alpha + */ +export interface IReportTemplate extends IReportTemplateDefinition, IAuditableDates, IAuditableUsers { + ref: ObjRef; + + /** + * When true, the object comes from a parent workspace and is not editable + * in the current workspace. + */ + isLocked?: boolean; +} + +/** + * Fields shared by report definitions and persisted reports. + * + * @alpha + */ +export interface IReportBase { + title: string; + + description?: string; + + tags?: string[]; + + /** + * Reported period start, ISO 8601 date (YYYY-MM-DD), inclusive. + * + * @remarks + * At execution time the period materializes as an absolute date filter on each + * visualization slot's dateDataSet (lowest precedence, per-slot opt-out via + * ignoreReportPeriod). Also available as `{{periodStart}}` in text. + */ + periodStart: ReportDateString; + + /** + * Reported period end, ISO 8601 date (YYYY-MM-DD), inclusive. + */ + periodEnd: ReportDateString; + + /** + * Content of the report. When created from a template the content is deep-copied + * and NO reference to the template is kept — the report stays frozen while pages + * and templates evolve. + */ + content: IReportContent; + + /** + * Values for variables declared in content.variables, keyed by variable name. + */ + variableValues?: Record; +} + +/** + * Payload for creating or updating a report. + * + * @alpha + */ +export interface IReportDefinition extends IReportBase { + type: "report"; + + /** + * Present when updating an existing report. + */ + ref?: ObjRef; +} + +/** + * Report metadata object. + * + * @alpha + */ +export interface IReport extends IReportDefinition, IAuditableDates, IAuditableUsers { + ref: ObjRef; + + /** + * When true, the object comes from a parent workspace and is not editable + * in the current workspace. + */ + isLocked?: boolean; +} + +/** + * Type-guard testing whether the provided object is an instance of {@link IReportTemplateDefinition}. + * + * @alpha + */ +export function isReportTemplateDefinition(obj: unknown): obj is IReportTemplateDefinition { + return !isEmpty(obj) && (obj as IReportTemplateDefinition).type === "reportTemplate"; +} + +/** + * Type-guard testing whether the provided object is an instance of {@link IReportTemplate}. + * + * @alpha + */ +export function isReportTemplate(obj: unknown): obj is IReportTemplate { + return isReportTemplateDefinition(obj) && isObjRef((obj as IReportTemplate).ref); +} + +/** + * Type-guard testing whether the provided object is an instance of {@link IReportDefinition}. + * + * @alpha + */ +export function isReportDefinition(obj: unknown): obj is IReportDefinition { + return !isEmpty(obj) && (obj as IReportDefinition).type === "report"; +} + +/** + * Type-guard testing whether the provided object is an instance of {@link IReport}. + * + * @alpha + */ +export function isReport(obj: unknown): obj is IReport { + return isReportDefinition(obj) && isObjRef((obj as IReport).ref); +} diff --git a/libs/sdk-model/src/reports/reports.test.ts b/libs/sdk-model/src/reports/reports.test.ts new file mode 100644 index 00000000000..c1f9d6ef0b0 --- /dev/null +++ b/libs/sdk-model/src/reports/reports.test.ts @@ -0,0 +1,252 @@ +// (C) 2026 GoodData Corporation + +import { describe, expect, it } from "vitest"; + +import { BuiltInReportPageLayouts } from "./builtinPageLayouts.js"; +import { isReportContentV1 } from "./content.js"; +import { + newAdHocReportDefinition, + newReportContent, + newReportContentPageFromLayout, + newReportDefinitionFromTemplate, + newReportPageLayoutDefinition, + newReportTemplateDefinition, + reportContentPage, +} from "./factory.js"; +import { type ReportPageLayoutNode, isReportLayoutSlotRef } from "./layout.js"; +import { + type IReportPageBody, + isReportPageLayout, + isReportPageLayoutDefinition, + validateReportPageBody, +} from "./pageLayout.js"; +import { isReport, isReportDefinition, isReportTemplate, isReportTemplateDefinition } from "./report.js"; +import { isReportImageSlot, isReportSlot, isReportTextSlot, isReportVisualizationSlot } from "./slot.js"; +import { getReportTextPlaceholders, resolveReportTextPlaceholders } from "./variables.js"; + +const body: IReportPageBody = { + kind: "content", + layout: { + type: "section", + direction: "row", + children: [ + { type: "slotRef", slotId: "widget", weight: 2 }, + { type: "slotRef", slotId: "summary", weight: 1 }, + ], + }, + slots: [ + { type: "visualization", localIdentifier: "widget", placeholder: { hint: "Add a visualization" } }, + { type: "text", localIdentifier: "summary", kind: "summary", placeholder: { hint: "Summary" } }, + ], +}; + +describe("placeholder helpers", () => { + it("collects distinct placeholders in order of first occurrence", () => { + expect(getReportTextPlaceholders("{{periodStart}} to {{periodEnd}} ({{periodStart}})")).toEqual([ + "periodStart", + "periodEnd", + ]); + }); + + it("ignores malformed markers", () => { + expect(getReportTextPlaceholders("{{1bad}} {{ spaced }} {{}} {{good_1}}")).toEqual(["good_1"]); + }); + + it("resolves known values and keeps unknown markers", () => { + expect(resolveReportTextPlaceholders("{{a}} and {{b}}", { a: "1" })).toBe("1 and {{b}}"); + }); + + it("does not expand values recursively", () => { + expect(resolveReportTextPlaceholders("{{a}}", { a: "{{b}}", b: "x" })).toBe("{{b}}"); + }); +}); + +describe("type guards", () => { + const page = newReportPageLayoutDefinition("Page", body); + const template = newReportTemplateDefinition( + "Template", + newReportContent([newReportContentPageFromLayout(page, "p1")]), + ); + const report = newReportDefinitionFromTemplate(template, { + title: "Report", + periodStart: "2026-01-01", + periodEnd: "2026-03-31", + }); + + it("distinguishes definitions from saved objects by ref", () => { + expect(isReportPageLayoutDefinition(page)).toBe(true); + expect(isReportPageLayout(page)).toBe(false); + expect(isReportPageLayout({ ...page, ref: { identifier: "p" } })).toBe(true); + + expect(isReportTemplateDefinition(template)).toBe(true); + expect(isReportTemplate(template)).toBe(false); + expect(isReportTemplate({ ...template, ref: { identifier: "t" } })).toBe(true); + + expect(isReportDefinition(report)).toBe(true); + expect(isReport(report)).toBe(false); + expect(isReport({ ...report, ref: { identifier: "r" } })).toBe(true); + }); + + it("discriminates slot types", () => { + const [viz, text] = body.slots; + expect(isReportVisualizationSlot(viz)).toBe(true); + expect(isReportTextSlot(text)).toBe(true); + expect(isReportImageSlot(viz)).toBe(false); + expect(isReportSlot(viz)).toBe(true); + expect(isReportSlot({})).toBe(false); + }); + + it("recognizes content version 1", () => { + expect(isReportContentV1(template.content)).toBe(true); + expect(isReportContentV1({ version: "2", pages: [] })).toBe(false); + }); +}); + +describe("newReportContentPageFromLayout", () => { + const page = newReportPageLayoutDefinition("Page", body); + + it("prefixes slot ids and layout slotIds consistently", () => { + const instance = newReportContentPageFromLayout(page, "p1"); + + expect(instance.slots.map((slot) => slot.localIdentifier)).toEqual(["p1_widget", "p1_summary"]); + expect(validateReportPageBody(instance)).toEqual([]); + }); + + it("keeps repeated instances of one page unique within content", () => { + const content = newReportContent([ + newReportContentPageFromLayout(page, "p1"), + newReportContentPageFromLayout(page, "p2"), + ]); + + const allSlotIds = content.pages.flatMap((contentPage) => + contentPage.slots.map((slot) => slot.localIdentifier), + ); + expect(new Set(allSlotIds).size).toBe(allSlotIds.length); + }); + + it("detaches the clone from the source page", () => { + const mutablePage = newReportPageLayoutDefinition("Page", JSON.parse(JSON.stringify(body))); + const instance = newReportContentPageFromLayout(mutablePage, "p1"); + + mutablePage.content.slots[0]!.placeholder!.hint = "changed"; + + expect(instance.slots[0]!.placeholder!.hint).toBe("Add a visualization"); + }); + + it("generates a localIdentifier when none is given", () => { + const instance = newReportContentPageFromLayout(page); + expect(instance.localIdentifier).toMatch(/^page_/); + }); +}); + +describe("newReportDefinitionFromTemplate", () => { + it("deep-copies content so the report stays frozen", () => { + const page = newReportPageLayoutDefinition("Page", JSON.parse(JSON.stringify(body))); + const template = newReportTemplateDefinition( + "Template", + newReportContent([newReportContentPageFromLayout(page, "p1")]), + ); + const report = newReportDefinitionFromTemplate(template, { + title: "Report", + periodStart: "2026-01-01", + periodEnd: "2026-03-31", + }); + + template.content.pages[0]!.slots[0]!.placeholder!.hint = "changed"; + + expect(report.content.pages[0]!.slots[0]!.placeholder!.hint).toBe("Add a visualization"); + expect(reportContentPage(report, "p1")).toBeDefined(); + }); +}); + +describe("newAdHocReportDefinition", () => { + it("creates a report with empty content by default", () => { + const report = newAdHocReportDefinition({ + title: "Ad hoc", + periodStart: "2026-01-01", + periodEnd: "2026-01-31", + }); + expect(isReportContentV1(report.content)).toBe(true); + expect(report.content.pages).toEqual([]); + }); +}); + +describe("validateReportPageBody", () => { + it("reports duplicate slot ids as errors", () => { + const invalid: IReportPageBody = { + ...body, + slots: [body.slots[0]!, { ...body.slots[1]!, localIdentifier: "widget" }], + }; + expect(validateReportPageBody(invalid).some((issue) => issue.severity === "error")).toBe(true); + }); + + it("reports non-positive weights as errors", () => { + const invalid: IReportPageBody = { + ...body, + layout: { type: "slotRef", slotId: "widget", weight: 0 }, + }; + expect(validateReportPageBody(invalid).some((issue) => issue.severity === "error")).toBe(true); + }); + + it("reports unresolved and unplaced slots as warnings", () => { + const withExtras: IReportPageBody = { + ...body, + layout: { type: "slotRef", slotId: "missing" }, + }; + const issues = validateReportPageBody(withExtras); + expect(issues.every((issue) => issue.severity === "warning")).toBe(true); + expect(issues).toHaveLength(3); + }); +}); + +describe("BuiltInReportPageLayouts", () => { + it("contains 15 pages, all flagged and locked", () => { + expect(BuiltInReportPageLayouts).toHaveLength(15); + for (const page of BuiltInReportPageLayouts) { + expect(page.isBuiltIn).toBe(true); + expect(page.isLocked).toBe(true); + expect(isReportPageLayout(page)).toBe(true); + } + }); + + it("every page body is structurally valid", () => { + for (const page of BuiltInReportPageLayouts) { + expect(validateReportPageBody(page.content)).toEqual([]); + } + }); + + it("has unique refs and titles", () => { + const refIds = BuiltInReportPageLayouts.map((page) => JSON.stringify(page.ref)); + const titles = BuiltInReportPageLayouts.map((page) => page.title); + expect(new Set(refIds).size).toBe(refIds.length); + expect(new Set(titles).size).toBe(titles.length); + }); + + it("is deeply frozen", () => { + for (const layout of BuiltInReportPageLayouts) { + expect(Object.isFrozen(layout)).toBe(true); + expect(Object.isFrozen(layout.content)).toBe(true); + expect(Object.isFrozen(layout.content.slots[0])).toBe(true); + expect(Object.isFrozen(layout.content.layout)).toBe(true); + } + }); + + it("summary variants place the summary slot in the layout", () => { + const withSummary = BuiltInReportPageLayouts.filter((page) => + page.content.slots.some((slot) => slot.localIdentifier === "summary"), + ); + expect(withSummary).toHaveLength(5); + for (const page of withSummary) { + const slotIds: string[] = []; + const visit = (node: ReportPageLayoutNode): void => { + if (isReportLayoutSlotRef(node)) { + slotIds.push(node.slotId); + } else { + node.children.forEach(visit); + } + }; + visit(page.content.layout); + expect(slotIds).toContain("summary"); + } + }); +}); diff --git a/libs/sdk-model/src/reports/slot.ts b/libs/sdk-model/src/reports/slot.ts new file mode 100644 index 00000000000..87411031f89 --- /dev/null +++ b/libs/sdk-model/src/reports/slot.ts @@ -0,0 +1,271 @@ +// (C) 2026 GoodData Corporation + +import { isEmpty } from "lodash-es"; + +import { type FilterContextItem, type IDashboardFilterReference } from "../dashboard/filterContext.js"; +import { type VisualizationProperties } from "../insight/index.js"; +import { type ObjRef } from "../objRef/index.js"; + +/** + * Metadata of a slot that is intentionally left unfilled in a report page or template. + * + * @alpha + */ +export interface IReportSlotPlaceholder { + /** + * Authoring hint shown in the empty slot. + */ + hint?: string; + + /** + * When true, a report is not considered complete until this slot is filled. + */ + required?: boolean; +} + +/** + * Structural base of every report slot. + * + * @remarks + * Parsers written against this base can skip slot types introduced by a newer + * content version instead of failing. + * + * @alpha + */ +export interface IReportSlotBase { + type: string; + + /** + * Identifier unique within the page body. The layout tree references it, and it is + * the join key when a report fills a template's slots. Regenerated when a page is + * cloned into template content so repeated use of one page stays unique. + */ + localIdentifier: string; + + /** + * Present when the slot is an unfilled placeholder. + */ + placeholder?: IReportSlotPlaceholder; +} + +/** + * Slot rendering an insight, with its own filter context. + * + * @alpha + */ +export interface IReportVisualizationSlot extends IReportSlotBase { + type: "visualization"; + + /** + * Insight to render. Undefined = declared-but-empty slot; renderers show a placeholder. + */ + insight?: ObjRef; + + /** + * Optional title rendered above the visualization. Supports `{{variables}}`. + */ + title?: string; + + showTitle?: boolean; + + /** + * Visualization properties overriding the insight's own. + */ + properties?: VisualizationProperties; + + /** + * Slot-level filters, applied on top of the effective content and page filters. + * A slot filter targeting the same object replaces the inherited one. + */ + filters?: FilterContextItem[]; + + /** + * Content- and page-level filters this slot ignores (matched by display form / date dataset). + */ + ignoredFilters?: IDashboardFilterReference[]; + + /** + * Date dataset the report's period (periodStart/periodEnd) is applied to as an + * absolute date filter at execution time. Undefined = backend date-dataset + * auto-resolution applies. + */ + dateDataSet?: ObjRef; + + /** + * When true, the implicit date filter derived from the report's period is not + * applied to this slot. + */ + ignoreReportPeriod?: boolean; +} + +/** + * Semantic kind of a text slot. Drives default typography/styling and AI context only — + * never geometry (geometry always comes from the layout tree). + * + * @alpha + */ +export type ReportTextSlotKind = + | "title" + | "subtitle" + | "sectionTitle" + | "description" + | "summary" + | "body" + | "custom"; + +/** + * Author-written text. + * + * @alpha + */ +export interface IReportStaticTextSource { + type: "static"; + + /** + * Markdown content with `{{variable}}` placeholders. Stored raw — placeholders are + * resolved only at render/export time. + */ + content: string; +} + +/** + * Text generated by AI at report-build time. + * + * @alpha + */ +export interface IReportAiTextSource { + type: "ai"; + + /** + * Instruction for the generator. Supports `{{variables}}`. + */ + prompt: string; + + /** + * Materialized generation result (markdown), stored so the report renders + * deterministically without re-invoking AI. Absent until first generation. + */ + content?: string; + + /** + * ISO 8601 timestamp of the stored generation. + */ + generatedAt?: string; +} + +/** + * Where the text of a text slot comes from. + * + * @alpha + */ +export type ReportTextSource = IReportStaticTextSource | IReportAiTextSource; + +/** + * Slot rendering text. + * + * @alpha + */ +export interface IReportTextSlot extends IReportSlotBase { + type: "text"; + + kind: ReportTextSlotKind; + + /** + * Undefined = declared-but-empty slot. + */ + source?: ReportTextSource; +} + +/** + * What an image slot displays. + * + * @alpha + */ +export type ReportImageSource = + | { + type: "url"; + + /** + * Image URL. Supports `{{variables}}` — a logo slot uses `{{logo}}`. + */ + url: string; + } + | { + type: "asset"; + + /** + * Reference to a backend-managed asset. + */ + ref: ObjRef; + }; + +/** + * Slot rendering an image. + * + * @alpha + */ +export interface IReportImageSlot extends IReportSlotBase { + type: "image"; + + /** + * Undefined = declared-but-empty slot. + */ + source?: ReportImageSource; + + altText?: string; + + /** + * How the image fills the slot area. Defaults to "contain". + */ + fit?: "contain" | "cover" | "fill"; +} + +/** + * All slot types of report content version 1. + * + * @alpha + */ +export type ReportSlot = IReportVisualizationSlot | IReportTextSlot | IReportImageSlot; + +/** + * List of built-in report slot type names. + * + * @alpha + */ +export const BuiltInReportSlotTypes: string[] = ["visualization", "text", "image"]; + +/** + * Type-guard testing whether the provided object is an instance of {@link IReportVisualizationSlot}. + * + * @alpha + */ +export function isReportVisualizationSlot(obj: unknown): obj is IReportVisualizationSlot { + return !isEmpty(obj) && (obj as IReportVisualizationSlot).type === "visualization"; +} + +/** + * Type-guard testing whether the provided object is an instance of {@link IReportTextSlot}. + * + * @alpha + */ +export function isReportTextSlot(obj: unknown): obj is IReportTextSlot { + return !isEmpty(obj) && (obj as IReportTextSlot).type === "text"; +} + +/** + * Type-guard testing whether the provided object is an instance of {@link IReportImageSlot}. + * + * @alpha + */ +export function isReportImageSlot(obj: unknown): obj is IReportImageSlot { + return !isEmpty(obj) && (obj as IReportImageSlot).type === "image"; +} + +/** + * Type-guard testing whether the provided object is an instance of {@link ReportSlot}. + * + * @alpha + */ +export function isReportSlot(obj: unknown): obj is ReportSlot { + return [isReportVisualizationSlot, isReportTextSlot, isReportImageSlot].some((guard) => guard(obj)); +} diff --git a/libs/sdk-model/src/reports/variables.ts b/libs/sdk-model/src/reports/variables.ts new file mode 100644 index 00000000000..204a015d24c --- /dev/null +++ b/libs/sdk-model/src/reports/variables.ts @@ -0,0 +1,103 @@ +// (C) 2026 GoodData Corporation + +/** + * ISO 8601 calendar date (YYYY-MM-DD). + * + * @alpha + */ +export type ReportDateString = string; + +/** + * Variables resolved by the rendering runtime; they are never declared or stored. + * + * @remarks + * Referenced from report text as `{{name}}`. Custom variable names must not collide with these; + * on collision the built-in wins. + * + * @alpha + */ +export type ReportBuiltInVariable = + | "reportTitle" + | "periodStart" + | "periodEnd" + | "workspaceName" + | "generatedAt" + | "pageNumber" + | "totalPages" + | "logo"; + +/** + * All built-in report variable names. + * + * @alpha + */ +export const ReportBuiltInVariables: ReportBuiltInVariable[] = [ + "reportTitle", + "periodStart", + "periodEnd", + "workspaceName", + "generatedAt", + "pageNumber", + "totalPages", + "logo", +]; + +/** + * A custom variable declared by report content and given a value by a report. + * + * @remarks + * Referenced from text as `{{name}}`. Values come from {@link IReportBase.variableValues}, + * falling back to {@link IReportVariableDefinition.defaultValue}. + * + * @alpha + */ +export interface IReportVariableDefinition { + /** + * Variable name as used inside the `{{...}}` marker. Must match /^[a-zA-Z][a-zA-Z0-9_]*$/. + * Built-in names ({@link ReportBuiltInVariable}) are reserved. + */ + name: string; + + /** + * Human readable label shown in the editor. + */ + title?: string; + + description?: string; + + /** + * Value used when the report does not provide one. May not contain further + * placeholders — there is no recursive expansion. + */ + defaultValue?: string; +} + +const placeholderRegex = /\{\{([a-zA-Z][a-zA-Z0-9_]*)\}\}/g; + +/** + * Collects the distinct `{{variable}}` names referenced in the text, in order of first occurrence. + * + * @alpha + */ +export function getReportTextPlaceholders(text: string): string[] { + const names: string[] = []; + for (const match of text.matchAll(placeholderRegex)) { + const name = match[1]!; + if (!names.includes(name)) { + names.push(name); + } + } + return names; +} + +/** + * Replaces `{{variable}}` markers with values. Markers with no value are left as-is. + * Values are inserted verbatim — there is no recursive expansion. + * + * @alpha + */ +export function resolveReportTextPlaceholders(text: string, values: Record): string { + return text.replace(placeholderRegex, (marker, name: string) => + Object.prototype.hasOwnProperty.call(values, name) ? values[name]! : marker, + ); +} diff --git a/libs/sdk-pluggable-application-model/package.json b/libs/sdk-pluggable-application-model/package.json index bfacac0f76f..480a139c5dc 100644 --- a/libs/sdk-pluggable-application-model/package.json +++ b/libs/sdk-pluggable-application-model/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-pluggable-application-model", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData SDK model contracts for pluggable applications", "license": "MIT", "author": "GoodData Corporation", @@ -45,7 +45,7 @@ "devDependencies": { "@gooddata/eslint-config": "workspace:*", "@gooddata/oxlint-config": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@types/node": "24.12.0", "@typescript-eslint/eslint-plugin": "8.58.0", diff --git a/libs/sdk-ui-all/package.json b/libs/sdk-ui-all/package.json index 0c0c8304e52..bece9c3ea7e 100644 --- a/libs/sdk-ui-all/package.json +++ b/libs/sdk-ui-all/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-ui-all", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData SDK - All-In-One", "license": "LicenseRef-LICENSE", "author": "GoodData", @@ -52,7 +52,7 @@ "devDependencies": { "@gooddata/eslint-config": "workspace:*", "@gooddata/oxlint-config": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@types/node": "24.12.0", "@typescript-eslint/eslint-plugin": "8.58.0", diff --git a/libs/sdk-ui-application-header/package.json b/libs/sdk-ui-application-header/package.json index 7019401caaa..f29a044cbc6 100644 --- a/libs/sdk-ui-application-header/package.json +++ b/libs/sdk-ui-application-header/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-ui-application-header", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData SDK application header components — workspace picker, notifications dropdown", "license": "MIT", "author": "GoodData Corporation", @@ -52,7 +52,7 @@ "@gooddata/eslint-config": "workspace:*", "@gooddata/oxlint-config": "workspace:*", "@gooddata/sdk-ui-theme-provider": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.0", diff --git a/libs/sdk-ui-catalog/api/sdk-ui-catalog.api.md b/libs/sdk-ui-catalog/api/sdk-ui-catalog.api.md index 16ef9a9cf81..3681cb98477 100644 --- a/libs/sdk-ui-catalog/api/sdk-ui-catalog.api.md +++ b/libs/sdk-ui-catalog/api/sdk-ui-catalog.api.md @@ -56,7 +56,7 @@ export type AsCodeValidationContext = { }; // @public -export type CatalogCreateObjectType = Extract; +export type CatalogCreateObjectType = Extract; // @public (undocumented) export type EditHandlerEvent = OpenHandlerEvent; @@ -144,7 +144,7 @@ export interface ICatalogDetailProps extends ICatalogDetailContentProps { } // @public -export type ICatalogItem = ICatalogItemDashboard | ICatalogItemInsight | ICatalogItemMeasure | ICatalogItemParameter | ICatalogItemAttribute | ICatalogItemFact | ICatalogItemDataSet; +export type ICatalogItem = ICatalogItemDashboard | ICatalogItemInsight | ICatalogItemMeasure | ICatalogItemParameter | ICatalogItemComputedAttribute | ICatalogItemAttribute | ICatalogItemFact | ICatalogItemDataSet; // @public export interface ICatalogItemAttribute extends ICatalogItemBase { @@ -185,6 +185,12 @@ export interface ICatalogItemBase extends ICatalogItemRef { updatedBy: string; } +// @public +export interface ICatalogItemComputedAttribute extends ICatalogItemBase { + // (undocumented) + type: "computedAttribute"; +} + // @public export interface ICatalogItemDashboard extends ICatalogItemBase { // (undocumented) @@ -254,6 +260,9 @@ export function InsightCodecProvider(input: PropsWithChildren<{ // @internal (undocumented) export function isCatalogItemAttribute(item: ICatalogItem | undefined | null): item is ICatalogItemAttribute; +// @internal (undocumented) +export function isCatalogItemComputedAttribute(item: ICatalogItem | undefined | null): item is ICatalogItemComputedAttribute; + // @internal (undocumented) export function isCatalogItemDashboard(item: ICatalogItem | undefined | null): item is ICatalogItemDashboard; @@ -273,7 +282,7 @@ export function isCatalogItemMeasure(item: ICatalogItem | undefined | null): ite export function isCatalogItemParameter(item: ICatalogItem | undefined | null): item is ICatalogItemParameter; // @public -export type ObjectType = Extract; +export type ObjectType = Extract; // @public (undocumented) export type OpenHandlerEvent = { diff --git a/libs/sdk-ui-catalog/package.json b/libs/sdk-ui-catalog/package.json index 3519976aa06..5fd17f45824 100644 --- a/libs/sdk-ui-catalog/package.json +++ b/libs/sdk-ui-catalog/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-ui-catalog", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData SDK - Analytics Catalog", "license": "MIT", "author": "GoodData Corporation", @@ -83,12 +83,12 @@ "@gooddata/oxlint-config": "workspace:*", "@gooddata/sdk-backend-mockingbird": "workspace:*", "@gooddata/stylelint-config": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.0", "@types/lodash-es": "^4.17.12", - "@types/raf": "^3.4.0", + "@types/raf": "3.4.3", "@types/react": "19.1.11", "@types/react-dom": "19.1.7", "@typescript-eslint/eslint-plugin": "8.58.0", diff --git a/libs/sdk-ui-catalog/src/asCode/AsCodeDeleteDialog.test.tsx b/libs/sdk-ui-catalog/src/asCode/AsCodeDeleteDialog.test.tsx index 5990f1c4614..4e6e9f949d0 100644 --- a/libs/sdk-ui-catalog/src/asCode/AsCodeDeleteDialog.test.tsx +++ b/libs/sdk-ui-catalog/src/asCode/AsCodeDeleteDialog.test.tsx @@ -2,7 +2,7 @@ import { type PropsWithChildren } from "react"; -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import type { IAnalyticalBackend } from "@gooddata/sdk-backend-spi"; @@ -10,7 +10,12 @@ import { BackendProvider, WorkspaceProvider } from "@gooddata/sdk-ui"; import { ToastsCenterContextProvider } from "@gooddata/sdk-ui-kit"; import { getAsCodeDescriptor } from "../asCodeRegistry.js"; -import type { ICatalogItemMeasure, ICatalogItemParameter } from "../catalogItem/types.js"; +import type { + ICatalogItemComputedAttribute, + ICatalogItemMeasure, + ICatalogItemParameter, +} from "../catalogItem/types.js"; +import { createTestComputedAttributeMutationPort } from "../computedAttribute/computedAttributeMutationPort.test.utils.js"; import { TestIntlProvider } from "../localization/TestIntlProvider.js"; import { createTestMetricMutationPort } from "../metric/metricMutationPort.test.utils.js"; import { ObjectTypes } from "../objectType/constants.js"; @@ -29,10 +34,22 @@ const parameterDescriptor = withMutationPort( createTestParameterMutationPort(), ); -function metricDescriptorWithCount(count: () => Promise): IAsCodeDescriptor { +function metricDescriptorWithReferences(load: () => Promise): IAsCodeDescriptor { return { ...metricDescriptor, - referenceCounted: { ...metricDescriptor.referenceCounted!, count }, + referenceCounted: { ...metricDescriptor.referenceCounted!, load }, + }; +} + +const computedAttributeDescriptor = withMutationPort( + getAsCodeDescriptor(ObjectTypes.COMPUTED_ATTRIBUTE)!, + createTestComputedAttributeMutationPort(), +); + +function computedAttributeDescriptorWithReferences(load: () => Promise): IAsCodeDescriptor { + return { + ...computedAttributeDescriptor, + referenceCounted: { ...computedAttributeDescriptor.referenceCounted!, load }, }; } @@ -80,6 +97,20 @@ const parameterItem: ICatalogItemParameter = { definition: { type: "NUMBER", defaultValue: 0 }, }; +const computedAttributeItem: ICatalogItemComputedAttribute = { + identifier: "rep_performance", + type: "computedAttribute", + title: "Rep Performance", + description: "", + tags: [], + createdBy: "user", + updatedBy: "user", + createdAt: null, + updatedAt: null, + isLocked: false, + isEditable: true, +}; + // ConfirmDialog marks disabled via `aria-disabled` (not the native prop) and may nest the label in a span. function getDeleteButton(): HTMLButtonElement { return screen.getByText("Delete", { selector: "button span, button" }).closest("button")!; @@ -99,30 +130,99 @@ describe("AsCodeDeleteDialog with a referencing-count lookup (metric)", () => { } it("keeps the delete action disabled until the usage lookup resolves", async () => { - let resolveLookup: (count: number) => void = () => {}; + let resolveLookup: (titles: string[]) => void = () => {}; renderMetric( - metricDescriptorWithCount( + metricDescriptorWithReferences( () => - new Promise((resolve) => { + new Promise((resolve) => { resolveLookup = resolve; }), ), ); expect(getDeleteButton()).toHaveAttribute("aria-disabled", "true"); - resolveLookup(0); + resolveLookup([]); await waitFor(() => expect(getDeleteButton()).toHaveAttribute("aria-disabled", "false")); }); it("surfaces the dependent-object warning once the usage lookup resolves", async () => { - renderMetric(metricDescriptorWithCount(vi.fn().mockResolvedValue(3))); + renderMetric(metricDescriptorWithReferences(vi.fn().mockResolvedValue(["A", "B", "C"]))); expect(await screen.findByText(/used by 3 objects/)).toBeInTheDocument(); expect(getDeleteButton()).toHaveAttribute("aria-disabled", "false"); }); + it("does not disclose the referencing objects for a type that does not opt in", async () => { + renderMetric(metricDescriptorWithReferences(vi.fn().mockResolvedValue(["A", "B", "C"]))); + + await screen.findByText(/used by 3 objects/); + expect(screen.queryByText("Show more")).toBeNull(); + expect(screen.queryByText("A")).toBeNull(); + }); + it("re-enables the delete action when the usage lookup fails so a failed lookup never traps the user", async () => { - renderMetric(metricDescriptorWithCount(vi.fn().mockRejectedValue(new Error("lookup failed")))); + renderMetric(metricDescriptorWithReferences(vi.fn().mockRejectedValue(new Error("lookup failed")))); + + await waitFor(() => expect(getDeleteButton()).toHaveAttribute("aria-disabled", "false")); + }); +}); + +describe("AsCodeDeleteDialog with a blocking referencing lookup (computed attribute)", () => { + function renderComputedAttribute(descriptor: IAsCodeDescriptor) { + return render( + , + { wrapper: Wrapper }, + ); + } + + it("refuses the deletion and explains why while a visualization still references it", async () => { + renderComputedAttribute( + computedAttributeDescriptorWithReferences(vi.fn().mockResolvedValue(["Rep performance"])), + ); + + expect( + await screen.findByText(/cannot be deleted because it is used in some visualizations/), + ).toBeInTheDocument(); + expect(screen.getByText("1 visualization")).toBeInTheDocument(); + expect(getDeleteButton()).toHaveAttribute("aria-disabled", "true"); + }); + + it("discloses the referencing visualizations behind the Show more toggle", async () => { + renderComputedAttribute( + computedAttributeDescriptorWithReferences( + vi.fn().mockResolvedValue(["Rep performance", "Won by band", "Pipeline"]), + ), + ); + + expect(await screen.findByText("3 visualizations")).toBeInTheDocument(); + expect(screen.queryByText("Rep performance")).toBeNull(); + + fireEvent.click(screen.getByText("Show more")); + + expect(screen.getByText("Rep performance")).toBeInTheDocument(); + expect(screen.getByText("Pipeline")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("Show less")); + + expect(screen.queryByText("Rep performance")).toBeNull(); + }); + + it("allows the deletion when nothing references it", async () => { + renderComputedAttribute(computedAttributeDescriptorWithReferences(vi.fn().mockResolvedValue([]))); + + await waitFor(() => expect(getDeleteButton()).toHaveAttribute("aria-disabled", "false")); + expect(screen.queryByText(/cannot be deleted/)).toBeNull(); + }); + + it("allows the deletion when the lookup fails, leaving the refusal to the backend", async () => { + renderComputedAttribute( + computedAttributeDescriptorWithReferences(vi.fn().mockRejectedValue(new Error("lookup failed"))), + ); await waitFor(() => expect(getDeleteButton()).toHaveAttribute("aria-disabled", "false")); }); diff --git a/libs/sdk-ui-catalog/src/asCode/AsCodeDeleteDialog.tsx b/libs/sdk-ui-catalog/src/asCode/AsCodeDeleteDialog.tsx index ac4183d623d..72750168205 100644 --- a/libs/sdk-ui-catalog/src/asCode/AsCodeDeleteDialog.tsx +++ b/libs/sdk-ui-catalog/src/asCode/AsCodeDeleteDialog.tsx @@ -5,7 +5,7 @@ import { useCallback, useState } from "react"; import { FormattedMessage, defineMessages, useIntl } from "react-intl"; import { useBackendStrict, useCancelablePromise, useWorkspaceStrict } from "@gooddata/sdk-ui"; -import { ConfirmDialog, useToastMessage } from "@gooddata/sdk-ui-kit"; +import { ConfirmDialog, Message, UiLink, useToastMessage } from "@gooddata/sdk-ui-kit"; import type { ICatalogItem } from "../catalogItem/types.js"; @@ -14,6 +14,8 @@ import { useMutationPort } from "./useMutationPort.js"; const messages = defineMessages({ cancel: { id: "analyticsCatalog.asCode.dialog.cancel" }, + showMore: { id: "analyticsCatalog.asCode.dialog.delete.showMore" }, + showLess: { id: "analyticsCatalog.asCode.dialog.delete.showLess" }, }); type Props = { @@ -33,12 +35,18 @@ export function AsCodeDeleteDialog({ descriptor, item, onClose, onDeleted }: Pro const { messages: msg, referenceCounted } = descriptor; const [isDeleting, setIsDeleting] = useState(false); + const [areReferencesShown, setAreReferencesShown] = useState(false); + // Block deletion until the usage lookup resolves, so the dependent-object warning surfaces first. const { result, status } = useCancelablePromise( - { promise: referenceCounted ? () => referenceCounted.count(backend, workspace, item) : undefined }, + { promise: referenceCounted ? () => referenceCounted.load(backend, workspace, item) : undefined }, [item, backend, workspace], ); - const referencingCount = !referenceCounted || status === "error" ? 0 : result; + const references = !referenceCounted || status === "error" ? [] : result; + const referencingCount = references?.length; + // A failed lookup leaves the count at zero, so a blocking type stays deletable rather than + // becoming permanently undeletable on a transient error; the backend refuses it either way. + const isBlocked = Boolean(referenceCounted?.blockedBody) && Boolean(referencingCount); const displayName = item.title || item.identifier; @@ -67,7 +75,7 @@ export function AsCodeDeleteDialog({ descriptor, item, onClose, onDeleted }: Pro cancelButtonText={intl.formatMessage(messages.cancel)} submitButtonText={intl.formatMessage(msg.deleteSubmit)} isPositive={false} - isSubmitDisabled={isDeleting || referencingCount === undefined} + isSubmitDisabled={isDeleting || referencingCount === undefined || isBlocked} isCancelDisabled={isDeleting} showProgressIndicator={isDeleting} onCancel={handleClose} @@ -76,18 +84,49 @@ export function AsCodeDeleteDialog({ descriptor, item, onClose, onDeleted }: Pro displayCloseButton={!isDeleting} > {chunks}, }} /> {referencingCount && referenceCounted ? ( -
- +
+ + {chunks}, + }} + /> + {referenceCounted.listReferences ? ( + <> + {" "} + setAreReferencesShown(!areReferencesShown)} + aria-expanded={areReferencesShown} + > + {intl.formatMessage( + areReferencesShown ? messages.showLess : messages.showMore, + )} + + + ) : null} + + {referenceCounted.listReferences && areReferencesShown ? ( +
    + {references?.map((title) => ( +
  • {title}
  • + ))} +
+ ) : null} +
) : null} diff --git a/libs/sdk-ui-catalog/src/asCode/AsCodeDetailActions.test.tsx b/libs/sdk-ui-catalog/src/asCode/AsCodeDetailActions.test.tsx index fb6f53c8d08..7f17fd997fd 100644 --- a/libs/sdk-ui-catalog/src/asCode/AsCodeDetailActions.test.tsx +++ b/libs/sdk-ui-catalog/src/asCode/AsCodeDetailActions.test.tsx @@ -38,7 +38,7 @@ const metricDescriptor: IAsCodeDescriptor = withMutationPort( seed: { load: async () => loadedMeasure, loadError: metricSeed.loadError }, referenceCounted: { ...typedMetricDescriptor.referenceCounted!, - count: async () => 0, + load: async () => [], }, }, createTestMetricMutationPort(), diff --git a/libs/sdk-ui-catalog/src/asCode/descriptor.test.tsx b/libs/sdk-ui-catalog/src/asCode/descriptor.test.tsx index c65d2006fe6..fe4fccf228b 100644 --- a/libs/sdk-ui-catalog/src/asCode/descriptor.test.tsx +++ b/libs/sdk-ui-catalog/src/asCode/descriptor.test.tsx @@ -4,9 +4,15 @@ import { renderHook } from "@testing-library/react"; import type { PropsWithChildren } from "react"; import { describe, expect, it } from "vitest"; -import type { IMeasureMetadataObject, IMeasureMetadataObjectDefinition } from "@gooddata/sdk-model"; +import type { + IComputedAttributeMetadataObject, + IComputedAttributeMetadataObjectDefinition, + IMeasureMetadataObject, + IMeasureMetadataObjectDefinition, +} from "@gooddata/sdk-model"; import { getAsCodeDescriptor } from "../asCodeRegistry.js"; +import { computedAttributeDescriptor } from "../computedAttribute/computedAttributeDescriptor.js"; import { TestIntlProvider } from "../localization/TestIntlProvider.js"; import { metricDescriptor } from "../metric/metricDescriptor.js"; import { ObjectTypes } from "../objectType/constants.js"; @@ -28,6 +34,9 @@ describe("registry", () => { it("resolves the descriptor for each as-code type", () => { expect(getAsCodeDescriptor(ObjectTypes.METRIC)?.objectType).toBe(ObjectTypes.METRIC); expect(getAsCodeDescriptor(ObjectTypes.PARAMETER)?.objectType).toBe(ObjectTypes.PARAMETER); + expect(getAsCodeDescriptor(ObjectTypes.COMPUTED_ATTRIBUTE)?.objectType).toBe( + ObjectTypes.COMPUTED_ATTRIBUTE, + ); }); it("returns undefined for a type that is not editable as code", () => { @@ -97,6 +106,72 @@ describe("metricDescriptor", () => { }); }); +describe("computedAttributeDescriptor", () => { + it("emptyDefinition seeds a blank computed attribute with the given title and no maql", () => { + expect(computedAttributeDescriptor.emptyDefinition("New CA")).toMatchObject({ + type: "computedAttribute", + title: "New CA", + expression: "", + }); + }); + + it("loads the full object for editing — the catalog item carries no MAQL", () => { + expect(isLoadSeed(computedAttributeDescriptor.seed)).toBe(true); + }); + + it("toCopy bumps the title and preserves the non-YAML fields of the source", () => { + const source: IComputedAttributeMetadataObject = { + id: "rep_performance", + uri: "rep_performance", + ref: { identifier: "rep_performance", type: "computedAttribute" }, + type: "computedAttribute", + title: "Rep Performance", + description: "Band", + tags: [], + production: true, + deprecated: false, + unlisted: false, + expression: 'SELECT CASE WHEN {metric/x} > 1 THEN "High" ELSE "Low" END', + dataType: "STRING", + displayForms: [], + }; + expect(computedAttributeDescriptor.toCopy(source)).toMatchObject({ + title: "Rep Performance (2)", + expression: 'SELECT CASE WHEN {metric/x} > 1 THEN "High" ELSE "Low" END', + dataType: "STRING", + }); + }); + + it("reconcile overlays the parsed YAML onto the base definition, keeping non-YAML fields", () => { + const base: IComputedAttributeMetadataObjectDefinition = { + id: "rep_performance", + type: "computedAttribute", + title: "Original", + description: "", + tags: [], + expression: "SELECT 1", + dataType: "STRING", + }; + const parsed: IComputedAttributeMetadataObjectDefinition = { + id: "rep_performance", + type: "computedAttribute", + title: "Edited", + description: "", + tags: [], + expression: "SELECT 2", + }; + const { result } = renderHook(() => computedAttributeDescriptor.useEditing(), { + wrapper: EditingWrapper, + }); + expect(result.current?.reconcile?.(base, parsed)).toMatchObject({ + id: "rep_performance", + title: "Edited", + expression: "SELECT 2", + dataType: "STRING", + }); + }); +}); + describe("parameterDescriptor", () => { const paramDefinition = { id: "param.id", diff --git a/libs/sdk-ui-catalog/src/asCode/descriptor.ts b/libs/sdk-ui-catalog/src/asCode/descriptor.ts index 985c7418979..441029065d3 100644 --- a/libs/sdk-ui-catalog/src/asCode/descriptor.ts +++ b/libs/sdk-ui-catalog/src/asCode/descriptor.ts @@ -15,6 +15,7 @@ export type AsCodeFeatureFlag = Parameters[0]; export type AsCodeObjectType = | typeof ObjectTypes.METRIC | typeof ObjectTypes.PARAMETER + | typeof ObjectTypes.COMPUTED_ATTRIBUTE | typeof ObjectTypes.VISUALIZATION; export interface IAsCodeMessages { @@ -112,8 +113,16 @@ export function loadErrorOf(descriptor: IAsCodeDescriptor): MessageDescriptor | /** @internal */ export type AsCodeReferenceCount = { - count(backend: IAnalyticalBackend, workspace: string, item: TItem): Promise; + /** Titles of the objects referencing the item; the count in the warning is their number. */ + load(backend: IAnalyticalBackend, workspace: string, item: TItem): Promise; usageWarning: MessageDescriptor; + /** Discloses the referencing titles behind a Show more/Show less toggle. */ + listReferences?: boolean; + /** + * Refuses the deletion while any reference exists — for a type the backend rejects rather than + * cascades — and replaces the delete body with this text. + */ + blockedBody?: MessageDescriptor; }; /** diff --git a/libs/sdk-ui-catalog/src/asCodeRegistry.ts b/libs/sdk-ui-catalog/src/asCodeRegistry.ts index 7032c73424f..144a50f1145 100644 --- a/libs/sdk-ui-catalog/src/asCodeRegistry.ts +++ b/libs/sdk-ui-catalog/src/asCodeRegistry.ts @@ -3,6 +3,7 @@ import { useMemo } from "react"; import type { AsCodeObjectType, IAsCodeDescriptor } from "./asCode/descriptor.js"; +import { computedAttributeDescriptor } from "./computedAttribute/computedAttributeDescriptor.js"; import { visualizationDescriptor } from "./insight/insightDescriptor.js"; import { metricDescriptor } from "./metric/metricDescriptor.js"; import { ObjectTypes } from "./objectType/constants.js"; @@ -13,6 +14,8 @@ import { useFeatureFlags, useWorkspacePermission } from "./permission/Permission /** @internal */ export const asCodeDescriptors: Record = { [ObjectTypes.METRIC]: metricDescriptor, + // Key order drives the create-menu order. + [ObjectTypes.COMPUTED_ATTRIBUTE]: computedAttributeDescriptor, [ObjectTypes.PARAMETER]: parameterDescriptor, [ObjectTypes.VISUALIZATION]: visualizationDescriptor, }; @@ -47,14 +50,16 @@ export function useCreatableObjectTypes(): ReadonlySet { // One unconditional hook call per registered type (fixed count for the rules of hooks). const metric = useIsAsCodeTypeCreatable(asCodeDescriptors[ObjectTypes.METRIC]); const parameter = useIsAsCodeTypeCreatable(asCodeDescriptors[ObjectTypes.PARAMETER]); + const computedAttribute = useIsAsCodeTypeCreatable(asCodeDescriptors[ObjectTypes.COMPUTED_ATTRIBUTE]); const visualization = useIsAsCodeTypeCreatable(asCodeDescriptors[ObjectTypes.VISUALIZATION]); return useMemo(() => { // The `Record` makes a newly registered type a compile error until listed here. const creatable: Record = { [ObjectTypes.METRIC]: metric, + [ObjectTypes.COMPUTED_ATTRIBUTE]: computedAttribute, [ObjectTypes.PARAMETER]: parameter, [ObjectTypes.VISUALIZATION]: visualization, }; return new Set((Object.keys(creatable) as AsCodeObjectType[]).filter((type) => creatable[type])); - }, [metric, parameter, visualization]); + }, [computedAttribute, metric, parameter, visualization]); } diff --git a/libs/sdk-ui-catalog/src/catalogDetail/hooks/useCatalogItemLoad.ts b/libs/sdk-ui-catalog/src/catalogDetail/hooks/useCatalogItemLoad.ts index 6506e876e1e..31efeeed216 100644 --- a/libs/sdk-ui-catalog/src/catalogDetail/hooks/useCatalogItemLoad.ts +++ b/libs/sdk-ui-catalog/src/catalogDetail/hooks/useCatalogItemLoad.ts @@ -11,6 +11,7 @@ import { import { convertAttributeToCatalogItem, + convertComputedAttributeToCatalogItem, convertDashboardToCatalogItem, convertDataSetToCatalogItem, convertFactToCatalogItem, @@ -160,6 +161,19 @@ async function loadObjectDefinition( identifier: id, }) .then(convertParameterToCatalogItem); + case "computedAttribute": + return workspace + .computedAttributes() + .getComputedAttribute( + { + type: "computedAttribute", + identifier: id, + }, + { + loadUserData: true, + }, + ) + .then(convertComputedAttributeToCatalogItem); case "fact": return workspace .facts() diff --git a/libs/sdk-ui-catalog/src/catalogItem/catalogBackend.test.utils.ts b/libs/sdk-ui-catalog/src/catalogItem/catalogBackend.test.utils.ts index 5d7d614ccb2..41c8cf27252 100644 --- a/libs/sdk-ui-catalog/src/catalogItem/catalogBackend.test.utils.ts +++ b/libs/sdk-ui-catalog/src/catalogItem/catalogBackend.test.utils.ts @@ -87,6 +87,10 @@ const ENTITY_FACTORIES: Record unknown> = { ...metadataEntity(id, "parameter"), definition: { type: "NUMBER", defaultValue: 0 }, }), + [ObjectTypes.COMPUTED_ATTRIBUTE]: (id) => ({ + ...metadataEntity(id, "computedAttribute"), + expression: "SELECT 1", + }), [ObjectTypes.ATTRIBUTE]: (id) => metadataEntity(id, "attribute"), [ObjectTypes.FACT]: (id) => metadataEntity(id, "fact"), [ObjectTypes.DATASET]: (id) => metadataEntity(id, "dataSet"), @@ -148,6 +152,7 @@ const QUERY_SERVICES = { [ObjectTypes.VISUALIZATION]: ["insights", "getInsightsQuery"], [ObjectTypes.METRIC]: ["measures", "getMeasuresQuery"], [ObjectTypes.PARAMETER]: ["parameters", "getParametersQuery"], + [ObjectTypes.COMPUTED_ATTRIBUTE]: ["computedAttributes", "getComputedAttributesQuery"], [ObjectTypes.ATTRIBUTE]: ["attributes", "getAttributesQuery"], [ObjectTypes.FACT]: ["facts", "getFactsQuery"], [ObjectTypes.DATASET]: ["datasets", "getDatasetsQuery"], diff --git a/libs/sdk-ui-catalog/src/catalogItem/converter.ts b/libs/sdk-ui-catalog/src/catalogItem/converter.ts index 3940420aab4..3f9ced0a4ba 100644 --- a/libs/sdk-ui-catalog/src/catalogItem/converter.ts +++ b/libs/sdk-ui-catalog/src/catalogItem/converter.ts @@ -4,6 +4,7 @@ import type { IAnalyticsCatalogTrendingObject } from "@gooddata/sdk-backend-spi" import { type GenAIObjectType, type IAttributeMetadataObject, + type IComputedAttributeMetadataObject, type IDashboard, type IDataSetMetadataObject, type IFactMetadataObject, @@ -14,6 +15,7 @@ import { type IParameterMetadataObject, type IUser, isAttributeMetadataObject, + isComputedAttributeMetadataObject, isDashboard, isDataSetMetadataObject, isFactMetadataObject, @@ -29,6 +31,7 @@ import { parseBackendDate } from "../utils/date.js"; import type { ICatalogItem, ICatalogItemAttribute, + ICatalogItemComputedAttribute, ICatalogItemDashboard, ICatalogItemDataSet, ICatalogItemFact, @@ -44,6 +47,7 @@ export function convertEntityToCatalogItem( | IListedDashboard | IMeasureMetadataObject | IParameterMetadataObject + | IComputedAttributeMetadataObject | IFactMetadataObject | IAttributeMetadataObject | IDataSetMetadataObject @@ -61,6 +65,9 @@ export function convertEntityToCatalogItem( if (isParameterMetadataObject(entity)) { return convertParameterToCatalogItem(entity); } + if (isComputedAttributeMetadataObject(entity)) { + return convertComputedAttributeToCatalogItem(entity); + } if (isFactMetadataObject(entity)) { return convertFactToCatalogItem(entity); } @@ -175,6 +182,26 @@ export function convertParameterToCatalogItem(parameter: IParameterMetadataObjec }; } +export function convertComputedAttributeToCatalogItem( + computedAttribute: IComputedAttributeMetadataObject, +): ICatalogItemComputedAttribute { + const updatedAt = computedAttribute.updated || computedAttribute.created; + + return { + identifier: computedAttribute.id, + type: "computedAttribute", + title: computedAttribute.title, + description: computedAttribute.description, + tags: computedAttribute.tags ?? [], + createdBy: getDisplayName(computedAttribute.createdBy), + createdAt: computedAttribute.created ? parseBackendDate(computedAttribute.created) : null, + updatedBy: getDisplayName(computedAttribute.updatedBy), + updatedAt: updatedAt ? parseBackendDate(updatedAt) : null, + isLocked: computedAttribute.isLocked ?? false, + isEditable: true, + }; +} + export function convertAttributeToCatalogItem(attribute: IAttributeMetadataObject): ICatalogItemAttribute { return { identifier: attribute.id, diff --git a/libs/sdk-ui-catalog/src/catalogItem/guards.ts b/libs/sdk-ui-catalog/src/catalogItem/guards.ts index 88131367b9f..f448c05ae71 100644 --- a/libs/sdk-ui-catalog/src/catalogItem/guards.ts +++ b/libs/sdk-ui-catalog/src/catalogItem/guards.ts @@ -3,6 +3,7 @@ import type { ICatalogItem, ICatalogItemAttribute, + ICatalogItemComputedAttribute, ICatalogItemDashboard, ICatalogItemDataSet, ICatalogItemFact, @@ -42,6 +43,15 @@ export function isCatalogItemParameter(item: ICatalogItem | undefined | null): i return item?.type === "parameter"; } +/** + * @internal + */ +export function isCatalogItemComputedAttribute( + item: ICatalogItem | undefined | null, +): item is ICatalogItemComputedAttribute { + return item?.type === "computedAttribute"; +} + /** * @internal */ diff --git a/libs/sdk-ui-catalog/src/catalogItem/query.ts b/libs/sdk-ui-catalog/src/catalogItem/query.ts index da6cdfb715a..99d55cb9794 100644 --- a/libs/sdk-ui-catalog/src/catalogItem/query.ts +++ b/libs/sdk-ui-catalog/src/catalogItem/query.ts @@ -17,6 +17,7 @@ import type { ObjectType } from "../objectType/types.js"; import type { ICatalogItem, ICatalogItemAttribute, + ICatalogItemComputedAttribute, ICatalogItemDashboard, ICatalogItemDataSet, ICatalogItemFact, @@ -194,6 +195,40 @@ export function getParametersQuery({ .withMethod("POST"); } +export function getComputedAttributesQuery({ + backend, + workspace, + search, + origin, + id, + excludeId, + createdBy, + excludeCreatedBy, + tags, + excludeTags, + pageSize = PAGE_SIZE, +}: ICatalogItemQueryOptions) { + return backend + .workspace(workspace) + .computedAttributes() + .getComputedAttributesQuery() + .withPage(0) + .withSize(pageSize) + .withInclude(["createdBy", "modifiedBy"]) + .withSorting(["title,asc"]) + .withOrigin(origin) + .withFilter({ + search, + id, + excludeId, + tags, + excludeTags, + createdBy, + excludeCreatedBy, + }) + .withMethod("POST"); +} + export function getAttributesQuery({ backend, workspace, @@ -318,6 +353,8 @@ export function updateCatalogItem(backend: IAnalyticalBackend, workspace: string return updateDataSetCatalogItemMeta(backend, workspace, item); case "parameter": return updateParameterCatalogItemMeta(backend, workspace, item); + case "computedAttribute": + return updateComputedAttributeCatalogItemMeta(backend, workspace, item); default: throw new Error(`Unsupported catalog item type`); } @@ -465,6 +502,22 @@ export function deleteParameterCatalogItem(backend: IAnalyticalBackend, workspac return backend.workspace(workspace).parameters().deleteParameter(ref); } +function updateComputedAttributeCatalogItemMeta( + backend: IAnalyticalBackend, + workspace: string, + item: ICatalogItemComputedAttribute, +) { + return backend + .workspace(workspace) + .computedAttributes() + .updateComputedAttributeMeta({ + ...buildIdentity(item), + title: item.title, + description: item.description, + tags: item.tags, + }); +} + export function createMeasureCatalogItem( backend: IAnalyticalBackend, workspace: string, diff --git a/libs/sdk-ui-catalog/src/catalogItem/types.ts b/libs/sdk-ui-catalog/src/catalogItem/types.ts index 7be0a6e3f7f..20f2708e37e 100644 --- a/libs/sdk-ui-catalog/src/catalogItem/types.ts +++ b/libs/sdk-ui-catalog/src/catalogItem/types.ts @@ -56,6 +56,7 @@ export type ICatalogItem = | ICatalogItemInsight | ICatalogItemMeasure | ICatalogItemParameter + | ICatalogItemComputedAttribute | ICatalogItemAttribute | ICatalogItemFact | ICatalogItemDataSet; @@ -124,6 +125,19 @@ export interface ICatalogItemParameter extends ICatalogItemBase { definition: IParameterDefinition; } +/** + * Catalog item for computed attributes. + * + * @remarks + * The MAQL expression and the value-shaping fields are not carried here; the editor loads the full + * computed attribute on demand. + * + * @public + */ +export interface ICatalogItemComputedAttribute extends ICatalogItemBase { + type: "computedAttribute"; +} + /** * Catalog item for attributes. * @public diff --git a/libs/sdk-ui-catalog/src/catalogItem/useCatalogEndpoints.ts b/libs/sdk-ui-catalog/src/catalogItem/useCatalogEndpoints.ts index 3058ae3d285..12ea44424a3 100644 --- a/libs/sdk-ui-catalog/src/catalogItem/useCatalogEndpoints.ts +++ b/libs/sdk-ui-catalog/src/catalogItem/useCatalogEndpoints.ts @@ -4,6 +4,7 @@ import { useMemo } from "react"; import type { IAttributesQueryResult, + IComputedAttributesQueryResult, IDashboardsQueryResult, IDatasetsQueryResult, IFactsQueryResult, @@ -13,11 +14,14 @@ import type { } from "@gooddata/sdk-backend-spi"; import type { IFeatureFlags, Identifier, ObjectOrigin } from "@gooddata/sdk-model"; +import { COMPUTED_ATTRIBUTE_FEATURE_FLAG } from "../computedAttribute/gate.js"; import { ObjectTypes } from "../objectType/constants.js"; import { type ObjectType } from "../objectType/types.js"; +import { useFeatureFlags } from "../permission/PermissionsContext.js"; import { getAttributesQuery, + getComputedAttributesQuery, getDashboardsQuery, getDateDatasetsQuery, getFactsQuery, @@ -34,7 +38,8 @@ export type EndpointResult = | IInsightsQueryResult | IAttributesQueryResult | IDatasetsQueryResult - | IParametersQueryResult; + | IParametersQueryResult + | IComputedAttributesQueryResult; export type FeedEndpoint = { type: ObjectType; @@ -132,6 +137,12 @@ const ENDPOINTS: readonly ICatalogEndpoint[] = [ query: (opts) => getParametersQuery(opts).query(), gatedBy: ["enableParameters"], }, + { + type: ObjectTypes.COMPUTED_ATTRIBUTE, + query: (opts) => getComputedAttributesQuery(opts).query(), + gatedBy: [COMPUTED_ATTRIBUTE_FEATURE_FLAG], + cannotFilterBy: ["certification"], + }, { type: ObjectTypes.ATTRIBUTE, query: (opts) => getAttributesQuery(opts).query(), @@ -157,6 +168,14 @@ export function useCatalogEndpoints( return useMemo(() => selectCatalogEndpoints(types, queryOptions, flags), [flags, queryOptions, types]); } +export function useEnabledObjectTypes(): ObjectType[] { + const flags = useFeatureFlags(); + return useMemo( + () => ENDPOINTS.filter((endpoint) => isEndpointEnabled(endpoint, flags)).map(({ type }) => type), + [flags], + ); +} + export function selectCatalogEndpoints( types: readonly ObjectType[], options: ICatalogItemQueryOptions, @@ -173,7 +192,7 @@ export function selectCatalogEndpoints( continue; } // gates rule: every listed flag must be on - if (endpoint.gatedBy?.some((flag) => !flags?.[flag])) { + if (!isEndpointEnabled(endpoint, flags)) { continue; } // compatibility rule: backend rejects these filters on this endpoint @@ -185,6 +204,10 @@ export function selectCatalogEndpoints( return result; } +function isEndpointEnabled(endpoint: ICatalogEndpoint, flags: IFeatureFlags | undefined): boolean { + return !endpoint.gatedBy?.some((flag) => !flags?.[flag]); +} + const isSlotActive: Record boolean> = { createdBy: (options) => (options.createdBy?.length ?? 0) > 0, excludeCreatedBy: (options) => (options.excludeCreatedBy?.length ?? 0) > 0, diff --git a/libs/sdk-ui-catalog/src/catalogItem/useCatalogItemFeed.ts b/libs/sdk-ui-catalog/src/catalogItem/useCatalogItemFeed.ts index 31939bdc24a..65fa3d9bd6e 100644 --- a/libs/sdk-ui-catalog/src/catalogItem/useCatalogItemFeed.ts +++ b/libs/sdk-ui-catalog/src/catalogItem/useCatalogItemFeed.ts @@ -105,6 +105,7 @@ function getTotalCountByType(endpoints: FeedEndpoint[], totalCounts: number[]) { [ObjectTypes.FACT]: 0, [ObjectTypes.DATASET]: 0, [ObjectTypes.PARAMETER]: 0, + [ObjectTypes.COMPUTED_ATTRIBUTE]: 0, }; endpoints.forEach((endpoint, idx) => { diff --git a/libs/sdk-ui-catalog/src/catalogItem/useCatalogItemSemanticSearch.ts b/libs/sdk-ui-catalog/src/catalogItem/useCatalogItemSemanticSearch.ts index aa34d8bb090..2f7b7e91bf2 100644 --- a/libs/sdk-ui-catalog/src/catalogItem/useCatalogItemSemanticSearch.ts +++ b/libs/sdk-ui-catalog/src/catalogItem/useCatalogItemSemanticSearch.ts @@ -95,7 +95,8 @@ function convertTypesToGenAiTypes(types: ObjectType[]): GenAIObjectType[] { } return types .map((a) => { - if (a === "parameter") { + // Neither type is indexed for semantic search. + if (a === "parameter" || a === "computedAttribute") { return null; } return mapGenAIObjectType(a); diff --git a/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeCompletions.test.ts b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeCompletions.test.ts new file mode 100644 index 00000000000..fa864f43fa6 --- /dev/null +++ b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeCompletions.test.ts @@ -0,0 +1,34 @@ +// (C) 2026 GoodData Corporation + +import { CompletionContext } from "@codemirror/autocomplete"; +import { EditorState } from "@codemirror/state"; +import { describe, expect, it } from "vitest"; + +import { computedAttributeCompletions } from "./computedAttributeCompletions.js"; + +function completeAt(docWithCursor: string) { + const pos = docWithCursor.indexOf("|"); + const doc = docWithCursor.replace("|", ""); + const state = EditorState.create({ doc }); + return computedAttributeCompletions(new CompletionContext(state, pos, true)); +} + +describe("computedAttributeCompletions", () => { + it("offers the computed attribute top-level keys at column zero", () => { + const result = completeAt("|"); + const labels = result && !(result instanceof Promise) ? result.options.map((o) => o.label) : null; + + expect(labels).toEqual(expect.arrayContaining(["type", "id", "title", "maql", "locale"])); + }); + + it("anchors completion at the start of a partially typed key", () => { + const result = completeAt("ma|"); + + expect(result && !(result instanceof Promise) ? result.from : null).toBe(0); + }); + + it("declines on an indented line inside a sequence value", () => { + // The YAML is flat; an indented line sits under `tags:`, where top-level keys must not show. + expect(completeAt("tags:\n |")).toBeNull(); + }); +}); diff --git a/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeCompletions.ts b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeCompletions.ts new file mode 100644 index 00000000000..c6af50be51f --- /dev/null +++ b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeCompletions.ts @@ -0,0 +1,28 @@ +// (C) 2026 GoodData Corporation + +import type { CompletionContext, CompletionResult } from "@codemirror/autocomplete"; + +import { COMPUTED_ATTRIBUTE_SCHEMA_KEYS } from "./computedAttributeSchema.js"; + +const rootOptions = (COMPUTED_ATTRIBUTE_SCHEMA_KEYS[""] ?? []).map((label) => ({ + label, + type: "property", +})); + +/** + * Completion source for the computed attribute YAML schema. The schema is a flat mapping, so + * completion only offers the top-level property names, and only at column zero — an indented line + * sits inside a sequence value (e.g. under `tags:`), where those keys must not be offered. + */ +export function computedAttributeCompletions(context: CompletionContext): CompletionResult | null { + const line = context.state.doc.lineAt(context.pos); + const before = line.text.slice(0, context.pos - line.from); + + // Trigger only for a key typed at column zero; any leading whitespace means the cursor is nested. + const match = before.match(/^([\w-]*)$/); + if (!match) { + return null; + } + + return { from: context.pos - match[1].length, options: rootOptions, validFor: /^[\w-]*$/ }; +} diff --git a/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeConverter.test.ts b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeConverter.test.ts new file mode 100644 index 00000000000..f91060f944b --- /dev/null +++ b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeConverter.test.ts @@ -0,0 +1,195 @@ +// (C) 2026 GoodData Corporation + +import { describe, expect, it } from "vitest"; + +import type { + IComputedAttributeMetadataObject, + IComputedAttributeMetadataObjectDefinition, +} from "@gooddata/sdk-model"; + +import { + computedAttributeYamlToDefinition, + definitionToComputedAttributeYaml, + reconcileComputedAttributeDefinition, +} from "./computedAttributeConverter.js"; +import type { ComputedAttributeSchema } from "./computedAttributeSchema.js"; + +const MAQL = 'SELECT CASE WHEN {metric/won_activities} > 50 THEN "High" ELSE "Low" END'; + +const computedAttribute: IComputedAttributeMetadataObject = { + id: "rep_performance", + uri: "rep_performance", + ref: { identifier: "rep_performance", type: "computedAttribute" }, + type: "computedAttribute", + title: "Rep Performance", + description: "Sales rep performance band", + tags: ["sales"], + production: true, + deprecated: false, + unlisted: false, + expression: MAQL, + displayForms: [], +}; + +describe("computedAttributeYamlToDefinition", () => { + it("maps the AAC shape to a backend computed attribute definition", () => { + const yaml: ComputedAttributeSchema = { + type: "computed_attribute", + id: "rep_performance", + title: "Rep Performance", + description: "Sales rep performance band", + tags: ["sales"], + maql: MAQL, + locale: "en-US", + }; + + expect(computedAttributeYamlToDefinition(yaml)).toEqual({ + type: "computedAttribute", + id: "rep_performance", + title: "Rep Performance", + description: "Sales rep performance band", + tags: ["sales"], + expression: MAQL, + locale: "en-US", + }); + }); + + it("omits the id when absent so the server derives one", () => { + const definition = computedAttributeYamlToDefinition({ + type: "computed_attribute", + maql: MAQL, + }); + expect(definition).not.toHaveProperty("id"); + expect(definition).toMatchObject({ type: "computedAttribute", expression: MAQL }); + }); + + it("omits the locale when absent rather than defaulting one", () => { + expect( + computedAttributeYamlToDefinition({ type: "computed_attribute", maql: MAQL }), + ).not.toHaveProperty("locale"); + }); +}); + +describe("definitionToComputedAttributeYaml", () => { + it("maps a loaded computed attribute to the canonical AAC shape", () => { + expect(definitionToComputedAttributeYaml(computedAttribute)).toEqual({ + type: "computed_attribute", + id: "rep_performance", + title: "Rep Performance", + description: "Sales rep performance band", + tags: ["sales"], + maql: MAQL, + }); + }); + + it("omits empty meta fields", () => { + expect( + definitionToComputedAttributeYaml({ ...computedAttribute, description: "", tags: [] }), + ).toEqual({ + type: "computed_attribute", + id: "rep_performance", + title: "Rep Performance", + maql: MAQL, + }); + }); + + it("omits the id when the definition has none", () => { + expect( + definitionToComputedAttributeYaml({ + type: "computedAttribute", + title: "Copy", + description: "", + tags: [], + expression: MAQL, + }), + ).toEqual({ type: "computed_attribute", title: "Copy", maql: MAQL }); + }); +}); + +describe("round trip", () => { + it("preserves a computed attribute through definition mapping", () => { + const yaml = definitionToComputedAttributeYaml(computedAttribute); + expect(computedAttributeYamlToDefinition(yaml as ComputedAttributeSchema)).toEqual({ + type: "computedAttribute", + id: "rep_performance", + title: "Rep Performance", + description: "Sales rep performance band", + tags: ["sales"], + expression: MAQL, + }); + }); +}); + +describe("reconcileComputedAttributeDefinition", () => { + it("overlays the YAML fields, keeping the fields the YAML cannot express", () => { + const base: IComputedAttributeMetadataObject = { + ...computedAttribute, + format: "#,##0", + metricType: "CURRENCY", + dataType: "STRING", + isNullable: true, + nullValue: "N/A", + }; + const merged = reconcileComputedAttributeDefinition( + base, + computedAttributeYamlToDefinition({ + type: "computed_attribute", + id: "rep_performance", + title: "Renamed", + maql: "SELECT 2", + }), + ); + + expect(merged).toMatchObject({ + title: "Renamed", + expression: "SELECT 2", + format: "#,##0", + metricType: "CURRENCY", + dataType: "STRING", + isNullable: true, + nullValue: "N/A", + }); + }); + + // A copied source carrying fields the YAML cannot represent. + const copied: IComputedAttributeMetadataObjectDefinition = { + type: "computedAttribute", + id: "rep_performance_2", + title: "Rep Performance (2)", + description: "Sales rep performance band", + tags: ["sales"], + expression: MAQL, + dataType: "STRING", + locale: "en-US", + }; + + it("takes the id from the YAML, not the copied source", () => { + const merged = reconcileComputedAttributeDefinition( + copied, + computedAttributeYamlToDefinition({ + type: "computed_attribute", + id: "renamed_id", + maql: MAQL, + }), + ); + expect(merged.id).toBe("renamed_id"); + expect(merged.dataType).toBe("STRING"); + }); + + it("drops the id when removed from the YAML so the server derives one", () => { + const merged = reconcileComputedAttributeDefinition( + copied, + computedAttributeYamlToDefinition({ type: "computed_attribute", maql: MAQL }), + ); + expect(merged).not.toHaveProperty("id"); + expect(merged.dataType).toBe("STRING"); + }); + + it("clears the collation when the locale line is removed", () => { + const merged = reconcileComputedAttributeDefinition( + copied, + computedAttributeYamlToDefinition({ type: "computed_attribute", maql: MAQL }), + ); + expect(merged.locale).toBeUndefined(); + }); +}); diff --git a/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeConverter.ts b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeConverter.ts new file mode 100644 index 00000000000..bb50de5d166 --- /dev/null +++ b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeConverter.ts @@ -0,0 +1,101 @@ +// (C) 2026 GoodData Corporation + +import type { v1 } from "@gooddata/sdk-code-schemas"; +import type { IComputedAttributeMetadataObjectDefinition } from "@gooddata/sdk-model"; + +import type { ComputedAttributeSchema } from "./computedAttributeSchema.js"; + +/** + * Canonical AAC computed attribute object with an optional `id`. + * + * The `id` is dropped when a computed attribute is created or duplicated so the server derives one; + * a loaded computed attribute always carries it. Field names otherwise mirror + * `@gooddata/sdk-code-schemas` `v1.ComputedAttribute`. + */ +export type ComputedAttributeYaml = Omit & { id?: string }; + +/** + * Maps a computed attribute definition (or a loaded computed attribute, which is a superset) to the + * canonical AAC object for YAML serialization. + * + * Optional meta fields are omitted when empty so the rendered YAML stays terse, matching the + * analytics-as-code tooling output. The `id` is omitted when the definition has none (e.g. seeding + * the create dialog from a duplicate) so the server derives a fresh one. + */ +export function definitionToComputedAttributeYaml( + definition: IComputedAttributeMetadataObjectDefinition, +): ComputedAttributeYaml { + return { + type: "computed_attribute", + ...(definition.id === undefined ? {} : { id: definition.id }), + title: definition.title, + ...(definition.description ? { description: definition.description } : {}), + ...(definition.tags && definition.tags.length > 0 ? { tags: definition.tags } : {}), + maql: definition.expression, + ...(definition.locale ? { locale: definition.locale } : {}), + }; +} + +/** + * Maps a validated AAC computed attribute object to a backend computed attribute definition. + */ +export function computedAttributeYamlToDefinition( + computedAttribute: ComputedAttributeSchema, +): IComputedAttributeMetadataObjectDefinition { + return { + type: "computedAttribute", + ...(computedAttribute.id === undefined ? {} : { id: computedAttribute.id }), + title: computedAttribute.title ?? "", + description: computedAttribute.description ?? "", + tags: computedAttribute.tags ?? [], + expression: computedAttribute.maql, + ...(computedAttribute.locale === undefined ? {} : { locale: computedAttribute.locale }), + }; +} + +/** + * The fields the as-code computed attribute YAML owns, extracted from a parsed definition and + * normalized for persistence. Every other field — `format`, `metricType`, `dataType`, `isNullable`, + * `nullValue`, and a loaded object's identity (`ref`, `uri`) — lies outside the YAML projection and + * is preserved by layering these fields over a base object rather than replacing it. + * + * `locale` is always returned (normalized to `undefined` when absent), never omitted: it must + * overwrite the base so that clearing the `locale` line drops a previously set collation. Were it + * omitted when undefined, the base's stale `locale` would leak through the overlay. + */ +function pickComputedAttributeYamlFields( + definition: IComputedAttributeMetadataObjectDefinition, +): Pick< + IComputedAttributeMetadataObjectDefinition, + "title" | "description" | "tags" | "expression" | "locale" +> { + return { + title: definition.title ?? "", + description: definition.description ?? "", + tags: definition.tags ?? [], + expression: definition.expression, + locale: definition.locale, + }; +} + +/** + * The computed attribute's `reconcile`: layers the author's parsed YAML edits over the base + * definition — the loaded object on an edit, the copy source on a duplicate. + * + * The base carries fields the YAML cannot express (`format`, `metricType`, `dataType`, ...), which + * survive because only the YAML-owned fields are overlaid on top. Identity stays YAML-authoritative: + * the parsed `id` wins, and its absence lets the server derive a fresh one, so the base's id is + * intentionally dropped first. (On an edit this is moot — validation pins the parsed id to the loaded + * object's — but on a duplicate it is what frees the copy from the source's identity.) + */ +export function reconcileComputedAttributeDefinition( + base: IComputedAttributeMetadataObjectDefinition, + edited: IComputedAttributeMetadataObjectDefinition, +): IComputedAttributeMetadataObjectDefinition { + const { id: _baseId, ...carriedOver } = base; + return { + ...carriedOver, + ...pickComputedAttributeYamlFields(edited), + ...(edited.id === undefined ? {} : { id: edited.id }), + }; +} diff --git a/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeCopy.test.ts b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeCopy.test.ts new file mode 100644 index 00000000000..5479fa907f8 --- /dev/null +++ b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeCopy.test.ts @@ -0,0 +1,68 @@ +// (C) 2026 GoodData Corporation + +import { describe, expect, it } from "vitest"; + +import type { IComputedAttributeMetadataObject } from "@gooddata/sdk-model"; + +import { createCopiedComputedAttribute } from "./computedAttributeCopy.js"; + +const MAQL = 'SELECT CASE WHEN {metric/won_activities} > 50 THEN "High" ELSE "Low" END'; + +const loaded: IComputedAttributeMetadataObject = { + id: "rep_performance", + uri: "rep_performance", + ref: { identifier: "rep_performance", type: "computedAttribute" }, + type: "computedAttribute", + title: "Rep Performance", + description: "Band", + tags: ["sales"], + production: true, + deprecated: false, + unlisted: false, + created: "2024-01-01", + updated: "2024-01-02", + expression: MAQL, + dataType: "STRING", + locale: "en-US", + displayForms: [], +}; + +describe("createCopiedComputedAttribute", () => { + it("re-derives the identity from the copied title", () => { + const copy = createCopiedComputedAttribute(loaded); + expect(copy.title).toBe("Rep Performance (2)"); + expect(copy.id).toBe("rep_performance__2_"); + }); + + it("increments an existing copy suffix", () => { + expect(createCopiedComputedAttribute({ ...loaded, title: "Rep Performance (2)" }).title).toBe( + "Rep Performance (3)", + ); + }); + + it("carries the author-owned value shaping over", () => { + expect(createCopiedComputedAttribute(loaded)).toMatchObject({ + expression: MAQL, + dataType: "STRING", + locale: "en-US", + tags: ["sales"], + }); + }); + + it("drops the server-managed fields of the source", () => { + const copy = createCopiedComputedAttribute(loaded); + expect(copy).not.toHaveProperty("ref"); + expect(copy).not.toHaveProperty("uri"); + expect(copy).not.toHaveProperty("created"); + expect(copy).not.toHaveProperty("updated"); + expect(copy).not.toHaveProperty("displayForms"); + }); + + it("omits the id when the source's was server-generated", () => { + const copy = createCopiedComputedAttribute({ + ...loaded, + id: "6f1a1d4e-1f2b-4c3d-8e9f-0a1b2c3d4e5f", + }); + expect(copy).not.toHaveProperty("id"); + }); +}); diff --git a/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeCopy.ts b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeCopy.ts new file mode 100644 index 00000000000..9d68bc1f843 --- /dev/null +++ b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeCopy.ts @@ -0,0 +1,26 @@ +// (C) 2026 GoodData Corporation + +import type { IComputedAttributeMetadataObjectDefinition } from "@gooddata/sdk-model"; + +import { deriveCopyIdentity } from "../asCode/copy.js"; + +export function createCopiedComputedAttribute( + computedAttribute: IComputedAttributeMetadataObjectDefinition, +): IComputedAttributeMetadataObjectDefinition { + // Whitelist only definition-owned fields; the source may be a loaded computed attribute carrying + // server-managed fields (uri, ref, timestamps, ...) that must not leak into the definition. + // The value-shaping fields are author-owned semantics, so they carry over. + return { + type: "computedAttribute", + ...deriveCopyIdentity(computedAttribute), + description: computedAttribute.description, + tags: computedAttribute.tags, + expression: computedAttribute.expression, + ...(computedAttribute.format === undefined ? {} : { format: computedAttribute.format }), + ...(computedAttribute.metricType === undefined ? {} : { metricType: computedAttribute.metricType }), + ...(computedAttribute.dataType === undefined ? {} : { dataType: computedAttribute.dataType }), + ...(computedAttribute.isNullable === undefined ? {} : { isNullable: computedAttribute.isNullable }), + ...(computedAttribute.nullValue === undefined ? {} : { nullValue: computedAttribute.nullValue }), + ...(computedAttribute.locale === undefined ? {} : { locale: computedAttribute.locale }), + }; +} diff --git a/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeDescriptor.ts b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeDescriptor.ts new file mode 100644 index 00000000000..d51d8b31dbe --- /dev/null +++ b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeDescriptor.ts @@ -0,0 +1,135 @@ +// (C) 2026 GoodData Corporation + +import { useMemo } from "react"; + +import { defineMessages, useIntl } from "react-intl"; + +import type { IComputedAttributeMetadataObjectDefinition } from "@gooddata/sdk-model"; + +import { + type IAsCodeEditing, + type IAsCodeMessages, + defineAsCodeDescriptor, + fixedIdentifierOf, +} from "../asCode/descriptor.js"; +import type { ICatalogItemComputedAttribute } from "../catalogItem/types.js"; +import { ObjectTypes } from "../objectType/constants.js"; + +import { computedAttributeCompletions } from "./computedAttributeCompletions.js"; +import { + computedAttributeYamlToDefinition, + definitionToComputedAttributeYaml, + reconcileComputedAttributeDefinition, +} from "./computedAttributeConverter.js"; +import { createCopiedComputedAttribute } from "./computedAttributeCopy.js"; +import { + createComputedAttributeMutationAdapter, + listComputedAttributeReferences, + loadComputedAttribute, +} from "./computedAttributeMutationPort.js"; +import { serializeComputedAttributeToYaml } from "./computedAttributeSerialization.js"; +import { validateComputedAttributeYaml } from "./computedAttributeValidation.js"; +import { COMPUTED_ATTRIBUTE_FEATURE_FLAG } from "./gate.js"; + +const COMPUTED_ATTRIBUTE_DOCS_URL = + "https://www.gooddata.ai/docs/cloud/api-and-sdk/vs-code-extension/structures/#computed-attribute"; + +const messages = defineMessages({ + createTitle: { id: "analyticsCatalog.computedAttribute.dialog.create.title" }, + editTitle: { id: "analyticsCatalog.computedAttribute.dialog.edit.title" }, + duplicate: { id: "analyticsCatalog.computedAttribute.dialog.edit.duplicate" }, + createDefaultTitle: { id: "analyticsCatalog.computedAttribute.create.defaultTitle" }, + createSuccess: { id: "analyticsCatalog.computedAttribute.create.success" }, + updateSuccess: { id: "analyticsCatalog.computedAttribute.update.success" }, + sectionHeader: { id: "analyticsCatalog.computedAttribute.dialog.sectionHeader" }, + sectionHeaderTooltip: { id: "analyticsCatalog.computedAttribute.dialog.sectionHeader.tooltip" }, + help: { id: "analyticsCatalog.computedAttribute.dialog.help" }, + submitError: { id: "analyticsCatalog.computedAttribute.dialog.submit.error" }, + deleteTitle: { id: "analyticsCatalog.computedAttribute.dialog.delete.title" }, + deleteBody: { id: "analyticsCatalog.computedAttribute.dialog.delete.body" }, + deleteSubmit: { id: "analyticsCatalog.computedAttribute.dialog.delete.submit" }, + deleteSuccess: { id: "analyticsCatalog.computedAttribute.delete.success" }, + deleteError: { id: "analyticsCatalog.computedAttribute.delete.error" }, +}) satisfies IAsCodeMessages; + +const capabilityMessages = defineMessages({ + loadError: { id: "analyticsCatalog.computedAttribute.load.error" }, + deleteUsageWarning: { id: "analyticsCatalog.computedAttribute.dialog.delete.usageWarning" }, + deleteBlocked: { id: "analyticsCatalog.computedAttribute.dialog.delete.blocked" }, +}); + +const errorMessages = defineMessages({ + empty: { id: "analyticsCatalog.computedAttribute.validation.empty" }, + syntax: { id: "analyticsCatalog.computedAttribute.validation.syntax" }, + invalidStructure: { id: "analyticsCatalog.computedAttribute.validation.invalidStructure" }, + idImmutable: { id: "analyticsCatalog.computedAttribute.dialog.edit.idImmutable" }, + missingMaql: { id: "analyticsCatalog.computedAttribute.validation.missingMaql" }, + invalidTags: { id: "analyticsCatalog.computedAttribute.validation.invalidTags" }, +}); + +function useComputedAttributeEditing(): IAsCodeEditing { + const intl = useIntl(); + return useMemo>( + () => ({ + completionSource: computedAttributeCompletions, + syntaxErrorMessage: intl.formatMessage(errorMessages.syntax), + // The editor works in the YAML shape, a lossy projection of the computed attribute + // definition — lossy in what a document carries, which `reconcile` restores, never in + // whether one exists. + serialize: (definition) => ({ + yaml: serializeComputedAttributeToYaml(definitionToComputedAttributeYaml(definition)), + hasCodeForm: true, + }), + validate: (value, context) => { + const result = validateComputedAttributeYaml(value, { + fixedIdentifier: fixedIdentifierOf(context), + }); + return result.isValid + ? { isValid: true, definition: result.computedAttribute } + : { isValid: false, error: intl.formatMessage(errorMessages[result.errorCode]) }; + }, + reconcile: reconcileComputedAttributeDefinition, + }), + [intl], + ); +} + +/** + * @internal + */ +export const computedAttributeDescriptor = defineAsCodeDescriptor< + IComputedAttributeMetadataObjectDefinition, + ICatalogItemComputedAttribute +>({ + objectType: ObjectTypes.COMPUTED_ATTRIBUTE, + docsUrl: COMPUTED_ATTRIBUTE_DOCS_URL, + featureFlag: COMPUTED_ATTRIBUTE_FEATURE_FLAG, + messages, + useEditing: useComputedAttributeEditing, + createMutationPort: createComputedAttributeMutationAdapter, + // The template offers the fields and leaves the MAQL line empty: the expression is the whole + // point of the object, so a placeholder one would only be text to delete. + emptyDefinition: (defaultTitle) => + computedAttributeYamlToDefinition({ + type: "computed_attribute", + title: defaultTitle, + maql: "", + }), + // The catalog item carries no MAQL, so the full object is fetched for editing. + seed: { load: loadComputedAttribute, loadError: capabilityMessages.loadError }, + // Refuses the deletion while a visualization still groups by it, and names those + // visualizations. The backend currently allows the delete (it would break the insight + // silently), so the catalog is the guard. + referenceCounted: { + load: listComputedAttributeReferences, + usageWarning: capabilityMessages.deleteUsageWarning, + listReferences: true, + blockedBody: capabilityMessages.deleteBlocked, + }, + // A copied computed attribute derives a human-readable id that can collide on create; identity lets the dialog retry without it. + identity: { + read: (definition) => definition.id, + strip: ({ id: _id, ...definition }) => definition, + }, + toCopy: createCopiedComputedAttribute, +}); diff --git a/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeMutationPort.test.ts b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeMutationPort.test.ts new file mode 100644 index 00000000000..cac291072d1 --- /dev/null +++ b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeMutationPort.test.ts @@ -0,0 +1,193 @@ +// (C) 2026 GoodData Corporation + +import { describe, expect, it, vi } from "vitest"; + +import type { IAnalyticalBackend } from "@gooddata/sdk-backend-spi"; +import type { + IComputedAttributeMetadataObject, + IComputedAttributeMetadataObjectDefinition, +} from "@gooddata/sdk-model"; + +import type { ICatalogItemComputedAttribute } from "../catalogItem/types.js"; + +import { + createComputedAttributeMutationAdapter, + listComputedAttributeReferences, +} from "./computedAttributeMutationPort.js"; + +const MAQL = 'SELECT CASE WHEN {metric/won_activities} > 50 THEN "High" ELSE "Low" END'; + +const computedAttributeItem = { + identifier: "rep_performance", + type: "computedAttribute", +} as ICatalogItemComputedAttribute; + +const saved: IComputedAttributeMetadataObject = { + id: "rep_performance", + uri: "rep_performance", + ref: { identifier: "rep_performance", type: "computedAttribute" }, + type: "computedAttribute", + title: "Rep Performance", + description: "Band", + tags: ["sales"], + production: true, + deprecated: false, + unlisted: false, + created: "2024-01-01", + updated: "2024-01-02", + expression: MAQL, + displayForms: [], +}; + +function createFakeBackend() { + const createComputedAttribute = vi.fn().mockResolvedValue(saved); + const updateComputedAttribute = vi.fn().mockResolvedValue(saved); + const deleteComputedAttribute = vi.fn().mockResolvedValue(undefined); + const getComputedAttribute = vi.fn().mockResolvedValue(saved); + const getComputedAttributeReferencingObjects = vi.fn().mockResolvedValue({ measures: [] }); + const backend = { + workspace: () => ({ + computedAttributes: () => ({ + createComputedAttribute, + updateComputedAttribute, + deleteComputedAttribute, + getComputedAttribute, + getComputedAttributeReferencingObjects, + }), + }), + } as unknown as IAnalyticalBackend; + return { + backend, + createComputedAttribute, + updateComputedAttribute, + deleteComputedAttribute, + getComputedAttributeReferencingObjects, + }; +} + +describe("computedAttributeMutationPort adapter", () => { + it("create calls the backend and converts the result to a catalog item", async () => { + const { backend, createComputedAttribute } = createFakeBackend(); + const adapter = createComputedAttributeMutationAdapter(backend, "ws-1"); + + const result = await adapter.create({ + type: "computedAttribute", + title: "Rep Performance", + description: "Band", + tags: ["sales"], + expression: MAQL, + }); + + expect(createComputedAttribute).toHaveBeenCalledWith(expect.objectContaining({ expression: MAQL })); + expect(result).toMatchObject({ type: "computedAttribute", identifier: "rep_performance" }); + }); + + it("update persists the definition's content with the loaded object's identity", async () => { + const { backend, updateComputedAttribute } = createFakeBackend(); + const adapter = createComputedAttributeMutationAdapter(backend, "ws-1"); + + await adapter.update(saved, { + type: "computedAttribute", + title: "Renamed", + description: "Updated", + tags: ["updated"], + expression: "SELECT 2", + dataType: "STRING", + }); + + expect(updateComputedAttribute).toHaveBeenCalledWith( + expect.objectContaining({ + type: "computedAttribute", + id: "rep_performance", + ref: { identifier: "rep_performance", type: "computedAttribute" }, + title: "Renamed", + expression: "SELECT 2", + dataType: "STRING", + }), + ); + }); + + it("update keeps the loaded identity even when the definition carries a different id", async () => { + const { backend, updateComputedAttribute } = createFakeBackend(); + const adapter = createComputedAttributeMutationAdapter(backend, "ws-1"); + + await adapter.update(saved, { + type: "computedAttribute", + id: "some_other_id", + title: "X", + description: "", + tags: [], + expression: "SELECT 1", + }); + + expect(updateComputedAttribute).toHaveBeenCalledWith( + expect.objectContaining({ id: "rep_performance", ref: saved.ref, uri: saved.uri }), + ); + }); + + it("update carries the base's audit metadata the definition shape cannot hold", async () => { + const { backend, updateComputedAttribute } = createFakeBackend(); + const adapter = createComputedAttributeMutationAdapter(backend, "ws-1"); + + await adapter.update(saved, { + type: "computedAttribute", + title: "Rep Performance", + description: "Band", + tags: ["sales"], + expression: MAQL, + }); + + expect(updateComputedAttribute).toHaveBeenCalledWith( + expect.objectContaining({ created: "2024-01-01", updated: "2024-01-02" }), + ); + }); + + it("update rejects a bare definition as base — it lacks the server-managed identity", async () => { + const { backend } = createFakeBackend(); + const adapter = createComputedAttributeMutationAdapter(backend, "ws-1"); + const bare: IComputedAttributeMetadataObjectDefinition = { + type: "computedAttribute", + title: "X", + description: "", + tags: [], + expression: "SELECT 1", + }; + + await expect(adapter.update(bare, bare)).rejects.toThrow(/requires the loaded object/); + }); + + it("delete targets the computed attribute by its own ref type", async () => { + const { backend, deleteComputedAttribute } = createFakeBackend(); + const adapter = createComputedAttributeMutationAdapter(backend, "ws-1"); + + await adapter.delete({ identifier: "rep_performance", type: "computedAttribute" }); + + expect(deleteComputedAttribute).toHaveBeenCalledWith({ + identifier: "rep_performance", + type: "computedAttribute", + }); + }); +}); + +describe("computed attribute references", () => { + it("titles the referencing insights and metrics", async () => { + const { backend, getComputedAttributeReferencingObjects } = createFakeBackend(); + getComputedAttributeReferencingObjects.mockResolvedValueOnce({ + insights: [{ insight: { title: "Rep performance" } }, { insight: { title: "Won by band" } }], + measures: [{ title: "Metric ABC" }], + }); + + expect(await listComputedAttributeReferences(backend, "ws-1", computedAttributeItem)).toEqual([ + "Rep performance", + "Won by band", + "Metric ABC", + ]); + }); + + it("reports nothing when the response carries neither array", async () => { + const { backend, getComputedAttributeReferencingObjects } = createFakeBackend(); + getComputedAttributeReferencingObjects.mockResolvedValueOnce({}); + + expect(await listComputedAttributeReferences(backend, "ws-1", computedAttributeItem)).toEqual([]); + }); +}); diff --git a/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeMutationPort.test.utils.ts b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeMutationPort.test.utils.ts new file mode 100644 index 00000000000..1539d8da9b6 --- /dev/null +++ b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeMutationPort.test.utils.ts @@ -0,0 +1,33 @@ +// (C) 2026 GoodData Corporation + +import { vi } from "vitest"; + +import type { ICatalogItemComputedAttribute } from "../catalogItem/types.js"; + +import type { IComputedAttributeMutationPort } from "./computedAttributeMutationPort.js"; + +const defaultSavedItem: ICatalogItemComputedAttribute = { + identifier: "test-computed-attribute", + type: "computedAttribute", + title: "Test Computed Attribute", + description: "", + tags: [], + createdBy: "test", + updatedBy: "test", + createdAt: null, + updatedAt: null, + isLocked: false, + isEditable: true, +}; + +/** @internal */ +export function createTestComputedAttributeMutationPort( + overrides: Partial = {}, +): IComputedAttributeMutationPort { + return { + create: vi.fn().mockResolvedValue(defaultSavedItem), + update: vi.fn().mockResolvedValue(defaultSavedItem), + delete: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} diff --git a/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeMutationPort.ts b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeMutationPort.ts new file mode 100644 index 00000000000..17ca8ef8a07 --- /dev/null +++ b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeMutationPort.ts @@ -0,0 +1,94 @@ +// (C) 2026 GoodData Corporation + +import type { IAnalyticalBackend } from "@gooddata/sdk-backend-spi"; +import { + type IComputedAttributeMetadataObject, + type IComputedAttributeMetadataObjectDefinition, + idRef, + insightTitle, + isComputedAttributeMetadataObject, +} from "@gooddata/sdk-model"; + +import type { IAsCodeMutationPort } from "../asCode/descriptor.js"; +import type { ServerIdentity } from "../asCode/serverIdentity.js"; +import { convertComputedAttributeToCatalogItem } from "../catalogItem/converter.js"; +import type { ICatalogItemComputedAttribute } from "../catalogItem/types.js"; + +/** + * @internal + */ +export type IComputedAttributeMutationPort = IAsCodeMutationPort< + IComputedAttributeMetadataObjectDefinition, + ICatalogItemComputedAttribute +>; + +// `id` is definition-expressible yet server-owned: the update targets by it, so a hand-edited id must not retarget it. +function pickComputedAttributeIdentity( + computedAttribute: IComputedAttributeMetadataObject, +): ServerIdentity & + Pick { + const { id, ref, uri, created, updated, createdBy, updatedBy, certification, displayForms } = + computedAttribute; + return { id, ref, uri, created, updated, createdBy, updatedBy, certification, displayForms }; +} + +/** Fetches the full computed attribute for editing (the catalog item carries no MAQL). @internal */ +export function loadComputedAttribute( + backend: IAnalyticalBackend, + workspace: string, + item: ICatalogItemComputedAttribute, +): Promise { + return backend + .workspace(workspace) + .computedAttributes() + .getComputedAttribute(idRef(item.identifier, "computedAttribute")); +} + +/** Titles of the insights and metrics referencing a computed attribute. @internal */ +export async function listComputedAttributeReferences( + backend: IAnalyticalBackend, + workspace: string, + item: ICatalogItemComputedAttribute, +): Promise { + const referencing = await backend + .workspace(workspace) + .computedAttributes() + .getComputedAttributeReferencingObjects(idRef(item.identifier, "computedAttribute")); + return [ + ...(referencing.insights ?? []).map((insight) => insightTitle(insight)), + ...(referencing.measures ?? []).map((measure) => measure.title), + ]; +} + +/** + * @internal + */ +export function createComputedAttributeMutationAdapter( + backend: IAnalyticalBackend, + workspace: string, +): IComputedAttributeMutationPort { + const computedAttributes = () => backend.workspace(workspace).computedAttributes(); + return { + async create(definition) { + const saved = await computedAttributes().createComputedAttribute(definition); + return convertComputedAttributeToCatalogItem(saved); + }, + async update(base, definition) { + if (!isComputedAttributeMetadataObject(base)) { + throw new Error( + "Computed attribute update requires the loaded object as base, not a bare definition.", + ); + } + // Definition is authoritative (already reconciled); base fills only non-definition fields, and the server-identity pick wins. + const saved = await computedAttributes().updateComputedAttribute({ + ...base, + ...definition, + ...pickComputedAttributeIdentity(base), + }); + return convertComputedAttributeToCatalogItem(saved); + }, + async delete(ref) { + await computedAttributes().deleteComputedAttribute(idRef(ref.identifier, "computedAttribute")); + }, + }; +} diff --git a/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeSchema.ts b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeSchema.ts new file mode 100644 index 00000000000..466d81fc9be --- /dev/null +++ b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeSchema.ts @@ -0,0 +1,33 @@ +// (C) 2026 GoodData Corporation + +import * as z from "zod/mini"; + +/** + * Zod schema for the analytics-as-code (AAC) computed attribute YAML shape. + * + * Field names and structure mirror the canonical AAC computed attribute definition + * (`@gooddata/sdk-code-schemas` `v1.ComputedAttribute`) so YAML authored here is interchangeable + * with computed attributes produced by the analytics-as-code tooling. `id` is optional because the + * server derives one from the title when it is omitted on create. + */ +export const computedAttributeSchema = z.strictObject({ + type: z._default(z.literal("computed_attribute"), "computed_attribute"), + id: z.optional(z.string()), + title: z.optional(z.string()), + description: z.optional(z.string()), + tags: z.optional(z.array(z.string())), + maql: z.string(), + locale: z.optional(z.string()), +}); + +export type ComputedAttributeSchemaInput = z.input; +export type ComputedAttributeSchema = z.infer; + +/** + * Allowed YAML property names keyed by parent mapping, used to power editor autocompletion. + * The computed attribute schema is flat, so the only entry is the top-level mapping (keyed by `""`), + * whose value is the object's property names read from zod's public `shape`. + */ +export const COMPUTED_ATTRIBUTE_SCHEMA_KEYS: Record = { + "": Object.keys(computedAttributeSchema.shape), +}; diff --git a/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeSerialization.test.ts b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeSerialization.test.ts new file mode 100644 index 00000000000..6d9b3e506f8 --- /dev/null +++ b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeSerialization.test.ts @@ -0,0 +1,102 @@ +// (C) 2026 GoodData Corporation + +import { describe, expect, it } from "vitest"; + +import { definitionToComputedAttributeYaml } from "./computedAttributeConverter.js"; +import { serializeComputedAttributeToYaml } from "./computedAttributeSerialization.js"; +import { validateComputedAttributeYaml } from "./computedAttributeValidation.js"; + +const MAQL = 'SELECT CASE WHEN {metric/won_activities} > 50 THEN "High" ELSE "Low" END'; + +describe("serializeComputedAttributeToYaml", () => { + it("renders the canonical field order as a gap-free document, maql last", () => { + const yaml = serializeComputedAttributeToYaml({ + type: "computed_attribute", + id: "rep_performance", + title: "Rep Performance", + description: "Sales rep performance band", + tags: ["sales"], + maql: MAQL, + locale: "en-US", + }); + + expect(yaml).toBe(`id: rep_performance +type: computed_attribute +title: Rep Performance +description: Sales rep performance band +tags: + - sales +locale: en-US +maql: ${MAQL}`); + }); + + it("renders an empty description as a quoted blank so the field is offered", () => { + const yaml = serializeComputedAttributeToYaml({ + type: "computed_attribute", + title: "Draft", + maql: MAQL, + }); + + expect(yaml).toContain(`description: ""`); + }); + + it("renders an empty maql as a bare key, the create template's state", () => { + const yaml = serializeComputedAttributeToYaml({ + type: "computed_attribute", + title: "My computed attribute", + maql: "", + }); + + expect(yaml).toBe(`type: computed_attribute +title: My computed attribute +description: "" +maql:`); + }); + + it("omits the id line when the computed attribute has none", () => { + const yaml = serializeComputedAttributeToYaml({ + type: "computed_attribute", + id: undefined, + title: "Draft", + maql: "SELECT 1", + }); + expect(yaml).not.toContain("id:"); + expect(yaml).toContain("maql: SELECT 1"); + }); + + it("emits locale only when one is set", () => { + expect( + serializeComputedAttributeToYaml({ + type: "computed_attribute", + id: "ca", + title: "CA", + maql: "SELECT 1", + }), + ).not.toContain("locale"); + }); + + it("round-trips a computed attribute through serialize and validate", () => { + const yaml = serializeComputedAttributeToYaml( + definitionToComputedAttributeYaml({ + type: "computedAttribute", + id: "rep_performance", + title: "Rep Performance", + description: "Band", + tags: ["sales"], + expression: MAQL, + }), + ); + + expect(validateComputedAttributeYaml(yaml)).toEqual({ + isValid: true, + computedAttribute: { + type: "computedAttribute", + id: "rep_performance", + title: "Rep Performance", + description: "Band", + tags: ["sales"], + expression: MAQL, + }, + }); + }); +}); diff --git a/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeSerialization.ts b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeSerialization.ts new file mode 100644 index 00000000000..8fd1de807f4 --- /dev/null +++ b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeSerialization.ts @@ -0,0 +1,37 @@ +// (C) 2026 GoodData Corporation + +import { Document } from "yaml"; + +import type { ComputedAttributeYaml } from "./computedAttributeConverter.js"; + +/** + * Serializes a canonical AAC computed attribute object to YAML. + * + * The document is a flat, gap-free list of lines in the order the design prescribes: `id`, `type`, + * `title`, `description`, then `maql` last, so the expression the author came to write sits at the + * bottom with the room below it. The `id` line is omitted when the computed attribute has none so + * the server derives one on create. + * + * `description` is always rendered, empty as `""`, so the field an author is likely to fill is + * offered rather than hidden. An empty `maql` renders as a bare `maql:` (see `nullStr`), an + * invitation to type rather than a quoted blank to delete. + */ +export function serializeComputedAttributeToYaml(computedAttribute: ComputedAttributeYaml): string { + const doc = new Document( + computedAttribute.id === undefined + ? { type: "computed_attribute" } + : { id: computedAttribute.id, type: "computed_attribute" }, + ); + + doc.add(doc.createPair("title", computedAttribute.title ?? "")); + doc.add(doc.createPair("description", computedAttribute.description ?? "")); + if (computedAttribute.tags && computedAttribute.tags.length > 0) { + doc.add(doc.createPair("tags", computedAttribute.tags)); + } + if (computedAttribute.locale) { + doc.add(doc.createPair("locale", computedAttribute.locale)); + } + doc.add(doc.createPair("maql", computedAttribute.maql ? computedAttribute.maql : null)); + + return doc.toString({ lineWidth: 0, nullStr: "" }).trimEnd(); +} diff --git a/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeValidation.test.ts b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeValidation.test.ts new file mode 100644 index 00000000000..83ddd07e9b6 --- /dev/null +++ b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeValidation.test.ts @@ -0,0 +1,112 @@ +// (C) 2026 GoodData Corporation + +import { describe, expect, it } from "vitest"; + +import { validateComputedAttributeYaml } from "./computedAttributeValidation.js"; + +const MAQL = 'SELECT CASE WHEN {metric/won_activities} > 50 THEN "High" ELSE "Low" END'; + +describe("validateComputedAttributeYaml", () => { + it("parses a valid computed attribute definition", () => { + const result = validateComputedAttributeYaml(`type: computed_attribute +id: rep_performance +title: Rep Performance +description: Sales rep performance band +tags: + - sales + +maql: ${MAQL} +locale: en-US +`); + + expect(result).toEqual({ + isValid: true, + computedAttribute: { + type: "computedAttribute", + id: "rep_performance", + title: "Rep Performance", + description: "Sales rep performance band", + tags: ["sales"], + expression: MAQL, + locale: "en-US", + }, + }); + }); + + it("parses a minimal definition with only maql", () => { + expect(validateComputedAttributeYaml(`maql: SELECT 1`)).toEqual({ + isValid: true, + computedAttribute: { + type: "computedAttribute", + title: "", + description: "", + tags: [], + expression: "SELECT 1", + }, + }); + }); + + it("rejects an empty definition", () => { + expect(validateComputedAttributeYaml(" ")).toEqual({ isValid: false, errorCode: "empty" }); + }); + + it("rejects invalid YAML syntax", () => { + expect(validateComputedAttributeYaml("maql: [unclosed")).toEqual({ + isValid: false, + errorCode: "syntax", + }); + }); + + it("rejects a definition missing maql", () => { + expect(validateComputedAttributeYaml(`title: No expression`)).toEqual({ + isValid: false, + errorCode: "missingMaql", + }); + }); + + it("reports a blank maql line - the create template's state - as missing, not malformed", () => { + expect(validateComputedAttributeYaml(`title: Draft\ndescription: ""\nmaql:`)).toEqual({ + isValid: false, + errorCode: "missingMaql", + }); + }); + + it("rejects a present but non-string maql as a structural error rather than missing", () => { + expect(validateComputedAttributeYaml(`maql:\n nested: value`)).toEqual({ + isValid: false, + errorCode: "invalidStructure", + }); + }); + + it("rejects non-string tags", () => { + expect(validateComputedAttributeYaml(`maql: SELECT 1\ntags: 5`)).toEqual({ + isValid: false, + errorCode: "invalidTags", + }); + }); + + // The bucketing sugar the design mocked up (using/buckets) is not part of the entity, so it must + // not silently pass through as an unknown field. + it("rejects unknown fields", () => { + expect(validateComputedAttributeYaml(`maql: SELECT 1\nusing: customer_age`)).toEqual({ + isValid: false, + errorCode: "invalidStructure", + }); + }); + + it("rejects changing a fixed identifier in edit mode", () => { + expect( + validateComputedAttributeYaml(`id: changed\nmaql: SELECT 1`, { + fixedIdentifier: "original", + }), + ).toEqual({ isValid: false, errorCode: "idImmutable" }); + }); + + it("accepts an unchanged fixed identifier in edit mode", () => { + expect( + validateComputedAttributeYaml(`id: original\nmaql: SELECT 1`, { + fixedIdentifier: "original", + }).isValid, + ).toBe(true); + }); +}); diff --git a/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeValidation.ts b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeValidation.ts new file mode 100644 index 00000000000..4b012c4f644 --- /dev/null +++ b/libs/sdk-ui-catalog/src/computedAttribute/computedAttributeValidation.ts @@ -0,0 +1,79 @@ +// (C) 2026 GoodData Corporation + +import type * as z from "zod/mini"; + +import type { IComputedAttributeMetadataObjectDefinition } from "@gooddata/sdk-model"; + +import { validateYaml } from "../asCode/validateYaml.js"; + +import { computedAttributeYamlToDefinition } from "./computedAttributeConverter.js"; +import { computedAttributeSchema } from "./computedAttributeSchema.js"; + +export type ComputedAttributeValidationResult = + | { + isValid: true; + computedAttribute: IComputedAttributeMetadataObjectDefinition; + } + | { + isValid: false; + errorCode: ComputedAttributeValidationErrorCode; + }; + +export type ComputedAttributeValidationErrorCode = + | "empty" + | "syntax" + | "invalidStructure" + | "idImmutable" + | "missingMaql" + | "invalidTags"; + +type ValidateComputedAttributeYamlOptions = { + fixedIdentifier?: string; +}; + +export function validateComputedAttributeYaml( + value: string, + options: ValidateComputedAttributeYamlOptions = {}, +): ComputedAttributeValidationResult { + const result = validateYaml(value, { + schema: computedAttributeSchema, + fixedIdentifier: options.fixedIdentifier, + }); + + if (result.ok) { + return { isValid: true, computedAttribute: computedAttributeYamlToDefinition(result.data) }; + } + return { + isValid: false, + errorCode: + result.kind === "schema" + ? classifyComputedAttributeError(result.error, result.parsed) + : result.kind, + }; +} + +function classifyComputedAttributeError( + error: z.core.$ZodError, + parsed: unknown, +): "invalidStructure" | "missingMaql" | "invalidTags" { + // A blank `maql:` line parses to null - the seeded template's state - and counts as unwritten, + // not as a value of the wrong type. + const maqlProvided = + typeof parsed === "object" && + parsed !== null && + ((parsed as { maql?: unknown }).maql ?? undefined) !== undefined; + + for (const issue of error.issues) { + const path = issue.path.map(String).join("."); + + if (path === "maql") { + // A present-but-wrong-typed maql is a structural error, not a missing field. + return maqlProvided ? "invalidStructure" : "missingMaql"; + } + if (path === "tags" || path.startsWith("tags.")) { + return "invalidTags"; + } + } + + return "invalidStructure"; +} diff --git a/libs/sdk-ui-catalog/src/computedAttribute/gate.ts b/libs/sdk-ui-catalog/src/computedAttribute/gate.ts new file mode 100644 index 00000000000..671d9eead98 --- /dev/null +++ b/libs/sdk-ui-catalog/src/computedAttribute/gate.ts @@ -0,0 +1,5 @@ +// (C) 2026 GoodData Corporation + +/** The flag gating catalog computed attributes, referenced by + * `computedAttributeDescriptor.featureFlag` and `useCatalogEndpoints` `gatedBy`. */ +export const COMPUTED_ATTRIBUTE_FEATURE_FLAG = "enableComputedAttributes"; diff --git a/libs/sdk-ui-catalog/src/filter/FilterObjectType.tsx b/libs/sdk-ui-catalog/src/filter/FilterObjectType.tsx index bf1ddcf250a..9e8a325c1aa 100644 --- a/libs/sdk-ui-catalog/src/filter/FilterObjectType.tsx +++ b/libs/sdk-ui-catalog/src/filter/FilterObjectType.tsx @@ -5,8 +5,8 @@ import { memo, useId } from "react"; import { FormattedMessage } from "react-intl"; import { useCatalogFeedCounter } from "../catalogItem/CatalogFeedContext.js"; +import { useEnabledObjectTypes } from "../catalogItem/useCatalogEndpoints.js"; import { ObjectTypeSelectMemo } from "../objectType/ObjectTypeSelect.js"; -import { useIsParametersEnabled } from "../parameter/gate.js"; import { useFilterActions, useFilterState } from "./FilterContext.js"; import { FilterGroupLayout } from "./FilterGroupLayout.js"; @@ -15,7 +15,7 @@ export function FilterObjectType() { const counter = useCatalogFeedCounter(); const { types } = useFilterState(); const { setTypes } = useFilterActions(); - const isParametersEnabled = useIsParametersEnabled(); + const enabledObjectTypes = useEnabledObjectTypes(); const id = useId(); const titleId = `filter-object-type-title/${id}`; @@ -28,8 +28,8 @@ export function FilterObjectType() { diff --git a/libs/sdk-ui-catalog/src/header/CreateObjectButton.tsx b/libs/sdk-ui-catalog/src/header/CreateObjectButton.tsx index aa94de660fa..c8816f9c55c 100644 --- a/libs/sdk-ui-catalog/src/header/CreateObjectButton.tsx +++ b/libs/sdk-ui-catalog/src/header/CreateObjectButton.tsx @@ -30,6 +30,7 @@ const icons: Record = { [ObjectTypes.VISUALIZATION]: "visualization", [ObjectTypes.METRIC]: "metric", [ObjectTypes.PARAMETER]: "parameter", + [ObjectTypes.COMPUTED_ATTRIBUTE]: "ldmAttribute", }; type Props = { diff --git a/libs/sdk-ui-catalog/src/index.ts b/libs/sdk-ui-catalog/src/index.ts index 5ee12b9be2c..0e993476586 100644 --- a/libs/sdk-ui-catalog/src/index.ts +++ b/libs/sdk-ui-catalog/src/index.ts @@ -47,6 +47,7 @@ export type { ICatalogItemInsight, ICatalogItemMeasure, ICatalogItemParameter, + ICatalogItemComputedAttribute, ICatalogItemAttribute, ICatalogItemFact, ICatalogItemDataSet, @@ -55,6 +56,7 @@ export type { } from "./catalogItem/types.js"; export { isCatalogItemAttribute, + isCatalogItemComputedAttribute, isCatalogItemDashboard, isCatalogItemDataSet, isCatalogItemFact, diff --git a/libs/sdk-ui-catalog/src/insight/insightDescriptor.ts b/libs/sdk-ui-catalog/src/insight/insightDescriptor.ts index 6e3087e1b9b..acdfee4aedf 100644 --- a/libs/sdk-ui-catalog/src/insight/insightDescriptor.ts +++ b/libs/sdk-ui-catalog/src/insight/insightDescriptor.ts @@ -16,7 +16,7 @@ import { useRequestInsightCodec, } from "./insightCodecContext.js"; import { createCopiedInsight } from "./insightCopy.js"; -import { countInsightReferences, createInsightMutationAdapter, loadInsight } from "./insightMutationPort.js"; +import { createInsightMutationAdapter, listInsightReferences, loadInsight } from "./insightMutationPort.js"; const VISUALIZATION_DOCS_URL = "https://www.gooddata.ai/docs/cloud/api-and-sdk/vs-code-extension/structures/#visualisation"; @@ -78,7 +78,7 @@ export const visualizationDescriptor = defineAsCodeDescriptor { }); }); -describe("insight reference count", () => { - it("countInsightReferences counts referencing dashboards", async () => { +describe("insight references", () => { + it("listInsightReferences titles the referencing dashboards", async () => { const { backend, getInsightReferencingObjects } = createFakeBackend(); - getInsightReferencingObjects.mockResolvedValueOnce({ analyticalDashboards: [{}, {}] }); + getInsightReferencingObjects.mockResolvedValueOnce({ + analyticalDashboards: [{ title: "Sales overview" }, { title: "Exec summary" }], + }); - expect(await countInsightReferences(backend, "ws-1", insightItem)).toBe(2); + expect(await listInsightReferences(backend, "ws-1", insightItem)).toEqual([ + "Sales overview", + "Exec summary", + ]); }); - it("countInsightReferences reports zero when the response carries no dashboard array", async () => { + it("listInsightReferences reports nothing when the response carries no dashboard array", async () => { const { backend, getInsightReferencingObjects } = createFakeBackend(); getInsightReferencingObjects.mockResolvedValueOnce({}); - expect(await countInsightReferences(backend, "ws-1", insightItem)).toBe(0); + expect(await listInsightReferences(backend, "ws-1", insightItem)).toEqual([]); }); }); diff --git a/libs/sdk-ui-catalog/src/insight/insightMutationPort.ts b/libs/sdk-ui-catalog/src/insight/insightMutationPort.ts index 4751b9cf6df..97f4e2c7480 100644 --- a/libs/sdk-ui-catalog/src/insight/insightMutationPort.ts +++ b/libs/sdk-ui-catalog/src/insight/insightMutationPort.ts @@ -25,17 +25,17 @@ export function loadInsight( .getInsight(idRef(item.identifier, "insight"), { loadUserData: true }); } -/** Counts the dashboards referencing an insight. @internal */ -export async function countInsightReferences( +/** Titles of the dashboards referencing an insight. @internal */ +export async function listInsightReferences( backend: IAnalyticalBackend, workspace: string, item: ICatalogItemInsight, -): Promise { +): Promise { const referencing = await backend .workspace(workspace) .insights() .getInsightReferencingObjects(idRef(item.identifier, "insight")); - return referencing.analyticalDashboards?.length ?? 0; + return (referencing.analyticalDashboards ?? []).map((dashboard) => dashboard.title); } function pickInsightIdentity( diff --git a/libs/sdk-ui-catalog/src/lineage/const.ts b/libs/sdk-ui-catalog/src/lineage/const.ts index 9256d7cf515..760941ee92f 100644 --- a/libs/sdk-ui-catalog/src/lineage/const.ts +++ b/libs/sdk-ui-catalog/src/lineage/const.ts @@ -10,6 +10,7 @@ export const HIDDEN_ITEMS: ObjectType[] = [ "workspaceDataFilter", "attribute", "attributeHierarchy", + "computedAttribute", "automation", "colorPalette", "dashboardPlugin", diff --git a/libs/sdk-ui-catalog/src/localization/bundles/en-US.json b/libs/sdk-ui-catalog/src/localization/bundles/en-US.json index 02df3ccc560..ba6088f59f1 100644 --- a/libs/sdk-ui-catalog/src/localization/bundles/en-US.json +++ b/libs/sdk-ui-catalog/src/localization/bundles/en-US.json @@ -71,6 +71,10 @@ "text": "Parameter", "crowdinContext": "Canonical display name for the Parameter object type, used in tooltips, menus, and labels." }, + "analyticsCatalog.objectType.computedAttribute.name": { + "text": "Computed attribute", + "crowdinContext": "Canonical display name for the Computed attribute object type, used in tooltips, menus, and labels." + }, "analyticsCatalog.visualizationType.table.tooltip": { "text": "Table", "crowdinContext": "Tooltip label shown when hovering a visualization item for insights of type table." @@ -775,6 +779,102 @@ "text": "Parameter could not be deleted.", "crowdinContext": "Error toast shown when parameter deletion fails." }, + "analyticsCatalog.computedAttribute.dialog.create.title": { + "text": "Create computed attribute", + "crowdinContext": "Title of the dialog for creating a new computed attribute as code." + }, + "analyticsCatalog.computedAttribute.dialog.edit.title": { + "text": "Edit computed attribute", + "crowdinContext": "Title of the dialog for editing an existing computed attribute." + }, + "analyticsCatalog.computedAttribute.dialog.edit.duplicate": { + "text": "Save as new", + "crowdinContext": "Label for the duplicate action button in the edit computed attribute dialog." + }, + "analyticsCatalog.computedAttribute.dialog.edit.idImmutable": { + "text": "Computed attribute id cannot be changed when saving an existing computed attribute. Use Save as new instead.", + "crowdinContext": "Error shown when the user tries to change the computed attribute id while saving an existing computed attribute." + }, + "analyticsCatalog.computedAttribute.dialog.sectionHeader": { + "text": "Computed attribute definition", + "crowdinContext": "Header above the as-code editor in the computed attribute dialog." + }, + "analyticsCatalog.computedAttribute.dialog.sectionHeader.tooltip": { + "text": "Write a MAQL expression that assigns each row a value, using CASE WHEN branches over a metric or a fact.", + "crowdinContext": "Tooltip shown for the help icon next to the computed attribute definition section header." + }, + "analyticsCatalog.computedAttribute.dialog.help": { + "text": "How to create a computed attribute?", + "crowdinContext": "Label for the help link in the computed attribute dialog footer." + }, + "analyticsCatalog.computedAttribute.dialog.submit.error": { + "text": "Computed attribute could not be saved.", + "crowdinContext": "Fallback error shown in the computed attribute dialog when saving fails without a specific backend error message." + }, + "analyticsCatalog.computedAttribute.validation.empty": { + "text": "Computed attribute definition cannot be empty.", + "crowdinContext": "Error message shown when attempting to save an empty computed attribute definition." + }, + "analyticsCatalog.computedAttribute.validation.syntax": { + "text": "YAML syntax error", + "crowdinContext": "Inline error tooltip shown in the YAML editor when the syntax is invalid." + }, + "analyticsCatalog.computedAttribute.validation.invalidStructure": { + "text": "The computed attribute definition has an invalid structure.", + "crowdinContext": "Validation error shown when the computed attribute YAML does not match the expected structure." + }, + "analyticsCatalog.computedAttribute.validation.missingMaql": { + "text": "The computed attribute must define a MAQL expression.", + "crowdinContext": "Validation error shown when the computed attribute YAML is missing the maql field." + }, + "analyticsCatalog.computedAttribute.validation.invalidTags": { + "text": "Tags must be a list of text values.", + "crowdinContext": "Validation error shown when the computed attribute tags are not a list of strings." + }, + "analyticsCatalog.computedAttribute.create.defaultTitle": { + "text": "My computed attribute", + "crowdinContext": "Default placeholder title seeded into the new computed attribute as-code editor before the user types their own." + }, + "analyticsCatalog.computedAttribute.create.success": { + "text": "Computed attribute created.", + "crowdinContext": "Toast shown after a computed attribute is created successfully from the Analytics Catalog." + }, + "analyticsCatalog.computedAttribute.update.success": { + "text": "Computed attribute saved.", + "crowdinContext": "Toast shown after a computed attribute is updated successfully from the Analytics Catalog." + }, + "analyticsCatalog.computedAttribute.load.error": { + "text": "The computed attribute could not be loaded.", + "crowdinContext": "Error toast shown when loading a computed attribute for editing or duplication fails." + }, + "analyticsCatalog.computedAttribute.dialog.delete.title": { + "text": "Delete computed attribute?", + "crowdinContext": "Title of the confirmation dialog shown when deleting a computed attribute." + }, + "analyticsCatalog.computedAttribute.dialog.delete.body": { + "text": "Are you sure you want to delete {name}?", + "crowdinContext": "Body text of the computed attribute delete confirmation dialog. {name} is the computed attribute title or fallback ID. ... wraps the name in bold." + }, + "analyticsCatalog.computedAttribute.dialog.delete.usageWarning": { + "text": "This attribute is used in {count, plural, one {# visualization} other {# visualizations}}", + "crowdinContext": "Warning banner in the computed attribute delete dialog when visualizations still reference it. {count} is the number of referencing visualizations, wrapped in . Followed by a Show more toggle that lists their titles." + }, + "analyticsCatalog.computedAttribute.dialog.delete.blocked": { + "text": "The computed attribute {name} cannot be deleted because it is used in some visualizations. To delete this attribute, remove it from all visualizations first.", + "crowdinContext": "Body of the computed attribute delete dialog when visualizations still reference it, so deletion is refused. {name} is the computed attribute title or fallback ID. ... wraps the name in bold." + }, + "analyticsCatalog.computedAttribute.dialog.delete.submit": { + "text": "Delete", + "crowdinContext": "Label for the Delete button in the computed attribute delete confirmation dialog." + }, + "analyticsCatalog.computedAttribute.delete.success": { + "text": "Computed attribute deleted.", + "crowdinContext": "Toast shown after a computed attribute is deleted successfully from the Analytics Catalog." + }, + "analyticsCatalog.computedAttribute.delete.error": { + "text": "Computed attribute could not be deleted.", + "crowdinContext": "Error toast shown when computed attribute deletion fails." + }, "analyticsCatalog.metric.dialog.create.title": { "text": "Create metric", "crowdinContext": "Title of the dialog for creating a new metric as code." @@ -851,6 +951,14 @@ "text": "This object uses content that cannot be represented as code. It is shown read-only; use the standard editor to change it.", "crowdinContext": "Notice shown above the as-code editor when the object's stored content has no code representation, so the editor is read-only and saving is unavailable." }, + "analyticsCatalog.asCode.dialog.delete.showMore": { + "text": "Show more", + "crowdinContext": "Toggle in the as-code delete dialog that reveals the list of objects referencing the one being deleted." + }, + "analyticsCatalog.asCode.dialog.delete.showLess": { + "text": "Show less", + "crowdinContext": "Toggle in the as-code delete dialog that hides the list of objects referencing the one being deleted." + }, "analyticsCatalog.asCode.dialog.autoFormat": { "text": "Auto-format", "crowdinContext": "Toolbar action of the as-code editor dialog that re-formats the YAML document (normalizes indentation) while keeping its comments." diff --git a/libs/sdk-ui-catalog/src/metric/metricDescriptor.ts b/libs/sdk-ui-catalog/src/metric/metricDescriptor.ts index bde1241f6e4..fac64e1999c 100644 --- a/libs/sdk-ui-catalog/src/metric/metricDescriptor.ts +++ b/libs/sdk-ui-catalog/src/metric/metricDescriptor.ts @@ -23,7 +23,7 @@ import { reconcileMetricDefinition, } from "./metricConverter.js"; import { createCopiedMetric } from "./metricCopy.js"; -import { countMetricReferences, createMetricMutationAdapter, loadMetric } from "./metricMutationPort.js"; +import { createMetricMutationAdapter, listMetricReferences, loadMetric } from "./metricMutationPort.js"; import { serializeMetricToYaml } from "./metricSerialization.js"; import { validateMetricYaml } from "./metricValidation.js"; @@ -104,7 +104,7 @@ export const metricDescriptor = defineAsCodeDescriptor { }); }); -describe("metric reference count", () => { - it("countMetricReferences sums the referencing insights and measures", async () => { +describe("metric references", () => { + it("listMetricReferences titles the referencing insights and measures", async () => { const { backend, getMeasureReferencingObjects } = createFakeBackend(); - getMeasureReferencingObjects.mockResolvedValueOnce({ insights: [{}, {}], measures: [{}] }); - - const count = await countMetricReferences(backend, "ws-1", measureItem); + getMeasureReferencingObjects.mockResolvedValueOnce({ + insights: [{ insight: { title: "Revenue trend" } }, { insight: { title: "Top accounts" } }], + measures: [{ title: "Revenue per account" }], + }); - expect(count).toBe(3); + expect(await listMetricReferences(backend, "ws-1", measureItem)).toEqual([ + "Revenue trend", + "Top accounts", + "Revenue per account", + ]); }); - it("countMetricReferences reports zero when the response carries neither array", async () => { + it("listMetricReferences reports nothing when the response carries neither array", async () => { const { backend, getMeasureReferencingObjects } = createFakeBackend(); getMeasureReferencingObjects.mockResolvedValueOnce({}); - expect(await countMetricReferences(backend, "ws-1", measureItem)).toBe(0); + expect(await listMetricReferences(backend, "ws-1", measureItem)).toEqual([]); }); }); diff --git a/libs/sdk-ui-catalog/src/metric/metricMutationPort.ts b/libs/sdk-ui-catalog/src/metric/metricMutationPort.ts index b07c56f0de5..08a9cfb7201 100644 --- a/libs/sdk-ui-catalog/src/metric/metricMutationPort.ts +++ b/libs/sdk-ui-catalog/src/metric/metricMutationPort.ts @@ -5,6 +5,7 @@ import { type IMeasureMetadataObject, type IMeasureMetadataObjectDefinition, idRef, + insightTitle, isMeasureMetadataObject, } from "@gooddata/sdk-model"; @@ -43,18 +44,21 @@ export function loadMetric( return getMeasureCatalogItem(backend, workspace, idRef(item.identifier, "measure")); } -/** Counts the insights and measures referencing a measure. @internal */ -export async function countMetricReferences( +/** Titles of the insights and measures referencing a measure. @internal */ +export async function listMetricReferences( backend: IAnalyticalBackend, workspace: string, item: ICatalogItemMeasure, -): Promise { +): Promise { const referencing = await getMeasureReferencingObjectsCatalogItem( backend, workspace, idRef(item.identifier, "measure"), ); - return (referencing.insights?.length ?? 0) + (referencing.measures?.length ?? 0); + return [ + ...(referencing.insights ?? []).map((insight) => insightTitle(insight)), + ...(referencing.measures ?? []).map((measure) => measure.title), + ]; } /** diff --git a/libs/sdk-ui-catalog/src/objectType/ObjectTypeIcon.tsx b/libs/sdk-ui-catalog/src/objectType/ObjectTypeIcon.tsx index b66defdc436..95004719e97 100644 --- a/libs/sdk-ui-catalog/src/objectType/ObjectTypeIcon.tsx +++ b/libs/sdk-ui-catalog/src/objectType/ObjectTypeIcon.tsx @@ -37,7 +37,7 @@ export function ObjectTypeIcon({ className={cx("gd-analytics-catalog__object-type", className)} data-object-type={type} > - {type === "attribute" ? ( + {type === "attribute" || type === "computedAttribute" ? ( ) : null} {type === "fact" ? ( diff --git a/libs/sdk-ui-catalog/src/objectType/ObjectTypeSelect.test.tsx b/libs/sdk-ui-catalog/src/objectType/ObjectTypeSelect.test.tsx index f1b97ee9738..4cb2265a87d 100644 --- a/libs/sdk-ui-catalog/src/objectType/ObjectTypeSelect.test.tsx +++ b/libs/sdk-ui-catalog/src/objectType/ObjectTypeSelect.test.tsx @@ -13,6 +13,15 @@ const wrapper = TestIntlProvider; describe("ObjectTypeSelect", () => { const commonProps: Parameters[0] = { selectedTypes: [], + enabledObjectTypes: [ + "analyticalDashboard", + "insight", + "measure", + "computedAttribute", + "attribute", + "fact", + "dataSet", + ], onSelect: vi.fn(), counter: { measure: 0, @@ -22,6 +31,7 @@ describe("ObjectTypeSelect", () => { insight: 0, analyticalDashboard: 0, parameter: 0, + computedAttribute: 0, }, }; @@ -34,18 +44,33 @@ describe("ObjectTypeSelect", () => { fact: 4, attribute: 5, dataSet: 6, + // Counted under the attribute button: a computed attribute has none of its own. + computedAttribute: 2, }; - render(, { wrapper }); + render( + , + { wrapper }, + ); expect(screen.getByRole("button", { name: "Dashboard: 1" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Visualization: 2" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Metric: 3" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Parameter: 4" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Fact: 4" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Attribute: 5" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Attribute: 7" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Date dataset: 6" })).toBeInTheDocument(); }); + it("has no button of its own for computed attributes", () => { + render(, { wrapper }); + + expect(screen.queryByTestId(`${objectType}/computedAttribute`)).not.toBeInTheDocument(); + }); + it("calls onSelect with added type when clicking an unselected type", () => { const onSelect = vi.fn(); @@ -56,6 +81,16 @@ describe("ObjectTypeSelect", () => { expect(onSelect).toHaveBeenCalledWith(["analyticalDashboard"]); }); + it("calls onSelect with every type in the selected group", () => { + const onSelect = vi.fn(); + + render(, { wrapper }); + + fireEvent.click(screen.getByRole("button", { name: /Attribute/ })); + + expect(onSelect).toHaveBeenCalledWith(["attribute", "computedAttribute"]); + }); + it("calls onSelect with removed type when clicking an already selected type", () => { const onSelect = vi.fn(); @@ -88,7 +123,12 @@ describe("ObjectTypeSelect", () => { expect(screen.queryByTestId(`${objectType}/parameter`)).not.toBeInTheDocument(); - rerender(); + rerender( + , + ); expect(screen.getByTestId(`${objectType}/parameter`)).toBeVisible(); }); diff --git a/libs/sdk-ui-catalog/src/objectType/ObjectTypeSelect.tsx b/libs/sdk-ui-catalog/src/objectType/ObjectTypeSelect.tsx index a46693bc924..44de6206c27 100644 --- a/libs/sdk-ui-catalog/src/objectType/ObjectTypeSelect.tsx +++ b/libs/sdk-ui-catalog/src/objectType/ObjectTypeSelect.tsx @@ -8,10 +8,10 @@ import { type IconType, UiButtonSegmentedControl, UiIconButton, UiTooltip } from import { objectType } from "../automation/testIds.js"; -import { OBJECT_TYPE_ORDER, ObjectTypes } from "./constants.js"; +import { FILTER_GROUPS, type FilterableObjectType, ObjectTypes } from "./constants.js"; import type { ObjectType } from "./types.js"; -const icons: Record = { +const icons: Record = { [ObjectTypes.DASHBOARD]: "dashboard", [ObjectTypes.VISUALIZATION]: "visualization", [ObjectTypes.METRIC]: "metric", @@ -21,7 +21,7 @@ const icons: Record = { [ObjectTypes.PARAMETER]: "parameter", }; -const messages: Record = defineMessages({ +const messages: Record = defineMessages({ [ObjectTypes.DASHBOARD]: { id: "analyticsCatalog.objectType.dashboard.button.ariaLabel" }, [ObjectTypes.VISUALIZATION]: { id: "analyticsCatalog.objectType.visualization.button.ariaLabel" }, [ObjectTypes.METRIC]: { id: "analyticsCatalog.objectType.metric.button.ariaLabel" }, @@ -34,42 +34,43 @@ const messages: Record = defineMessages({ type Props = { counter: Record; selectedTypes: ObjectType[]; + enabledObjectTypes: readonly ObjectType[]; onSelect: (selectedTypes: ObjectType[]) => void; - showParameter?: boolean; ariaLabelledBy?: string; }; export function ObjectTypeSelect({ selectedTypes, + enabledObjectTypes, onSelect, counter, - showParameter = false, ariaLabelledBy, }: Props) { const intl = useIntl(); - const visibleObjectTypes = showParameter - ? OBJECT_TYPE_ORDER - : OBJECT_TYPE_ORDER.filter((type) => type !== ObjectTypes.PARAMETER); + const visibleGroups = FILTER_GROUPS.filter(({ types }) => + types.some((type) => enabledObjectTypes.includes(type)), + ); - const handleSelect = (type: ObjectType) => { - if (selectedTypes.includes(type)) { - onSelect(selectedTypes.filter((selectedType) => selectedType !== type)); + const handleSelect = (types: readonly ObjectType[]) => { + if (types.every((type) => selectedTypes.includes(type))) { + onSelect(selectedTypes.filter((selectedType) => !types.includes(selectedType))); } else { - onSelect([...selectedTypes, type]); + onSelect([...new Set([...selectedTypes, ...types])]); } }; return ( - {visibleObjectTypes.map((type) => { - const isSelected = selectedTypes.includes(type); - const ariaLabel = intl.formatMessage(messages[type], { count: counter[type] }); + {visibleGroups.map(({ id, types }) => { + const isSelected = types.every((type) => selectedTypes.includes(type)); + const count = types.reduce((sum, type) => sum + counter[type], 0); + const ariaLabel = intl.formatMessage(messages[id], { count }); return (
handleSelect(type)} + onClick={() => handleSelect(types)} // Since object types are stable, dynamic testing ID is acceptable. - dataTestId={`${objectType}/${type}`} + dataTestId={`${objectType}/${id}`} /> } content={ariaLabel} diff --git a/libs/sdk-ui-catalog/src/objectType/constants.ts b/libs/sdk-ui-catalog/src/objectType/constants.ts index 00f357be46a..b23d42019f3 100644 --- a/libs/sdk-ui-catalog/src/objectType/constants.ts +++ b/libs/sdk-ui-catalog/src/objectType/constants.ts @@ -10,20 +10,26 @@ export const ObjectTypes = { VISUALIZATION: "insight", METRIC: "measure", PARAMETER: "parameter", + COMPUTED_ATTRIBUTE: "computedAttribute", ATTRIBUTE: "attribute", FACT: "fact", DATASET: "dataSet", } as const satisfies Record; /** - * Analytics Catalog default object type order. + * Analytics Catalog object type filter groups in display order. */ -export const OBJECT_TYPE_ORDER: ObjectType[] = [ - ObjectTypes.DASHBOARD, - ObjectTypes.VISUALIZATION, - ObjectTypes.METRIC, - ObjectTypes.PARAMETER, - ObjectTypes.ATTRIBUTE, - ObjectTypes.FACT, - ObjectTypes.DATASET, -]; +export const FILTER_GROUPS = [ + { id: ObjectTypes.DASHBOARD, types: [ObjectTypes.DASHBOARD] }, + { id: ObjectTypes.VISUALIZATION, types: [ObjectTypes.VISUALIZATION] }, + { id: ObjectTypes.METRIC, types: [ObjectTypes.METRIC] }, + { id: ObjectTypes.PARAMETER, types: [ObjectTypes.PARAMETER] }, + { id: ObjectTypes.ATTRIBUTE, types: [ObjectTypes.ATTRIBUTE, ObjectTypes.COMPUTED_ATTRIBUTE] }, + { id: ObjectTypes.FACT, types: [ObjectTypes.FACT] }, + { id: ObjectTypes.DATASET, types: [ObjectTypes.DATASET] }, +] as const satisfies readonly { id: ObjectType; types: readonly ObjectType[] }[]; + +/** + * Object types offered as filter group buttons. + */ +export type FilterableObjectType = (typeof FILTER_GROUPS)[number]["id"]; diff --git a/libs/sdk-ui-catalog/src/objectType/labels.ts b/libs/sdk-ui-catalog/src/objectType/labels.ts index a2aeb509944..5e139c56333 100644 --- a/libs/sdk-ui-catalog/src/objectType/labels.ts +++ b/libs/sdk-ui-catalog/src/objectType/labels.ts @@ -12,6 +12,7 @@ const objectTypeMessages: Record = defineMessages [ObjectTypes.VISUALIZATION]: { id: "analyticsCatalog.objectType.visualization.name" }, [ObjectTypes.METRIC]: { id: "analyticsCatalog.objectType.metric.name" }, [ObjectTypes.PARAMETER]: { id: "analyticsCatalog.objectType.parameter.name" }, + [ObjectTypes.COMPUTED_ATTRIBUTE]: { id: "analyticsCatalog.objectType.computedAttribute.name" }, [ObjectTypes.ATTRIBUTE]: { id: "analyticsCatalog.objectType.attribute.name" }, [ObjectTypes.FACT]: { id: "analyticsCatalog.objectType.fact.name" }, [ObjectTypes.DATASET]: { id: "analyticsCatalog.objectType.dateDataset.name" }, diff --git a/libs/sdk-ui-catalog/src/objectType/mapping.ts b/libs/sdk-ui-catalog/src/objectType/mapping.ts index e1b1c8cf120..d5e18ad48a8 100644 --- a/libs/sdk-ui-catalog/src/objectType/mapping.ts +++ b/libs/sdk-ui-catalog/src/objectType/mapping.ts @@ -7,7 +7,9 @@ import type { ObjectType } from "./types.js"; /** * Maps the catalog ObjectType to the GenAIObjectType. */ -export function mapGenAIObjectType(type: Exclude): GenAIObjectType { +export function mapGenAIObjectType( + type: Exclude, +): GenAIObjectType { if (type === "analyticalDashboard") { return "dashboard"; } diff --git a/libs/sdk-ui-catalog/src/objectType/types.ts b/libs/sdk-ui-catalog/src/objectType/types.ts index f0f8a110c8c..66d7ea53f73 100644 --- a/libs/sdk-ui-catalog/src/objectType/types.ts +++ b/libs/sdk-ui-catalog/src/objectType/types.ts @@ -9,7 +9,14 @@ import type { ObjectType as ModelObjectType } from "@gooddata/sdk-model"; */ export type ObjectType = Extract< ModelObjectType, - "analyticalDashboard" | "insight" | "measure" | "parameter" | "fact" | "attribute" | "dataSet" + | "analyticalDashboard" + | "insight" + | "measure" + | "parameter" + | "computedAttribute" + | "fact" + | "attribute" + | "dataSet" >; /** @@ -19,5 +26,5 @@ export type ObjectType = Extract< */ export type CatalogCreateObjectType = Extract< ObjectType, - "analyticalDashboard" | "insight" | "measure" | "parameter" + "analyticalDashboard" | "insight" | "measure" | "parameter" | "computedAttribute" >; diff --git a/libs/sdk-ui-catalog/styles/scss/codeDialog.scss b/libs/sdk-ui-catalog/styles/scss/codeDialog.scss index 61f99048ddf..209f4bef4c5 100644 --- a/libs/sdk-ui-catalog/styles/scss/codeDialog.scss +++ b/libs/sdk-ui-catalog/styles/scss/codeDialog.scss @@ -115,3 +115,12 @@ flex: auto; margin-right: 10px; } + +.gd-analytics-catalog__as-code-dialog__usage { + margin-top: 20px; + + &-list { + margin: 10px 0 0; + padding-left: 20px; + } +} diff --git a/libs/sdk-ui-catalog/styles/scss/objectType.scss b/libs/sdk-ui-catalog/styles/scss/objectType.scss index 1b1e3ebb1c3..c0a1df6975a 100644 --- a/libs/sdk-ui-catalog/styles/scss/objectType.scss +++ b/libs/sdk-ui-catalog/styles/scss/objectType.scss @@ -1,4 +1,4 @@ -// (C) 2024-2025 GoodData Corporation +// (C) 2024-2026 GoodData Corporation @use "@gooddata/sdk-ui-kit/styles/scss/variables" as kit-variables; @@ -21,7 +21,8 @@ --catalog-object-type-icon-color: #{kit-variables.$gd-color-object-type-metric}; } - &[data-object-type="attribute"] { + &[data-object-type="attribute"], + &[data-object-type="computedAttribute"] { --catalog-object-type-icon-color: #{kit-variables.$gd-color-object-type-attribute}; } } diff --git a/libs/sdk-ui-charts/package.json b/libs/sdk-ui-charts/package.json index 9a80ce70aa1..7739deff905 100644 --- a/libs/sdk-ui-charts/package.json +++ b/libs/sdk-ui-charts/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-ui-charts", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData.UI SDK - Charts", "license": "LicenseRef-LICENSE", "author": "GoodData Corporation", @@ -108,15 +108,15 @@ "@gooddata/reference-workspace": "workspace:*", "@gooddata/sdk-backend-mockingbird": "workspace:*", "@gooddata/stylelint-config": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.0", "@types/hoist-non-react-statics": "^3.3.1", - "@types/js-yaml": "^4.0.9", + "@types/js-yaml": "4.0.9", "@types/lodash-es": "^4.17.12", "@types/node": "24.12.0", - "@types/raf": "^3.4.0", + "@types/raf": "3.4.3", "@types/react": "19.1.11", "@types/react-dom": "19.1.7", "@types/react-measure": "2.0.12", @@ -136,7 +136,7 @@ "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "5.2.0", "eslint-plugin-sonarjs": "3.0.6", - "full-icu": "^1.3.0", + "full-icu": "1.6.0", "happy-dom": "20.9.0", "npm-run-all": "^4.1.5", "oxfmt": "0.52.0", diff --git a/libs/sdk-ui-dashboard/package.json b/libs/sdk-ui-dashboard/package.json index 8ee64f897bb..86455826c95 100644 --- a/libs/sdk-ui-dashboard/package.json +++ b/libs/sdk-ui-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-ui-dashboard", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData SDK - Dashboard Component", "license": "LicenseRef-LICENSE", "author": "GoodData Corporation", @@ -110,17 +110,17 @@ "@gooddata/reference-workspace": "workspace:*", "@gooddata/sdk-backend-mockingbird": "workspace:*", "@gooddata/stylelint-config": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.0", "@testing-library/user-event": "14.6.1", "@types/lodash-es": "^4.17.12", "@types/node": "24.12.0", - "@types/raf": "^3.4.0", + "@types/raf": "3.4.3", "@types/react": "19.1.11", "@types/react-dom": "19.1.7", - "@types/react-lines-ellipsis": "^0.15.6", + "@types/react-lines-ellipsis": "0.15.6", "@types/react-measure": "2.0.12", "@types/react-redux": "7.1.34", "@typescript-eslint/eslint-plugin": "8.58.0", @@ -149,7 +149,7 @@ "react-dom": "19.1.1", "sass": "1.70.0", "stylelint": "^16.26.1", - "svgo": "^2.8.0", + "svgo": "2.8.3", "typescript": "5.9.3", "vitest": "4.1.8", "vitest-dom": "0.1.1" diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/DefaultAlertingDialog/DefaultAlertingDialog.tsx b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/DefaultAlertingDialog/DefaultAlertingDialog.tsx index aedac84af42..5d6286d6016 100644 --- a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/DefaultAlertingDialog/DefaultAlertingDialog.tsx +++ b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/DefaultAlertingDialog/DefaultAlertingDialog.tsx @@ -1,8 +1,8 @@ // (C) 2019-2026 GoodData Corporation -import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; -import { FormattedMessage, defineMessage, useIntl } from "react-intl"; +import { FormattedMessage, useIntl } from "react-intl"; import { type IAutomationMetadataObject } from "@gooddata/sdk-model"; import { @@ -19,7 +19,6 @@ import { OverlayController, OverlayControllerProvider, ScrollablePanel, - UiIcon, UiIconButton, UiTooltip, useId, @@ -34,23 +33,21 @@ import { AutomationDialogActionBar, AutomationDialogFooterLeft, } from "../../shared/slots/AutomationDialogActionBar.js"; -import { - type IAutomationDialogActionBarProps, - type IAutomationDialogDestinationProps, - type IAutomationDialogRecipientsProps, -} from "../../shared/slots/types.js"; import { DeleteAlertConfirmDialog } from "../DefaultAlertingManagementDialog/components/DeleteAlertConfirmDialog.js"; import { useAlertActions } from "../state/AlertActionsContext.js"; import { useAlertData } from "../state/AlertDataContext.js"; import { useAlertDraft } from "../state/AlertDraftContext.js"; import { useAlertFilters } from "../state/AlertFiltersContext.js"; import { useAlertDialogValidity } from "../state/useAlertDialogValidity.js"; -import { useAlertSelectedValues } from "../state/useAlertSelectedValues.js"; import { - type AlertingDialogHeaderDefaultProps, - type IAlertingDialogFiltersProps, - type IDefaultAlertingDialogProps, -} from "../types.js"; + useAlertingDialogActionBarProps, + useAlertingDialogDestinationProps, + useAlertingDialogFiltersProps, + useAlertingDialogHeaderProps, + useAlertingDialogRecipientsProps, +} from "../state/useAlertingDialogRegionProps.js"; +import { useAlertSelectedValues } from "../state/useAlertSelectedValues.js"; +import { type IDefaultAlertingDialogProps } from "../types.js"; import { AlertingDialogDestination } from "./AlertingDialogDestination.js"; import { AlertingDialogFilters } from "./AlertingDialogFilters.js"; @@ -74,7 +71,6 @@ import { useAlertSubmit } from "./hooks/useAlertSubmit.js"; import { useAlertThreshold } from "./hooks/useAlertThreshold.js"; import { getValueSuffix } from "./utils/getters.js"; import { isAnomalyDetection, isChangeOrDifferenceOperator } from "./utils/guards.js"; -import { isMobileView } from "./utils/responsive.js"; const OVERLAY_POSITION_TYPE = "sameAsTarget"; const CLOSE_ON_PARENT_SCROLL = true; @@ -104,11 +100,6 @@ export function AlertingDialogRenderer({ const dialogTitleRef = useRef(null); const { - isWhiteLabeled, - isSecondaryTitleVisible, - externalRecipient: externalRecipientOverride, - maxAutomationsRecipients, - isExecutionTimestampMode, features: { enableAlertOncePerInterval, enableAnomalyDetectionAlert, @@ -116,10 +107,9 @@ export function AlertingDialogRenderer({ }, catalogAttributes, catalogDateDatasets, - separators, allowHourlyRecurrence, } = useAutomationsContext(); - const { widgetTitle, alertToEdit, notificationChannels } = useAlertingDialogContext(); + const { alertToEdit, notificationChannels } = useAlertingDialogContext(); const [alertToDelete, setAlertToDelete] = useState(null); @@ -131,15 +121,12 @@ export function AlertingDialogRenderer({ const { editedAutomation, warningMessage } = useAlertDraft(); const { - onTitleChange, - onRecipientsChange, onMeasureChange, onAttributeChange, onComparisonOperatorChange, onRelativeOperatorChange, onAnomalyDetectionChange, onComparisonTypeChange, - onDestinationChange, onTriggerModeChange, onTriggerIntervalChange, onSensitivityChange, @@ -147,29 +134,11 @@ export function AlertingDialogRenderer({ setEditedAutomation, } = useAlertActions(); - const { - supportedMeasures, - supportedAttributes, - isResultLoading, - getAttributeValues, - defaultUser, - getMetricValue, - } = useAlertData(); + const { supportedMeasures, supportedAttributes, isResultLoading, getAttributeValues, getMetricValue } = + useAlertData(); - const { - selectedFilters, - availableFilters, - onFiltersChange, - onApplyCurrentFilters, - automationIsValid, - filtersAreStale, - automationParameters, - availableParameters, - onParameterChange, - onParameterDelete, - onParameterAdd, - dropStaleParameters, - } = useAlertFilters(); + const { onApplyCurrentFilters, automationIsValid, filtersAreStale, dropStaleParameters } = + useAlertFilters(); const { selectedMeasure, @@ -181,8 +150,6 @@ export function AlertingDialogRenderer({ selectedGranularity, selectedAttribute, selectedValue, - allowExternalRecipients, - allowOnlyLoggedUserRecipients, } = useAlertSelectedValues(); const { value, onChange, onBlur, thresholdErrorMessage } = useAlertThreshold({ @@ -196,26 +163,23 @@ export function AlertingDialogRenderer({ selectedValue, }); - const { - isSubmitDisabled, - validationErrorMessage, - isParentValid, - canChangeMeasure, - isInvalidConnectionToInsight, - } = useAlertDialogValidity(); + const { validationErrorMessage, canChangeMeasure, isInvalidConnectionToInsight } = + useAlertDialogValidity(); const [isApplyCurrentFiltersDialogOpen, setIsApplyCurrentFiltersDialogOpen] = useState(!automationIsValid); - const { isSaving, submit } = useAlertSubmit({ - editedAutomation, - supportedMeasures, - separators, - alertToEdit, - onSuccess, - onError, - onSaveSuccess, - onSaveError, + const { isSaving, submit } = useAlertSubmit({ onSuccess, onError, onSaveSuccess, onSaveError }); + + const headerDefaultProps = useAlertingDialogHeaderProps({ onCancel, ref: dialogTitleRef }); + const filtersDefaultProps = useAlertingDialogFiltersProps(); + const destinationDefaultProps = useAlertingDialogDestinationProps(); + const recipientsDefaultProps = useAlertingDialogRecipientsProps(); + const actionBarDefaultProps = useAlertingDialogActionBarProps({ + onCancel, + onSubmit: () => void submit(), + isSaving, + onDelete: () => setAlertToDelete(alertToEdit as IAutomationMetadataObject), }); const validationContextValue = useValidationContextValue(createInvalidNode({ id: "AlertingDialog" })); @@ -232,30 +196,8 @@ export function AlertingDialogRenderer({ ]); }, [validationErrorMessage, isInvalidConnectionToInsight, setInvalidDatapoints]); - const helpTextId = isMobileView() - ? defineMessage({ id: "dialogs.alerting.footer.title.short" }).id - : defineMessage({ id: "dialogs.alerting.footer.title" }).id; - const titleElementId = useId(); - const { secondaryTitle, secondaryTitleIcon } = useMemo(() => { - return { - secondaryTitle: widgetTitle, - secondaryTitleIcon: ( - - ), - }; - }, [widgetTitle, intl]); - if (isApplyCurrentFiltersDialogOpen) { return ( onCancel?.(), - onSubmit: () => void submit(), - isSubmitDisabled: submitDisabled, - isSaving, - submitButtonTooltipText: isExecutionTimestampMode - ? intl.formatMessage({ id: "dialogs.alert.save.executionTimestampMode" }) - : undefined, - ...(isWhiteLabeled - ? {} - : { - helpLinkText: intl.formatMessage({ id: helpTextId }), - helpLinkHref: "https://www.gooddata.com/docs/cloud/create-dashboards/automation/alerts/", - }), - ...(alertToEdit - ? { - deleteButtonText: intl.formatMessage({ id: "delete" }), - onDelete: () => setAlertToDelete(alertToEdit as IAutomationMetadataObject), - } - : {}), - }; - return ( <> ) } @@ -383,34 +263,14 @@ export function AlertingDialogRenderer({ ) : undefined } - isSubmitDisabled={submitDisabled} - submitButtonTooltipText={ - isExecutionTimestampMode - ? intl.formatMessage({ - id: "dialogs.alert.save.executionTimestampMode", - }) - : undefined - } + isSubmitDisabled={actionBarDefaultProps.isSubmitDisabled} + submitButtonTooltipText={actionBarDefaultProps.submitButtonTooltipText} initialFocus={dialogTitleRef} submitOnEnterKey={false} onCancel={onCancel} - onSubmit={() => void submit()} + onSubmit={actionBarDefaultProps.onSubmit} headline={undefined} headerLeftButtonRenderer={() => { - const headerDefaultProps: AlertingDialogHeaderDefaultProps = { - title: editedAutomation?.title ?? "", - onChange: onTitleChange, - onCancel, - placeholder: intl.formatMessage({ - id: "dialogs.alert.title.placeholder", - }), - ref: dialogTitleRef, - secondaryTitle, - secondaryTitleIcon, - isSecondaryTitleVisible: isSecondaryTitleVisible - ? isParentValid - : undefined, - }; return HeaderSlot ? ( { vi.resetModules(); }); +// What the mocked contexts return; each test sets it through renderAlertSubmitHook. +const contextState = vi.hoisted(() => ({ + editedAutomation: undefined as IAutomationMetadataObjectDefinition | undefined, + alertToEdit: undefined as IAutomationMetadataObject | undefined, +})); + vi.mock("../../../contexts/AlertingDialogContext.js", () => ({ useAlertingDialogContext: () => ({ createAlert: createAlertMock, saveAlert: saveAlertMock, deleteAlert: vi.fn(), + alertToEdit: contextState.alertToEdit, }), })); +vi.mock("../../../contexts/AutomationsContext.js", () => ({ + useAutomationsContext: () => ({ separators: undefined }), +})); + +vi.mock("../../state/AlertDraftContext.js", () => ({ + useAlertDraft: () => ({ editedAutomation: contextState.editedAutomation }), +})); + +vi.mock("../../state/AlertDataContext.js", () => ({ + useAlertData: () => ({ supportedMeasures: [] }), +})); + vi.mock("../utils/getters.js", async (importOriginal: () => Promise>) => { const original = await importOriginal(); return { @@ -46,7 +65,7 @@ vi.mock("../utils/getters.js", async (importOriginal: () => Promise = {}) { - const mergedProps: IUseAlertSubmitProps = { - editedAutomation: baseAutomation, - supportedMeasures: [], - ...props, - }; - return renderHook(() => useAlertSubmit(mergedProps), { wrapper: IntlWrapper }); +type RenderInput = IUseAlertSubmitCallbacks & { + editedAutomation?: IAutomationMetadataObjectDefinition; + alertToEdit?: IAutomationMetadataObject; +}; + +function renderAlertSubmitHook(input: RenderInput = {}) { + const { editedAutomation, alertToEdit, ...callbacks } = input; + // an explicit `editedAutomation: undefined` must reach the hook as undefined (the no-draft case) + contextState.editedAutomation = "editedAutomation" in input ? editedAutomation : baseAutomation; + contextState.alertToEdit = alertToEdit; + return renderHook(() => useAlertSubmit(callbacks), { wrapper: IntlWrapper }); } // --------------------------------------------------------------------------- diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/DefaultAlertingDialog/hooks/useAlertSubmit.ts b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/DefaultAlertingDialog/hooks/useAlertSubmit.ts index f3c67527e3d..fc11fb44e0e 100644 --- a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/DefaultAlertingDialog/hooks/useAlertSubmit.ts +++ b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/DefaultAlertingDialog/hooks/useAlertSubmit.ts @@ -4,43 +4,51 @@ import { useRef, useState } from "react"; import { useIntl } from "react-intl"; -import { - type IAutomationMetadataObject, - type IAutomationMetadataObjectDefinition, - type ISeparators, -} from "@gooddata/sdk-model"; -import { type GoodDataSdkError, convertError } from "@gooddata/sdk-ui"; +import { type IAutomationMetadataObject } from "@gooddata/sdk-model"; +import { convertError } from "@gooddata/sdk-ui"; import { useAlertingDialogContext } from "../../../contexts/AlertingDialogContext.js"; -import { type AlertMetric } from "../../types.js"; +import { useAutomationsContext } from "../../../contexts/AutomationsContext.js"; +import { useAlertData } from "../../state/AlertDataContext.js"; +import { useAlertDraft } from "../../state/AlertDraftContext.js"; +import { type IAlertingDialogProps } from "../../types.js"; import { getDescription } from "../utils/getters.js"; -export interface IUseAlertSubmitProps { - editedAutomation: IAutomationMetadataObjectDefinition | undefined; - supportedMeasures: AlertMetric[]; - separators?: ISeparators; - alertToEdit?: IAutomationMetadataObject; - onSuccess?: (alert: IAutomationMetadataObject) => void; - onError?: (error: GoodDataSdkError) => void; - onSaveSuccess?: (alert: IAutomationMetadataObject) => void; - onSaveError?: (error: GoodDataSdkError) => void; -} +/** + * Lifecycle callbacks of {@link useAlertSubmit}: the dialog's own `onSuccess`/`onError` (create) and + * `onSaveSuccess`/`onSaveError` (edit). + * + * @internal + */ +export type IUseAlertSubmitCallbacks = Pick< + IAlertingDialogProps, + "onSuccess" | "onError" | "onSaveSuccess" | "onSaveError" +>; +/** + * Submits the alerting dialog's draft: creates a new alert or saves the edited one, deriving a title + * when the draft has none, and routes the result to the matching callback. Guards against a second + * submit while one is in flight. + * + * Reads the draft, the supported measures, the edited alert and the separators from the alerting + * contexts, so it throws outside the alerting dialog's state providers. + * + * @internal + */ export function useAlertSubmit({ - editedAutomation, - supportedMeasures, - separators, - alertToEdit, onSuccess, onError, onSaveSuccess, onSaveError, -}: IUseAlertSubmitProps): { +}: IUseAlertSubmitCallbacks): { isSaving: boolean; submit: () => Promise; } { const intl = useIntl(); - const { createAlert, saveAlert } = useAlertingDialogContext(); + const { separators } = useAutomationsContext(); + const { createAlert, saveAlert, alertToEdit } = useAlertingDialogContext(); + const { editedAutomation } = useAlertDraft(); + const { supportedMeasures } = useAlertData(); const [isSaving, setIsSaving] = useState(false); const submitInFlight = useRef(false); diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/AlertActionsContext.ts b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/AlertActionsContext.ts index bbe08850677..28074e205e5 100644 --- a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/AlertActionsContext.ts +++ b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/AlertActionsContext.ts @@ -3,33 +3,7 @@ import { createContext, useContext } from "react"; import { missingAlertStateProvider } from "./missingAlertStateProvider.js"; -import { type useAlertFormState } from "./useAlertFormState.js"; - -/** - * The alerting dialog's mutators: the draft setter and every form change-handler. - * - * Changes rarely: `onGranularityChange` is re-created when `triggerIntervalDirty` toggles or the - * supported measures load. The shape is derived from `useAlertFormState`, which owns them. - * - * @internal - */ -export type IAlertActionsContextValue = Pick< - ReturnType, - | "setEditedAutomation" - | "onTitleChange" - | "onMeasureChange" - | "onAttributeChange" - | "onComparisonOperatorChange" - | "onRelativeOperatorChange" - | "onAnomalyDetectionChange" - | "onComparisonTypeChange" - | "onSensitivityChange" - | "onTriggerIntervalChange" - | "onGranularityChange" - | "onDestinationChange" - | "onTriggerModeChange" - | "onRecipientsChange" ->; +import { type IAlertActionsContextValue } from "./types.js"; const AlertActionsContext = createContext(undefined); AlertActionsContext.displayName = "AlertActionsContext"; diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/AlertDataContext.ts b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/AlertDataContext.ts index 825c06afb24..2a9a32d8539 100644 --- a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/AlertDataContext.ts +++ b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/AlertDataContext.ts @@ -3,27 +3,7 @@ import { createContext, useContext } from "react"; import { missingAlertStateProvider } from "./missingAlertStateProvider.js"; -import { type useAlertFormState } from "./useAlertFormState.js"; -import { type useAlertSupportedMetrics } from "./useAlertSupportedMetrics.js"; - -/** - * The data the alerting dialog loads asynchronously: the supported measures and attributes with - * their formats, the execution-result readers, and the current user's default recipients. - * - * Changes when an async load resolves, not per keystroke. - * - * @internal - */ -export type IAlertDataContextValue = Pick< - ReturnType, - | "supportedMeasures" - | "supportedAttributes" - | "measureFormatMap" - | "isResultLoading" - | "getAttributeValues" - | "getMetricValue" -> & - Pick, "defaultUser" | "defaultRecipient">; +import { type IAlertDataContextValue } from "./types.js"; const AlertDataContext = createContext(undefined); AlertDataContext.displayName = "AlertDataContext"; diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/AlertDraftContext.ts b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/AlertDraftContext.ts index 7bb6a8e69dd..2d3bf32dd08 100644 --- a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/AlertDraftContext.ts +++ b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/AlertDraftContext.ts @@ -3,21 +3,7 @@ import { createContext, useContext } from "react"; import { missingAlertStateProvider } from "./missingAlertStateProvider.js"; -import { type useAlertFormState } from "./useAlertFormState.js"; - -/** - * The alerting dialog's edit draft: the automation being edited, the baseline it is compared - * against, and the form-level warning and title-validity flag that move with it. - * - * Changes on every keystroke; consumers re-render by design. The shape is derived from - * `useAlertFormState`, which owns these values. - * - * @internal - */ -export type IAlertDraftContextValue = Pick< - ReturnType, - "editedAutomation" | "originalAutomation" | "warningMessage" | "isTitleValid" ->; +import { type IAlertDraftContextValue } from "./types.js"; const AlertDraftContext = createContext(undefined); AlertDraftContext.displayName = "AlertDraftContext"; diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/AlertFiltersContext.ts b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/AlertFiltersContext.ts index d1d9a544e22..11fdbfc40e2 100644 --- a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/AlertFiltersContext.ts +++ b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/AlertFiltersContext.ts @@ -3,37 +3,7 @@ import { createContext, useContext } from "react"; import { missingAlertStateProvider } from "./missingAlertStateProvider.js"; -import { type useAlertFiltersModel } from "./useAlertFiltersModel.js"; -import { type useAlertFormState } from "./useAlertFormState.js"; - -/** - * The alerting dialog's filter and export-parameter model: the current selection, the available - * filters, the two filter mutators and the staleness flags, plus the automation's execution - * parameters and their mutators. - * - * Changes when a filter or a parameter is edited. The shape is derived from - * `useAlertFiltersModel` and from the parameter members `useAlertFormState` re-exports. - * - * @internal - */ -export type IAlertFiltersContextValue = Pick< - ReturnType, - | "selectedFilters" - | "availableFilters" - | "onFiltersChange" - | "onApplyCurrentFilters" - | "automationIsValid" - | "filtersAreStale" -> & - Pick< - ReturnType, - | "automationParameters" - | "availableParameters" - | "onParameterChange" - | "onParameterDelete" - | "onParameterAdd" - | "dropStaleParameters" - >; +import { type IAlertFiltersContextValue } from "./types.js"; const AlertFiltersContext = createContext(undefined); AlertFiltersContext.displayName = "AlertFiltersContext"; diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/AlertingDialogStateProvider.tsx b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/AlertingDialogStateProvider.tsx index 3caf15965dd..684db7ff9af 100644 --- a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/AlertingDialogStateProvider.tsx +++ b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/AlertingDialogStateProvider.tsx @@ -6,10 +6,16 @@ import { useAlertingDialogContext } from "../../contexts/AlertingDialogContext.j import { useAutomationsContext } from "../../contexts/AutomationsContext.js"; import { useAutomationFiltersSelect } from "../../shared/automationFilters/useAutomationFiltersSelect.js"; -import { AlertActionsContextProvider, type IAlertActionsContextValue } from "./AlertActionsContext.js"; -import { AlertDataContextProvider, type IAlertDataContextValue } from "./AlertDataContext.js"; -import { AlertDraftContextProvider, type IAlertDraftContextValue } from "./AlertDraftContext.js"; -import { AlertFiltersContextProvider, type IAlertFiltersContextValue } from "./AlertFiltersContext.js"; +import { AlertActionsContextProvider } from "./AlertActionsContext.js"; +import { AlertDataContextProvider } from "./AlertDataContext.js"; +import { AlertDraftContextProvider } from "./AlertDraftContext.js"; +import { AlertFiltersContextProvider } from "./AlertFiltersContext.js"; +import { + type IAlertActionsContextValue, + type IAlertDataContextValue, + type IAlertDraftContextValue, + type IAlertFiltersContextValue, +} from "./types.js"; import { useAlertFiltersModel } from "./useAlertFiltersModel.js"; import { useAlertFormState } from "./useAlertFormState.js"; import { getAlertSelectedValues } from "./useAlertSelectedValues.js"; diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/types.ts b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/types.ts new file mode 100644 index 00000000000..a63d6fb289d --- /dev/null +++ b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/types.ts @@ -0,0 +1,225 @@ +// (C) 2026 GoodData Corporation + +import { type Dispatch, type SetStateAction } from "react"; + +import { + type DateAttributeGranularity, + type FilterContextItem, + type IAlertAnomalyDetectionGranularity, + type IAlertAnomalyDetectionSensitivity, + type IAlertComparisonOperator, + type IAlertRelativeArithmeticOperator, + type IAlertRelativeOperator, + type IAlertTriggerInterval, + type IAlertTriggerMode, + type IAttribute, + type IAttributeMetadataObject, + type IAutomationMetadataObjectDefinition, + type IAutomationRecipient, + type IMeasure, + type IdentifierRef, + type ParameterValue, +} from "@gooddata/sdk-model"; + +import { type IAutomationParameter } from "../../shared/automationFilters/automationParameters.js"; +import { type AttributeValue } from "../DefaultAlertingDialog/hooks/useAttributeValuesFromExecResults.js"; +import { type IMeasureFormatMap } from "../DefaultAlertingDialog/utils/getters.js"; +import { type AlertAttribute, type AlertMetric, type AlertMetricComparatorType } from "../types.js"; + +/** + * The alerting dialog's edit draft: the automation being edited, the baseline it is compared + * against, and the form-level warning and title-validity flag that move with it. + * + * Changes on every keystroke; consumers re-render by design. + * + * @internal + */ +export interface IAlertDraftContextValue { + /** + * The alert being edited. Undefined only before the state model has seeded it. + */ + editedAutomation: IAutomationMetadataObjectDefinition | undefined; + + /** + * The draft as it was when the dialog opened; the dirty check compares against it. + */ + originalAutomation: IAutomationMetadataObjectDefinition | undefined; + + /** + * Form-level warning shown below the form, e.g. after switching to a creator-only channel. + */ + warningMessage: string | undefined; + + /** + * Whether the current title passes the length limit. + */ + isTitleValid: boolean; +} + +/** + * The alerting dialog's mutators: the draft setter and every form change-handler. + * + * Changes rarely: `onGranularityChange` is re-created when `triggerIntervalDirty` toggles or the + * supported measures load. + * + * @internal + */ +export interface IAlertActionsContextValue { + setEditedAutomation: Dispatch>; + onTitleChange: (value: string) => void; + onMeasureChange: (measure: AlertMetric) => void; + onAttributeChange: (attribute: AlertAttribute | undefined, value: AttributeValue | undefined) => void; + onComparisonOperatorChange: (measure: AlertMetric, comparisonOperator: IAlertComparisonOperator) => void; + onRelativeOperatorChange: ( + measure: AlertMetric, + relativeOperator: IAlertRelativeOperator, + arithmeticOperator: IAlertRelativeArithmeticOperator, + ) => void; + onAnomalyDetectionChange: (measure: AlertMetric) => void; + onComparisonTypeChange: ( + measure: AlertMetric | undefined, + relativeOperator: [IAlertRelativeOperator, IAlertRelativeArithmeticOperator] | undefined, + comparisonType: AlertMetricComparatorType, + granularity?: DateAttributeGranularity, + ) => void; + onSensitivityChange: (sensitivity: IAlertAnomalyDetectionSensitivity) => void; + /** + * `dirty` defaults to true; the granularity handler passes false when it derives the interval. + */ + onTriggerIntervalChange: (triggerInterval: IAlertTriggerInterval, dirty?: boolean) => void; + onGranularityChange: ( + measure: AlertMetric | undefined, + granularity: IAlertAnomalyDetectionGranularity, + ) => void; + onDestinationChange: (destinationId: string) => void; + onTriggerModeChange: (triggerMode: IAlertTriggerMode) => void; + onRecipientsChange: (recipients: IAutomationRecipient[]) => void; +} + +/** + * The data the alerting dialog loads asynchronously: the supported measures and attributes with + * their formats, the execution-result readers, and the current user's default recipients. + * + * Changes when an async load resolves, not per keystroke. + * + * @internal + */ +export interface IAlertDataContextValue { + supportedMeasures: AlertMetric[]; + supportedAttributes: AlertAttribute[]; + measureFormatMap: IMeasureFormatMap; + /** + * Whether the widget's execution result is still loading. + */ + isResultLoading: boolean; + /** + * Distinct values of an attribute in the widget's execution result. + */ + getAttributeValues: (attribute: IAttributeMetadataObject) => AttributeValue[]; + /** + * The measure's current value, optionally sliced by an attribute value; undefined while loading. + */ + getMetricValue: (measure?: IMeasure, attribute?: IAttribute, value?: string | null) => number | undefined; + /** + * The logged-in user as a recipient. + */ + defaultUser: IAutomationRecipient; + /** + * The recipient a new alert is seeded with; an external-recipient override replaces the user. + */ + defaultRecipient: IAutomationRecipient; +} + +/** + * The alerting dialog's filter and export-parameter model: the current selection, the available + * filters, the two filter mutators and the staleness flags, plus the automation's execution + * parameters and their mutators. + * + * Changes when a filter or a parameter is edited. + * + * @internal + */ +export interface IAlertFiltersContextValue { + selectedFilters: FilterContextItem[]; + availableFilters: FilterContextItem[] | undefined; + /** + * Replaces the selection with the complete updated array and mirrors it into the draft. + */ + onFiltersChange: (filters: FilterContextItem[]) => void; + /** + * Replaces the selection with the dashboard's current filters (the stale-filters repair). + */ + onApplyCurrentFilters: () => void; + /** + * False when the saved filters or the saved parameters no longer match the dashboard; gates the + * stale-filters confirmation. + */ + automationIsValid: boolean; + /** + * Whether the saved filters alone no longer match the dashboard. + */ + filtersAreStale: boolean; + /** + * The parameter chips to render. Empty when the `enableParameters` feature is off. + */ + automationParameters: IAutomationParameter[]; + /** + * Workspace parameters addable via the "+" menu. Empty when the feature is off. + */ + availableParameters: IAutomationParameter[]; + onParameterChange: (ref: IdentifierRef, value: ParameterValue) => void; + onParameterDelete: (ref: IdentifierRef) => void; + onParameterAdd: (ref: IdentifierRef) => void; + /** + * Drops stored parameters whose `ref` left the workspace catalog, keeping every other override. + */ + dropStaleParameters: () => void; +} + +/** + * Return type of `useAlertFormState`, composed from the context values it feeds. + * + * @internal + */ +export type IAlertFormState = IAlertDraftContextValue & + IAlertActionsContextValue & + Pick & + Pick< + IAlertFiltersContextValue, + | "automationParameters" + | "availableParameters" + | "onParameterChange" + | "onParameterDelete" + | "onParameterAdd" + | "dropStaleParameters" + >; + +/** + * Return type of `useAlertSupportedMetrics`. + * + * @internal + */ +export type IAlertSupportedMetrics = Pick< + IAlertDataContextValue, + | "supportedMeasures" + | "supportedAttributes" + | "measureFormatMap" + | "isResultLoading" + | "getAttributeValues" + | "getMetricValue" +>; + +/** + * Return type of `useAlertFiltersModel`. + * + * @internal + */ +export type IAlertFiltersModel = Pick< + IAlertFiltersContextValue, + | "selectedFilters" + | "availableFilters" + | "onFiltersChange" + | "onApplyCurrentFilters" + | "automationIsValid" + | "filtersAreStale" +>; diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/useAlertDialogValidity.test.tsx b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/useAlertDialogValidity.test.tsx index 94fd29ec1ca..582ea9b0ec3 100644 --- a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/useAlertDialogValidity.test.tsx +++ b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/useAlertDialogValidity.test.tsx @@ -56,8 +56,9 @@ import { } from "../../contexts/AlertingDialogContext.js"; import { useAutomationsContext, type IAutomationsContextValue } from "../../contexts/AutomationsContext.js"; -import { useAlertData, type IAlertDataContextValue } from "./AlertDataContext.js"; -import { useAlertDraft, type IAlertDraftContextValue } from "./AlertDraftContext.js"; +import { useAlertData } from "./AlertDataContext.js"; +import { useAlertDraft } from "./AlertDraftContext.js"; +import { type IAlertDataContextValue, type IAlertDraftContextValue } from "./types.js"; import { useAlertDialogValidity } from "./useAlertDialogValidity.js"; import { useAlertFormValidation } from "./useAlertFormValidation.js"; diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/useAlertFiltersModel.ts b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/useAlertFiltersModel.ts index 1b98bf55a52..635405de480 100644 --- a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/useAlertFiltersModel.ts +++ b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/useAlertFiltersModel.ts @@ -21,6 +21,8 @@ import { } from "../DefaultAlertingDialog/utils/transformation.js"; import { type AlertAttribute, type AlertMetric } from "../types.js"; +import { type IAlertFiltersModel } from "./types.js"; + /** * Props for {@link useAlertFiltersModel}. * @internal @@ -80,14 +82,7 @@ export function useAlertFiltersModel({ selectedValue, weekStart, timezone, -}: IUseAlertFiltersModelProps): { - selectedFilters: FilterContextItem[]; - availableFilters: FilterContextItem[] | undefined; - onFiltersChange: (filters: FilterContextItem[]) => void; - onApplyCurrentFilters: () => void; - automationIsValid: boolean; - filtersAreStale: boolean; -} { +}: IUseAlertFiltersModelProps): IAlertFiltersModel { const onFiltersChange = useCallback( (filters: FilterContextItem[]) => { setEditedAutomationFilters(filters); diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/useAlertFormState.ts b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/useAlertFormState.ts index 219c57afc0f..ae5ab2159af 100644 --- a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/useAlertFormState.ts +++ b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/useAlertFormState.ts @@ -53,6 +53,8 @@ import { } from "../DefaultAlertingDialog/utils/transformation.js"; import { type AlertAttribute, type AlertMetric, type AlertMetricComparatorType } from "../types.js"; +import { type IAlertFormState } from "./types.js"; + /** * Props for {@link useAlertFormState}. * @internal @@ -92,7 +94,7 @@ export function useAlertFormState({ supportedMeasures, supportedAttributes, measureFormatMap, -}: IUseAlertFormStateProps) { +}: IUseAlertFormStateProps): IAlertFormState { const intl = useIntl(); const { diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/useAlertSelectedValues.test.tsx b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/useAlertSelectedValues.test.tsx index db8a1ec9c59..eee93b9342a 100644 --- a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/useAlertSelectedValues.test.tsx +++ b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/useAlertSelectedValues.test.tsx @@ -41,8 +41,9 @@ import * as AlertingDialogContextModule from "../../contexts/AlertingDialogConte import * as gettersModule from "../DefaultAlertingDialog/utils/getters.js"; import { type AlertAttribute, type AlertMetric } from "../types.js"; -import { AlertDataContextProvider, type IAlertDataContextValue } from "./AlertDataContext.js"; -import { AlertDraftContextProvider, type IAlertDraftContextValue } from "./AlertDraftContext.js"; +import { AlertDataContextProvider } from "./AlertDataContext.js"; +import { AlertDraftContextProvider } from "./AlertDraftContext.js"; +import { type IAlertDataContextValue, type IAlertDraftContextValue } from "./types.js"; import { getAlertSelectedValues, type IGetAlertSelectedValuesProps, diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/useAlertSupportedMetrics.ts b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/useAlertSupportedMetrics.ts index eeeda88c7ce..7659923a62b 100644 --- a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/useAlertSupportedMetrics.ts +++ b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/useAlertSupportedMetrics.ts @@ -8,15 +8,13 @@ import { fillMissingTitles } from "@gooddata/sdk-ui"; import { useAlertingDialogContext } from "../../contexts/AlertingDialogContext.js"; import { useAutomationsContext } from "../../contexts/AutomationsContext.js"; import { useAttributeValuesFromExecResults } from "../DefaultAlertingDialog/hooks/useAttributeValuesFromExecResults.js"; -import { - type IMeasureFormatMap, - getMeasureFormatsFromExecution, -} from "../DefaultAlertingDialog/utils/getters.js"; +import { getMeasureFormatsFromExecution } from "../DefaultAlertingDialog/utils/getters.js"; import { getSupportedInsightAttributesByInsight, getSupportedInsightMeasuresByInsight, } from "../DefaultAlertingDialog/utils/items.js"; -import { type AlertAttribute, type AlertMetric } from "../types.js"; + +import { type IAlertSupportedMetrics } from "./types.js"; export interface IUseAlertSupportedMetricsProps { insight?: IInsight; @@ -34,14 +32,11 @@ export interface IUseAlertSupportedMetricsProps { * * @internal */ -export function useAlertSupportedMetrics({ insight, widget, alertToEdit }: IUseAlertSupportedMetricsProps): { - measureFormatMap: IMeasureFormatMap; - supportedMeasures: AlertMetric[]; - supportedAttributes: AlertAttribute[]; - isResultLoading: boolean; - getAttributeValues: ReturnType["getAttributeValues"]; - getMetricValue: ReturnType["getMetricValue"]; -} { +export function useAlertSupportedMetrics({ + insight, + widget, + alertToEdit, +}: IUseAlertSupportedMetricsProps): IAlertSupportedMetrics { const { locale, catalogDateDatasets, catalogAttributes } = useAutomationsContext(); const { executionResultByRef } = useAlertingDialogContext(); diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/useAlertingDialogRegionProps.tsx b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/useAlertingDialogRegionProps.tsx new file mode 100644 index 00000000000..136f7d75e84 --- /dev/null +++ b/libs/sdk-ui-dashboard/src/presentation/automations/alerting/state/useAlertingDialogRegionProps.tsx @@ -0,0 +1,246 @@ +// (C) 2026 GoodData Corporation + +import { type Ref, useMemo } from "react"; + +import { defineMessage, useIntl } from "react-intl"; + +import { UiIcon } from "@gooddata/sdk-ui-kit"; + +import { useAlertingDialogContext } from "../../contexts/AlertingDialogContext.js"; +import { useAutomationsContext } from "../../contexts/AutomationsContext.js"; +import { + type IAutomationDialogActionBarProps, + type IAutomationDialogDestinationProps, + type IAutomationDialogRecipientsProps, +} from "../../shared/slots/types.js"; +import { isAnomalyDetection } from "../DefaultAlertingDialog/utils/guards.js"; +import { isMobileView } from "../DefaultAlertingDialog/utils/responsive.js"; +import { type AlertingDialogHeaderDefaultProps, type IAlertingDialogFiltersProps } from "../types.js"; + +import { useAlertActions } from "./AlertActionsContext.js"; +import { useAlertData } from "./AlertDataContext.js"; +import { useAlertDraft } from "./AlertDraftContext.js"; +import { useAlertFilters } from "./AlertFiltersContext.js"; +import { useAlertDialogValidity } from "./useAlertDialogValidity.js"; +import { useAlertSelectedValues } from "./useAlertSelectedValues.js"; + +/** + * Inputs of {@link useAlertingDialogHeaderProps} that come from the dialog rather than its state. + * + * @internal + */ +export interface IUseAlertingDialogHeaderPropsInput { + /** + * Closes the dialog; the header's back/close button calls it. + */ + onCancel?: () => void; + + /** + * The dialog's initial-focus ref, attached to the title input. + */ + ref?: Ref; +} + +/** + * The exact props the default alerting dialog renders its header region with. + * + * Throws outside the alerting dialog's state providers. + * + * @internal + */ +export function useAlertingDialogHeaderProps({ + onCancel, + ref, +}: IUseAlertingDialogHeaderPropsInput): AlertingDialogHeaderDefaultProps { + const intl = useIntl(); + const { isSecondaryTitleVisible } = useAutomationsContext(); + const { widgetTitle } = useAlertingDialogContext(); + const { editedAutomation } = useAlertDraft(); + const { onTitleChange } = useAlertActions(); + const { isParentValid } = useAlertDialogValidity(); + + const secondaryTitleIcon = useMemo( + () => ( + + ), + [intl], + ); + + return { + title: editedAutomation?.title ?? "", + onChange: onTitleChange, + onCancel, + placeholder: intl.formatMessage({ id: "dialogs.alert.title.placeholder" }), + ref, + secondaryTitle: widgetTitle, + secondaryTitleIcon, + isSecondaryTitleVisible: isSecondaryTitleVisible ? isParentValid : undefined, + }; +} + +/** + * The exact props the default alerting dialog renders its filters region with. + * + * Throws outside the alerting dialog's state providers. + * + * @internal + */ +export function useAlertingDialogFiltersProps(): IAlertingDialogFiltersProps { + const { editedAutomation } = useAlertDraft(); + const { + availableFilters, + selectedFilters, + onFiltersChange, + automationParameters, + availableParameters, + onParameterAdd, + onParameterChange, + onParameterDelete, + } = useAlertFilters(); + + return { + availableFilters, + selectedFilters, + onFiltersChange, + disableDateFilters: isAnomalyDetection(editedAutomation?.alert), + parameters: automationParameters, + availableParameters, + onParameterAdd, + onParameterChange, + onParameterDelete, + }; +} + +/** + * The exact props the default alerting dialog renders its destination region with. + * + * Throws outside the alerting dialog's state providers. + * + * @internal + */ +export function useAlertingDialogDestinationProps(): IAutomationDialogDestinationProps { + const { notificationChannels } = useAlertingDialogContext(); + const { editedAutomation } = useAlertDraft(); + const { onDestinationChange } = useAlertActions(); + + return { + notificationChannels, + selectedNotificationChannelId: editedAutomation?.notificationChannel, + onChange: onDestinationChange, + }; +} + +/** + * The exact props the default alerting dialog renders its recipients region with. + * + * Throws outside the alerting dialog's state providers. + * + * @internal + */ +export function useAlertingDialogRecipientsProps(): IAutomationDialogRecipientsProps { + const { maxAutomationsRecipients, externalRecipient: externalRecipientOverride } = + useAutomationsContext(); + const { notificationChannels } = useAlertingDialogContext(); + const { editedAutomation } = useAlertDraft(); + const { onRecipientsChange } = useAlertActions(); + const { defaultUser } = useAlertData(); + const { allowExternalRecipients, allowOnlyLoggedUserRecipients } = useAlertSelectedValues(); + + return { + loggedUser: defaultUser, + value: editedAutomation?.recipients ?? [], + onChange: onRecipientsChange, + allowEmptySelection: true, + allowOnlyLoggedUserRecipients, + allowExternalRecipients, + maxRecipients: maxAutomationsRecipients, + notificationChannels, + notificationChannelId: editedAutomation?.notificationChannel, + externalRecipientOverride, + }; +} + +/** + * Inputs of {@link useAlertingDialogActionBarProps} that come from the dialog rather than its state. + * + * @internal + */ +export interface IUseAlertingDialogActionBarPropsInput { + /** + * Closes the dialog without saving. + */ + onCancel?: () => void; + + /** + * Submits the automation; the dialog's single submit path (see `useAlertSubmit`). + */ + onSubmit: () => void; + + /** + * Whether a save is in flight. + */ + isSaving: boolean; + + /** + * Opens the delete confirmation. Rendered only when an existing alert is being edited. + */ + onDelete?: () => void; +} + +/** + * The exact props the default alerting dialog renders its action bar with. + * + * Throws outside the alerting dialog's state providers. + * + * @internal + */ +export function useAlertingDialogActionBarProps({ + onCancel, + onSubmit, + isSaving, + onDelete, +}: IUseAlertingDialogActionBarPropsInput): IAutomationDialogActionBarProps { + const intl = useIntl(); + const { isWhiteLabeled, isExecutionTimestampMode } = useAutomationsContext(); + const { alertToEdit } = useAlertingDialogContext(); + const { isSubmitDisabled } = useAlertDialogValidity(); + + const helpTextId = isMobileView() + ? defineMessage({ id: "dialogs.alerting.footer.title.short" }).id + : defineMessage({ id: "dialogs.alerting.footer.title" }).id; + + return { + cancelButtonText: intl.formatMessage({ id: "cancel" }), + submitButtonText: alertToEdit + ? intl.formatMessage({ id: "save" }) + : intl.formatMessage({ id: "create" }), + onCancel: () => onCancel?.(), + onSubmit, + isSubmitDisabled: isSubmitDisabled || isSaving || isExecutionTimestampMode, + isSaving, + submitButtonTooltipText: isExecutionTimestampMode + ? intl.formatMessage({ id: "dialogs.alert.save.executionTimestampMode" }) + : undefined, + ...(isWhiteLabeled + ? {} + : { + helpLinkText: intl.formatMessage({ id: helpTextId }), + helpLinkHref: "https://www.gooddata.com/docs/cloud/create-dashboards/automation/alerts/", + }), + ...(alertToEdit && onDelete + ? { + deleteButtonText: intl.formatMessage({ id: "delete" }), + onDelete, + } + : {}), + }; +} diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/DefaultScheduledEmailDialog/DefaultScheduledEmailDialog.tsx b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/DefaultScheduledEmailDialog/DefaultScheduledEmailDialog.tsx index d85f9dd0df0..f2d787fff9e 100644 --- a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/DefaultScheduledEmailDialog/DefaultScheduledEmailDialog.tsx +++ b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/DefaultScheduledEmailDialog/DefaultScheduledEmailDialog.tsx @@ -3,7 +3,7 @@ import { type KeyboardEvent, useCallback, useMemo, useRef, useState } from "react"; import cx from "classnames"; -import { defineMessage, useIntl } from "react-intl"; +import { useIntl } from "react-intl"; import { type DashboardAttachmentType, @@ -22,7 +22,6 @@ import { OverlayControllerProvider, RecurrenceForm, ScrollablePanel, - UiIcon, UiTabs, getTimezoneDisplayLabel, isEnterKey, @@ -38,27 +37,23 @@ import { AutomationDialogActionBar, AutomationDialogFooterLeft, } from "../../shared/slots/AutomationDialogActionBar.js"; -import { - type IAutomationDialogActionBarProps, - type IAutomationDialogDestinationProps, -} from "../../shared/slots/types.js"; import { DeleteScheduleConfirmDialog } from "../DefaultScheduledEmailManagementDialog/components/DeleteScheduleConfirmDialog.js"; import { useScheduleEmailDialogAccessibility } from "../hooks/useScheduleEmailDialogAccessibility.js"; import { useScheduledExportActions } from "../state/ScheduledExportActionsContext.js"; -import { useScheduledExportData } from "../state/ScheduledExportDataContext.js"; import { useScheduledExportDraft } from "../state/ScheduledExportDraftContext.js"; import { useScheduledExportFilters } from "../state/ScheduledExportFiltersContext.js"; +import { + useScheduledEmailDialogActionBarProps, + useScheduledEmailDialogDestinationProps, + useScheduledEmailDialogFiltersProps, + useScheduledEmailDialogHeaderProps, + useScheduledEmailDialogRecipientsProps, + useScheduledEmailDialogTimezoneProps, +} from "../state/useScheduledEmailDialogRegionProps.js"; import { useScheduledExportAttachments } from "../state/useScheduledExportAttachments.js"; import { useScheduledExportDialogValidity } from "../state/useScheduledExportDialogValidity.js"; -import { - type IDefaultScheduledEmailDialogProps, - type IScheduledEmailDialogFiltersProps, - type IScheduledEmailDialogRecipientsProps, - type ScheduledEmailDialogHeaderDefaultProps, - type ScheduledEmailDialogTimezoneDefaultProps, -} from "../types.js"; +import { type IDefaultScheduledEmailDialogProps } from "../types.js"; import { getDefaultCronExpression } from "../utils/cron.js"; -import { isMobileView } from "../utils/responsive.js"; import { TIMEZONE_DEFAULT } from "../utils/timezone.js"; import { DashboardAttachments } from "./components/Attachments/DashboardAttachments.js"; @@ -114,20 +109,9 @@ export function ScheduledMailDialogRenderer({ const [selectedTabId, setSelectedTabId] = useState<"general" | "filters">("general"); - const { - isWhiteLabeled, - externalRecipient: externalRecipientOverride, - isSecondaryTitleVisible, - tabIds, - } = useAutomationsContext(); - const { - exportTemplates, - widgetTitle, - scheduledExportToEdit, - widget, - dashboardFilters, - notificationChannels, - } = useScheduledEmailDialogContext(); + const { isWhiteLabeled, tabIds } = useAutomationsContext(); + const { exportTemplates, widget, dashboardFilters, notificationChannels } = + useScheduledEmailDialogContext(); const handleScheduleDeleteSuccess = () => { onDeleteSuccess?.(); @@ -139,10 +123,8 @@ export function ScheduledMailDialogRenderer({ dashboardTitle, dateFormat, weekStart, - maxAutomationsRecipients, allowHourlyRecurrence, isCrossFiltering, - isExecutionTimestampMode, isSlidesExportEnabled, isAccessibilityModeEnabled, enableAutomationEvaluationMode, @@ -153,16 +135,11 @@ export function ScheduledMailDialogRenderer({ startDate, isTimezoneFeatureEnabled, canSelectScheduleTimezone, - scheduleTimezoneSelection, - defaultResolvedTimezone, scheduleTimezoneIsStale, } = useScheduledExportDraft(); const { - onTitleChange, onRecurrenceChange, onEvaluationModeChange, - onDestinationChange, - onRecipientsChange, onSubjectChange, onMessageChange, onDashboardAttachmentsChange, @@ -172,42 +149,16 @@ export function ScheduledMailDialogRenderer({ onCsvSettingsChange, onCsvRawSettingsChange, onSlidesTemplateIdChange, - onScheduleTimezoneChange, applyCurrentScheduleTimezone, } = useScheduledExportActions(); - const { defaultUser } = useScheduledExportData(); const { - selectedFilters, - availableFilters, - storeFilters, - filtersByTab, - editedFiltersByTab, - onFiltersChange, - onFiltersByTabChange, onApplyCurrentFilters, - onStoreFiltersChange, automationIsValid, - parametersEnabled, - visibleParametersByTab, - availableParametersByTab, - flatTabId, - onParameterAdd, - onParameterChange, - onParameterDelete, - onParameterAddByTab, - onParameterChangeByTab, - onParameterDeleteByTab, applyLatest: applyLatestParameters, } = useScheduledExportFilters(); const { selectedAttachments, xlsxSettings, pdfSettings, csvSettings, csvRawSettings, slidesTemplateIds } = useScheduledExportAttachments(); - const { - isSubmitDisabled, - validationErrorMessage, - isParentValid, - allowExternalRecipients, - allowOnlyLoggedUserRecipients, - } = useScheduledExportDialogValidity(); + const { isSubmitDisabled, validationErrorMessage } = useScheduledExportDialogValidity(); // a stale schedule timezone repairs through the same consent dialog as stale filters const [isApplyCurrentFiltersDialogOpen, setIsApplyCurrentFiltersDialogOpen] = useState( @@ -215,14 +166,7 @@ export function ScheduledMailDialogRenderer({ ); const { handleSaveScheduledEmail, isSavingScheduledEmail, savingErrorMessage } = - useSaveScheduledEmailToBackend(editedAutomation, { - onSuccess, - onError, - onSubmit, - onSaveSuccess, - onSaveError, - onSave, - }); + useSaveScheduledEmailToBackend({ onSuccess, onError, onSubmit, onSaveSuccess, onSaveError, onSave }); const { returnFocusTo } = useScheduleEmailDialogAccessibility(); @@ -237,13 +181,15 @@ export function ScheduledMailDialogRenderer({ const errorMessage = savingErrorMessage ?? validationErrorMessage ?? missingAttachmentsErrorMessage; - const helpTextId = isMobileView() - ? defineMessage({ id: "dialogs.schedule.email.footer.title.short" }).id - : defineMessage({ id: "dialogs.schedule.email.footer.title" }).id; - const titleElementId = useIdPrefixed("title"); - const submitDisabled = isSubmitDisabled || isSavingScheduledEmail || isExecutionTimestampMode; + const actionBarDefaultProps = useScheduledEmailDialogActionBarProps({ + onCancel, + onSubmit: handleSaveScheduledEmail, + isSaving: isSavingScheduledEmail, + onDelete: () => setScheduledEmailToDelete(editedAutomation), + }); + const submitDisabled = actionBarDefaultProps.isSubmitDisabled; const handleSubmitForm = useCallback( (e: KeyboardEvent) => { @@ -254,49 +200,17 @@ export function ScheduledMailDialogRenderer({ [submitDisabled, handleSaveScheduledEmail], ); - // the exact props the default dialog renders the "Time zone" section with; also handed to the - // Timezone slot so a wrap keeps the default behavior - const timezoneDefaultProps: ScheduledEmailDialogTimezoneDefaultProps = { - isWidget: !!widget, - selection: scheduleTimezoneSelection, - defaultResolvedTimezone, - onTimezoneChange: onScheduleTimezoneChange, - }; - - const { secondaryTitle, secondaryTitleIcon } = useMemo(() => { - if (widget) { - return { - secondaryTitle: widgetTitle, - secondaryTitleIcon: ( - - ), - }; - } - return { - secondaryTitle: dashboardTitle, - secondaryTitleIcon: ( - - ), - }; - }, [widget, widgetTitle, dashboardTitle, intl]); + const headerDefaultProps = useScheduledEmailDialogHeaderProps({ + onBack, + onTitleKeyDown: handleSubmitForm, + ref: dialogTitleRef, + }); + const filtersDefaultProps = useScheduledEmailDialogFiltersProps(); + const destinationDefaultProps = useScheduledEmailDialogDestinationProps(); + const recipientsDefaultProps = useScheduledEmailDialogRecipientsProps({ + onKeyDownSubmit: handleSubmitForm, + }); + const timezoneDefaultProps = useScheduledEmailDialogTimezoneProps(); const tabs: IUiTab[] = useMemo( () => [ @@ -349,76 +263,6 @@ export function ScheduledMailDialogRenderer({ ); const isInPlatformChannel = selectedChannel?.destinationType === "inPlatform"; - const filtersDefaultProps: IScheduledEmailDialogFiltersProps = { - availableFilters, - selectedFilters, - onFiltersChange, - storeFilters, - onStoreFiltersChange, - isDashboardAutomation: !widget, - filtersByTab, - editedFiltersByTab, - onFiltersByTabChange, - parameters: flatTabId ? visibleParametersByTab[flatTabId] : undefined, - availableParameters: flatTabId ? availableParametersByTab[flatTabId] : undefined, - onParameterAdd, - onParameterChange, - onParameterDelete, - parametersByTab: visibleParametersByTab, - availableParametersByTab, - onParameterAddByTab, - onParameterChangeByTab, - onParameterDeleteByTab, - parametersEnabled, - }; - - const destinationDefaultProps: IAutomationDialogDestinationProps = { - notificationChannels, - selectedNotificationChannelId: editedAutomation.notificationChannel, - onChange: onDestinationChange, - }; - - const recipientsDefaultProps: IScheduledEmailDialogRecipientsProps = { - loggedUser: defaultUser, - value: editedAutomation.recipients ?? [], - onChange: onRecipientsChange, - allowEmptySelection: true, - allowOnlyLoggedUserRecipients, - allowExternalRecipients, - maxRecipients: maxAutomationsRecipients, - notificationChannels, - notificationChannelId: editedAutomation.notificationChannel, - onKeyDownSubmit: handleSubmitForm, - externalRecipientOverride, - }; - - const actionBarDefaultProps: IAutomationDialogActionBarProps = { - cancelButtonText: intl.formatMessage({ id: "cancel" }), - submitButtonText: scheduledExportToEdit - ? intl.formatMessage({ id: "dialogs.schedule.email.save" }) - : intl.formatMessage({ id: "dialogs.schedule.email.create" }), - onCancel: () => onCancel?.(), - onSubmit: handleSaveScheduledEmail, - isSubmitDisabled: submitDisabled, - isSaving: isSavingScheduledEmail, - submitButtonTooltipText: isExecutionTimestampMode - ? intl.formatMessage({ id: "dialogs.schedule.email.save.executionTimestampMode" }) - : undefined, - ...(isWhiteLabeled - ? {} - : { - helpLinkText: intl.formatMessage({ id: helpTextId }), - helpLinkHref: - "https://www.gooddata.com/docs/cloud/create-dashboards/automation/scheduled-exports/#ScheduleExportsinDashboards-ScheduleExport", - }), - ...(scheduledExportToEdit - ? { - deleteButtonText: intl.formatMessage({ id: "delete" }), - onDelete: () => setScheduledEmailToDelete(editedAutomation), - } - : {}), - }; - return ( <> ) } @@ -473,35 +313,14 @@ export function ScheduledMailDialogRenderer({ ) : undefined } - isSubmitDisabled={submitDisabled} - submitButtonTooltipText={ - isExecutionTimestampMode - ? intl.formatMessage({ - id: "dialogs.schedule.email.save.executionTimestampMode", - }) - : undefined - } + isSubmitDisabled={actionBarDefaultProps.isSubmitDisabled} + submitButtonTooltipText={actionBarDefaultProps.submitButtonTooltipText} initialFocus={dialogTitleRef} submitOnEnterKey={false} onCancel={onCancel} - onSubmit={handleSaveScheduledEmail} + onSubmit={actionBarDefaultProps.onSubmit} headline={undefined} headerLeftButtonRenderer={() => { - const headerDefaultProps: ScheduledEmailDialogHeaderDefaultProps = { - title: editedAutomation.title ?? "", - onChange: onTitleChange, - onBack, - placeholder: intl.formatMessage({ - id: "dialogs.schedule.email.title.placeholder", - }), - ref: dialogTitleRef, - onTitleKeyDown: handleSubmitForm, - secondaryTitle, - secondaryTitleIcon, - isSecondaryTitleVisible: isSecondaryTitleVisible - ? isParentValid - : undefined, - }; return HeaderSlot ? ( { vi.resetModules(); }); +// What the mocked draft context returns; each test sets it through renderSaveHook. +const draftState = vi.hoisted(() => ({ + editedAutomation: undefined as unknown as IAutomationMetadataObject | IAutomationMetadataObjectDefinition, +})); + vi.mock("../../../contexts/ScheduledEmailDialogContext.js", () => ({ useScheduledEmailDialogContext: () => ({ createScheduledEmail: createScheduledEmailMock, @@ -36,7 +41,14 @@ vi.mock("../../../contexts/ScheduledEmailDialogContext.js", () => ({ }), })); -import { useSaveScheduledEmailToBackend } from "./useSaveScheduledEmailToBackend.js"; +vi.mock("../../state/ScheduledExportDraftContext.js", () => ({ + useScheduledExportDraft: () => ({ editedAutomation: draftState.editedAutomation }), +})); + +import { + useSaveScheduledEmailToBackend, + type IUseSaveScheduledEmailCallbacks, +} from "./useSaveScheduledEmailToBackend.js"; // --------------------------------------------------------------------------- // Fixtures @@ -73,9 +85,10 @@ const createdAutomation: IAutomationMetadataObject = { function renderSaveHook( automation: IAutomationMetadataObject | IAutomationMetadataObjectDefinition, - callbacks: Parameters[1] = {}, + callbacks: IUseSaveScheduledEmailCallbacks = {}, ) { - return renderHook(() => useSaveScheduledEmailToBackend(automation, callbacks), { + draftState.editedAutomation = automation; + return renderHook(() => useSaveScheduledEmailToBackend(callbacks), { wrapper: IntlWrapper, }); } diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/DefaultScheduledEmailDialog/hooks/useSaveScheduledEmailToBackend.ts b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/DefaultScheduledEmailDialog/hooks/useSaveScheduledEmailToBackend.ts index 4aeb3bbb662..c9dae65fdc5 100644 --- a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/DefaultScheduledEmailDialog/hooks/useSaveScheduledEmailToBackend.ts +++ b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/DefaultScheduledEmailDialog/hooks/useSaveScheduledEmailToBackend.ts @@ -18,27 +18,47 @@ import { import { type GoodDataSdkError } from "@gooddata/sdk-ui"; import { useScheduledEmailDialogContext } from "../../../contexts/ScheduledEmailDialogContext.js"; +import { useScheduledExportDraft } from "../../state/ScheduledExportDraftContext.js"; import { type IScheduledEmailDialogProps } from "../../types.js"; -export function useSaveScheduledEmailToBackend( - automation: IAutomationMetadataObject | IAutomationMetadataObjectDefinition, - { - onSuccess, - onError, - onSubmit, - onSaveSuccess, - onSaveError, - onSave, - }: Pick< - IScheduledEmailDialogProps, - "onSuccess" | "onError" | "onSubmit" | "onSaveSuccess" | "onSaveError" | "onSave" - >, -) { +/** + * Lifecycle callbacks of {@link useSaveScheduledEmailToBackend}: `onSubmit`/`onSuccess`/`onError` + * (create) and `onSave`/`onSaveSuccess`/`onSaveError` (edit). + * + * @internal + */ +export type IUseSaveScheduledEmailCallbacks = Pick< + IScheduledEmailDialogProps, + "onSuccess" | "onError" | "onSubmit" | "onSaveSuccess" | "onSaveError" | "onSave" +>; + +/** + * Saves the scheduled-export dialog's draft: creates a new schedule or updates the edited one after + * sanitizing it, keeps a 400 response's detail as an in-dialog error message, and routes everything + * else to the matching callback. + * + * Reads the draft from the scheduled-export state contexts, so it throws outside them. + * + * @internal + */ +export function useSaveScheduledEmailToBackend({ + onSuccess, + onError, + onSubmit, + onSaveSuccess, + onSaveError, + onSave, +}: IUseSaveScheduledEmailCallbacks): { + handleSaveScheduledEmail: () => void; + isSavingScheduledEmail: boolean; + savingErrorMessage: string | undefined; +} { const intl = useIntl(); const [savingErrorMessage, setSavingErrorMessage] = useState(undefined); const [isSavingScheduledEmail, setIsSavingScheduledEmail] = useState(false); const { createScheduledEmail, saveScheduledEmail } = useScheduledEmailDialogContext(); + const { editedAutomation } = useScheduledExportDraft(); const handleCreateScheduledEmail = useCallback( async (scheduledEmail: IAutomationMetadataObject | IAutomationMetadataObjectDefinition) => { @@ -96,12 +116,12 @@ export function useSaveScheduledEmailToBackend( ); const handleSaveScheduledEmail = useCallback((): void => { - if (automation.id) { - void handleUpdateScheduledEmail(automation); + if (editedAutomation.id) { + void handleUpdateScheduledEmail(editedAutomation); } else { - void handleCreateScheduledEmail(automation); + void handleCreateScheduledEmail(editedAutomation); } - }, [automation, handleUpdateScheduledEmail, handleCreateScheduledEmail]); + }, [editedAutomation, handleUpdateScheduledEmail, handleCreateScheduledEmail]); return { handleSaveScheduledEmail, isSavingScheduledEmail, savingErrorMessage }; } diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/DefaultScheduledEmailDialog/hooks/useScheduleTimezone.ts b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/DefaultScheduledEmailDialog/hooks/useScheduleTimezone.ts index f366937f313..63a5eedb428 100644 --- a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/DefaultScheduledEmailDialog/hooks/useScheduleTimezone.ts +++ b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/DefaultScheduledEmailDialog/hooks/useScheduleTimezone.ts @@ -12,6 +12,7 @@ import { getExportDefinitionsTimezone, withExportDefinitionsTimezone, } from "../../state/exportDefinitions.js"; +import { type IScheduleTimezoneState } from "../../state/types.js"; import { type IScheduleTimezoneSelection } from "../../types.js"; import { useExportTimezones } from "./useExportTimezones.js"; @@ -36,7 +37,7 @@ export function useScheduleTimezone({ scheduledExportToEdit, widget, setEditedAutomation, -}: IUseScheduleTimezoneProps) { +}: IUseScheduleTimezoneProps): IScheduleTimezoneState { const isWidget = !!widget; const { isTimezoneFeatureEnabled, canSelectScheduleTimezone, initialSelection, defaultResolvedTimezone } = useExportTimezones(isWidget); @@ -101,29 +102,13 @@ export function useScheduleTimezone({ isTimezoneFeatureEnabled, canSelectScheduleTimezone, scheduleTimezoneSelection: selection, - /** - * Concrete timezone the dashboard-schedule Default option currently resolves to - * (display only). - */ defaultResolvedTimezone, onScheduleTimezoneChange, - /** - * True when the edited schedule cannot behave correctly as stored (widget schedule - * missing a dashboard-scoped timezone); feeds the apply-current-state confirmation. - */ scheduleTimezoneIsStale, applyCurrentScheduleTimezone, - /** - * Live value for export definitions created after the dialog opened (attachment changes). - * Active when the section is interactive, and also when an edited schedule carries a - * stored timezone while the section is hidden — new definitions must then inherit the - * stored value instead of the store-derived default. - */ scheduleTimezone: { active: canSelectScheduleTimezone || !!storedTimezoneId, timezoneId: selection.shouldSave ? selection.id : undefined, }, }; } - -export type ScheduleTimezoneState = ReturnType; diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/ScheduledEmailDialogStateProvider.tsx b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/ScheduledEmailDialogStateProvider.tsx index 5861e4ecc0a..a31dad82919 100644 --- a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/ScheduledEmailDialogStateProvider.tsx +++ b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/ScheduledEmailDialogStateProvider.tsx @@ -8,19 +8,15 @@ import { useAutomationFiltersSelect } from "../../shared/automationFilters/useAu import { useScheduleTimezone } from "../DefaultScheduledEmailDialog/hooks/useScheduleTimezone.js"; import { getDefaultPdfPageSize } from "../utils/pdfPageSize.js"; +import { ScheduledExportActionsContextProvider } from "./ScheduledExportActionsContext.js"; +import { ScheduledExportDataContextProvider } from "./ScheduledExportDataContext.js"; +import { ScheduledExportDraftContextProvider } from "./ScheduledExportDraftContext.js"; +import { ScheduledExportFiltersContextProvider } from "./ScheduledExportFiltersContext.js"; import { type IScheduledExportActionsContextValue, - ScheduledExportActionsContextProvider, -} from "./ScheduledExportActionsContext.js"; -import { type IScheduledExportDataContextValue, - ScheduledExportDataContextProvider, -} from "./ScheduledExportDataContext.js"; -import { type IScheduledExportDraftContextValue, - ScheduledExportDraftContextProvider, -} from "./ScheduledExportDraftContext.js"; -import { ScheduledExportFiltersContextProvider } from "./ScheduledExportFiltersContext.js"; +} from "./types.js"; import { useScheduledEmailEffectiveFilters } from "./useScheduledEmailEffectiveFilters.js"; import { useScheduledEmailExportSettings } from "./useScheduledEmailExportSettings.js"; import { useScheduledEmailFiltersModel } from "./useScheduledEmailFiltersModel.js"; diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/ScheduledExportActionsContext.ts b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/ScheduledExportActionsContext.ts index d1887df1300..f349f48c147 100644 --- a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/ScheduledExportActionsContext.ts +++ b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/ScheduledExportActionsContext.ts @@ -2,46 +2,8 @@ import { createContext, useContext } from "react"; -import { type useScheduleTimezone } from "../DefaultScheduledEmailDialog/hooks/useScheduleTimezone.js"; - import { missingScheduledExportStateProvider } from "./missingScheduledExportStateProvider.js"; -import { type useScheduledEmailExportSettings } from "./useScheduledEmailExportSettings.js"; -import { type useScheduledEmailFormState } from "./useScheduledEmailFormState.js"; - -/** - * The scheduled-export dialog's draft mutators: the form fields' change handlers, the raw draft - * setter, and the attachment handlers, which rebuild the automation's export definitions while - * carrying the stored export-parameter wire across the rebuild. - * - * Every handler holds its identity across a keystroke, so a consumer that only mutates does not - * re-render while the draft changes. The two attachment handlers are rebuilt when the filters they - * write into a new export definition change. The shape is derived from `useScheduledEmailFormState` - * and `useScheduledEmailExportSettings`, which own these handlers. - * - * @internal - */ -export type IScheduledExportActionsContextValue = Pick< - ReturnType, - | "setEditedAutomation" - | "onTitleChange" - | "onRecurrenceChange" - | "onEvaluationModeChange" - | "onDestinationChange" - | "onRecipientsChange" - | "onSubjectChange" - | "onMessageChange" -> & - Pick< - ReturnType, - | "onDashboardAttachmentsChange" - | "onWidgetAttachmentsChange" - | "onXlsxSettingsChange" - | "onPdfSettingsChange" - | "onCsvSettingsChange" - | "onCsvRawSettingsChange" - | "onSlidesTemplateIdChange" - > & - Pick, "onScheduleTimezoneChange" | "applyCurrentScheduleTimezone">; +import { type IScheduledExportActionsContextValue } from "./types.js"; const ScheduledExportActionsContext = createContext( undefined, diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/ScheduledExportDataContext.ts b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/ScheduledExportDataContext.ts index ad2b2efb624..0f913188bc7 100644 --- a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/ScheduledExportDataContext.ts +++ b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/ScheduledExportDataContext.ts @@ -3,22 +3,7 @@ import { createContext, useContext } from "react"; import { missingScheduledExportStateProvider } from "./missingScheduledExportStateProvider.js"; -import { type useScheduledEmailFormState } from "./useScheduledEmailFormState.js"; - -/** - * The scheduled-export dialog's recipient defaults: the logged-in user as a recipient, and the - * recipient a new schedule is seeded with, which an external-recipient override replaces. - * - * Changes when the current user or the override changes, so not while the draft is edited. Two - * members is the whole of it: this dialog loads no execution data. The shape is derived from - * `useScheduledEmailFormState`, which owns these values. - * - * @internal - */ -export type IScheduledExportDataContextValue = Pick< - ReturnType, - "defaultUser" | "defaultRecipient" ->; +import { type IScheduledExportDataContextValue } from "./types.js"; const ScheduledExportDataContext = createContext(undefined); ScheduledExportDataContext.displayName = "ScheduledExportDataContext"; diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/ScheduledExportDraftContext.ts b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/ScheduledExportDraftContext.ts index 098ceca605b..997b3bcc26c 100644 --- a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/ScheduledExportDraftContext.ts +++ b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/ScheduledExportDraftContext.ts @@ -2,41 +2,8 @@ import { createContext, useContext } from "react"; -import { type useScheduleTimezone } from "../DefaultScheduledEmailDialog/hooks/useScheduleTimezone.js"; - import { missingScheduledExportStateProvider } from "./missingScheduledExportStateProvider.js"; -import { type useScheduledEmailFormState } from "./useScheduledEmailFormState.js"; - -/** - * The scheduled-export dialog's edit draft: the automation being edited, the baseline it is - * compared against for the submit gate, the normalized start date, and the per-field validity - * flags the form fields report back. - * - * Changes on every keystroke; consumers re-render by design. The shape is derived from - * `useScheduledEmailFormState`, which owns these values, and `useScheduleTimezone`, which owns the - * "Time zone" section's view state (a timezone pick also edits the draft's export definitions, so - * the selection changes together with the draft). - * - * @internal - */ -export type IScheduledExportDraftContextValue = Pick< - ReturnType, - | "editedAutomation" - | "originalAutomation" - | "startDate" - | "isCronValid" - | "isTitleValid" - | "isSubjectValid" - | "isOnMessageValid" -> & - Pick< - ReturnType, - | "isTimezoneFeatureEnabled" - | "canSelectScheduleTimezone" - | "scheduleTimezoneSelection" - | "defaultResolvedTimezone" - | "scheduleTimezoneIsStale" - >; +import { type IScheduledExportDraftContextValue } from "./types.js"; const ScheduledExportDraftContext = createContext(undefined); ScheduledExportDraftContext.displayName = "ScheduledExportDraftContext"; diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/ScheduledExportFiltersContext.ts b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/ScheduledExportFiltersContext.ts index c7ed534fb24..6aaa81ce261 100644 --- a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/ScheduledExportFiltersContext.ts +++ b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/ScheduledExportFiltersContext.ts @@ -3,20 +3,7 @@ import { createContext, useContext } from "react"; import { missingScheduledExportStateProvider } from "./missingScheduledExportStateProvider.js"; -import { type useScheduledEmailFiltersModel } from "./useScheduledEmailFiltersModel.js"; - -/** - * The scheduled-export dialog's filter and export-parameter model: the current selection and the - * available filters, flat and per-tab, the handlers that mutate them and mirror the result into the - * draft, the store-filters toggle, the staleness gate that opens the apply-current-filters dialog, - * and the parameter chips with their add, change, delete and apply-latest handlers. - * - * The model's whole return is the context value rather than a selection from it, because the model - * is the unit a replacement reads: a member cannot be dropped from it. - * - * @internal - */ -export type IScheduledExportFiltersContextValue = ReturnType; +import { type IScheduledExportFiltersContextValue } from "./types.js"; const ScheduledExportFiltersContext = createContext( undefined, diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/types.ts b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/types.ts new file mode 100644 index 00000000000..5ed20eb9ee5 --- /dev/null +++ b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/types.ts @@ -0,0 +1,266 @@ +// (C) 2026 GoodData Corporation + +import { type Dispatch, type SetStateAction } from "react"; + +import { + type DashboardAttachmentType, + type FilterContextItem, + type IAutomationMetadataObjectDefinition, + type IAutomationRecipient, + type IDashboardExportParameter, + type IExportDefinitionVisualizationObjectSettings, + type IdentifierRef, + type ParameterValue, + type WidgetAttachmentType, +} from "@gooddata/sdk-model"; + +import { type IAutomationFiltersTab } from "../../../../model/store/filtering/types.js"; +import { type IAutomationParameter } from "../../shared/automationFilters/automationParameters.js"; +import { type IScheduleTimezoneSelection } from "../types.js"; + +/** + * The scheduled-export dialog's edit draft: the automation being edited, its baseline, the derived + * start date, the per-field validity flags, and the "Time zone" section's state. + * + * Changes on every keystroke; consumers re-render by design. + * + * @internal + */ +export interface IScheduledExportDraftContextValue { + editedAutomation: IAutomationMetadataObjectDefinition; + /** + * The draft as it was when the dialog opened; the dirty check compares against it. + */ + originalAutomation: IAutomationMetadataObjectDefinition; + /** + * The schedule's first run in the user's timezone, normalized for the recurrence form. + */ + startDate: Date; + isCronValid: boolean; + isTitleValid: boolean; + isSubjectValid: boolean; + isOnMessageValid: boolean; + /** + * The enableTimezoneChange setting; timezone-related presentation is gated by it. + */ + isTimezoneFeatureEnabled: boolean; + /** + * Whether the schedule may define its own export timezone (the "Time zone" section renders). + */ + canSelectScheduleTimezone: boolean; + scheduleTimezoneSelection: IScheduleTimezoneSelection; + /** + * Concrete timezone the dashboard-schedule Default option currently resolves to (display only). + */ + defaultResolvedTimezone: string | undefined; + /** + * True when the edited schedule cannot behave correctly as stored (widget schedule missing a + * dashboard-scoped timezone); feeds the apply-current-state confirmation. + */ + scheduleTimezoneIsStale: boolean; +} + +/** + * The scheduled-export dialog's mutators: the draft setter, the form change-handlers, the + * attachment handlers and the "Time zone" section handlers. + * + * Changes rarely. + * + * @internal + */ +export interface IScheduledExportActionsContextValue { + setEditedAutomation: Dispatch>; + onTitleChange: (value: string) => void; + onRecurrenceChange: (cronExpression: string, startDate: Date | null, isValid: boolean) => void; + onEvaluationModeChange: (isShared: boolean) => void; + onDestinationChange: (notificationChannelId: string) => void; + onRecipientsChange: (recipients: IAutomationRecipient[]) => void; + onSubjectChange: (value: string | number, isValid: boolean) => void; + onMessageChange: (value: string, isValid: boolean) => void; + /** + * Replaces the dashboard export formats; new definitions inherit the current filters and timezone. + */ + onDashboardAttachmentsChange: (formats: DashboardAttachmentType[]) => void; + /** + * Replaces the widget export formats; throws when the dialog has no widget. + */ + onWidgetAttachmentsChange: (formats: WidgetAttachmentType[]) => void; + onXlsxSettingsChange: (settings: IExportDefinitionVisualizationObjectSettings) => void; + onPdfSettingsChange: (settings: IExportDefinitionVisualizationObjectSettings) => void; + onCsvSettingsChange: (settings: IExportDefinitionVisualizationObjectSettings) => void; + onCsvRawSettingsChange: (settings: IExportDefinitionVisualizationObjectSettings) => void; + onSlidesTemplateIdChange: (templateId: string | undefined, format: "PPTX" | "PDF_SLIDES" | "PDF") => void; + /** + * Picks the export-content timezone; undefined selects the Default option. + */ + onScheduleTimezoneChange: (timezoneId: string | undefined) => void; + /** + * Repairs a stale schedule timezone with the currently effective one. + */ + applyCurrentScheduleTimezone: () => void; +} + +/** + * The scheduled-export dialog's recipient defaults: the logged-in user as a recipient, and the + * recipient a new schedule is seeded with, which an external-recipient override replaces. + * + * Changes when the current user or the override changes, so not while the draft is edited. + * + * @internal + */ +export interface IScheduledExportDataContextValue { + defaultUser: IAutomationRecipient; + defaultRecipient: IAutomationRecipient; +} + +/** + * The scheduled-export dialog's filter and export-parameter model: the current selection and + * available filters (flat and per tab), the handlers that mutate them, the staleness gate, and the + * export-parameter chips with their handlers. + * + * Changes when a filter, a parameter or the store-filters toggle is edited; never per keystroke. + * + * @internal + */ +export interface IScheduledExportFiltersContextValue { + selectedFilters: FilterContextItem[]; + availableFilters: FilterContextItem[] | undefined; + /** + * Whether the schedule stores its own filters instead of using the latest saved dashboard filters. + */ + storeFilters: boolean; + /** + * Filters structured per dashboard tab; present with more than one tab. + */ + filtersByTab: IAutomationFiltersTab[] | undefined; + editedFiltersByTab: Record | undefined; + /** + * Replaces the selection with the complete updated array and mirrors it into the export + * definitions. `storeFiltersParam` overrides the current toggle value for this write. + */ + onFiltersChange: (filters: FilterContextItem[], storeFiltersParam?: boolean) => void; + onFiltersByTabChange: ( + newFiltersByTab: Record, + storeFiltersParam?: boolean, + ) => void; + /** + * Replaces the selection with the dashboard's current filters (the stale-filters repair). + */ + onApplyCurrentFilters: () => void; + /** + * Toggles filter and parameter persistence. The current filters must be passed back — flat mode + * as `(value, filters, undefined)`, by-tab mode as `(value, undefined, filtersByTab)`. + */ + onStoreFiltersChange: ( + value: boolean, + filters?: FilterContextItem[], + filtersByTabParam?: Record, + ) => void; + /** + * False when the saved filters or the saved parameters no longer match the dashboard; gates the + * stale-filters confirmation. + */ + automationIsValid: boolean; + /** + * Whether the saved filters alone no longer match the dashboard. + */ + filtersAreStale: boolean; + parametersEnabled: boolean; + /** + * Per-tab visible parameters to render as chips. + */ + visibleParametersByTab: Record; + /** + * Per-tab workspace parameters addable via the "+" menu. + */ + availableParametersByTab: Record; + /** + * The tab the flat (non-tabbed) UI edits; undefined for multi-tab dashboards or when no + * parameter context applies. + */ + flatTabId: string | undefined; + onParameterAdd: (ref: IdentifierRef) => void; + onParameterChange: (ref: IdentifierRef, value: ParameterValue) => void; + onParameterDelete: (ref: IdentifierRef) => void; + onParameterAddByTab: (tabId: string, ref: IdentifierRef) => void; + onParameterChangeByTab: (tabId: string, ref: IdentifierRef, value: ParameterValue) => void; + onParameterDeleteByTab: (tabId: string, ref: IdentifierRef) => void; + /** + * Resets the parameters to the dashboard's current effective values (the "apply latest" flow). + */ + applyLatest: () => void; + /** + * Re-encodes the parameters onto the automation with the new persistence value. + */ + onStoreParametersChange: (storeParameters: boolean) => void; +} + +/** + * Return type of `useScheduledEmailFormState`, composed from the context values it feeds. + * + * @internal + */ +export type IScheduledEmailFormState = Pick< + IScheduledExportDraftContextValue, + | "editedAutomation" + | "originalAutomation" + | "startDate" + | "isCronValid" + | "isTitleValid" + | "isSubjectValid" + | "isOnMessageValid" +> & + Pick< + IScheduledExportActionsContextValue, + | "setEditedAutomation" + | "onTitleChange" + | "onRecurrenceChange" + | "onEvaluationModeChange" + | "onDestinationChange" + | "onRecipientsChange" + | "onSubjectChange" + | "onMessageChange" + > & + IScheduledExportDataContextValue; + +/** + * Return type of `useScheduledEmailExportSettings`: the attachment handlers plus the + * parameter-wire writer only the filters model consumes. + * + * @internal + */ +export type IScheduledEmailExportSettings = Pick< + IScheduledExportActionsContextValue, + | "onDashboardAttachmentsChange" + | "onWidgetAttachmentsChange" + | "onXlsxSettingsChange" + | "onPdfSettingsChange" + | "onCsvSettingsChange" + | "onCsvRawSettingsChange" + | "onSlidesTemplateIdChange" +> & { + setParametersWire: (wire: Record | undefined) => void; +}; + +/** + * Return type of `useScheduleTimezone`: the "Time zone" section's state and handlers plus the live + * value export-definition rebuilds read. + * + * @internal + */ +export type IScheduleTimezoneState = Pick< + IScheduledExportDraftContextValue, + | "isTimezoneFeatureEnabled" + | "canSelectScheduleTimezone" + | "scheduleTimezoneSelection" + | "defaultResolvedTimezone" + | "scheduleTimezoneIsStale" +> & + Pick & { + /** + * Live value for export definitions created after the dialog opened. Active when the section + * is interactive, and also when an edited schedule carries a stored timezone while the + * section is hidden. + */ + scheduleTimezone: { active: boolean; timezoneId: string | undefined }; + }; diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/useScheduledEmailDialogRegionProps.tsx b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/useScheduledEmailDialogRegionProps.tsx new file mode 100644 index 00000000000..c146156e019 --- /dev/null +++ b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/useScheduledEmailDialogRegionProps.tsx @@ -0,0 +1,331 @@ +// (C) 2026 GoodData Corporation + +import { type KeyboardEvent, type Ref, useMemo } from "react"; + +import { defineMessage, useIntl } from "react-intl"; + +import { UiIcon } from "@gooddata/sdk-ui-kit"; + +import { useAutomationsContext } from "../../contexts/AutomationsContext.js"; +import { useScheduledEmailDialogContext } from "../../contexts/ScheduledEmailDialogContext.js"; +import { + type IAutomationDialogActionBarProps, + type IAutomationDialogDestinationProps, +} from "../../shared/slots/types.js"; +import { + type IScheduledEmailDialogFiltersProps, + type IScheduledEmailDialogRecipientsProps, + type ScheduledEmailDialogHeaderDefaultProps, + type ScheduledEmailDialogTimezoneDefaultProps, +} from "../types.js"; +import { isMobileView } from "../utils/responsive.js"; + +import { useScheduledExportActions } from "./ScheduledExportActionsContext.js"; +import { useScheduledExportData } from "./ScheduledExportDataContext.js"; +import { useScheduledExportDraft } from "./ScheduledExportDraftContext.js"; +import { useScheduledExportFilters } from "./ScheduledExportFiltersContext.js"; +import { useScheduledExportDialogValidity } from "./useScheduledExportDialogValidity.js"; + +/** + * Inputs of {@link useScheduledEmailDialogHeaderProps} that come from the dialog rather than its state. + * + * @internal + */ +export interface IUseScheduledEmailDialogHeaderPropsInput { + /** + * Returns to the management dialog; the header's back button calls it. + */ + onBack?: () => void; + + /** + * Key-down handler of the title input; the dialog submits on Enter through it. + */ + onTitleKeyDown: (event: KeyboardEvent) => void; + + /** + * The dialog's initial-focus ref, attached to the title input. + */ + ref?: Ref; +} + +/** + * The exact props the default scheduled-email dialog renders its header region with. + * + * Throws outside the scheduled-export dialog's state providers. + * + * @internal + */ +export function useScheduledEmailDialogHeaderProps({ + onBack, + onTitleKeyDown, + ref, +}: IUseScheduledEmailDialogHeaderPropsInput): ScheduledEmailDialogHeaderDefaultProps { + const intl = useIntl(); + const { isSecondaryTitleVisible } = useAutomationsContext(); + const { widget, widgetTitle, dashboardTitle } = useScheduledEmailDialogContext(); + const { editedAutomation } = useScheduledExportDraft(); + const { onTitleChange } = useScheduledExportActions(); + const { isParentValid } = useScheduledExportDialogValidity(); + + const { secondaryTitle, secondaryTitleIcon } = useMemo(() => { + if (widget) { + return { + secondaryTitle: widgetTitle, + secondaryTitleIcon: ( + + ), + }; + } + return { + secondaryTitle: dashboardTitle, + secondaryTitleIcon: ( + + ), + }; + }, [widget, widgetTitle, dashboardTitle, intl]); + + return { + title: editedAutomation.title ?? "", + onChange: onTitleChange, + onBack, + placeholder: intl.formatMessage({ id: "dialogs.schedule.email.title.placeholder" }), + ref, + onTitleKeyDown, + secondaryTitle, + secondaryTitleIcon, + isSecondaryTitleVisible: isSecondaryTitleVisible ? isParentValid : undefined, + }; +} + +/** + * The exact props the default scheduled-email dialog renders its filters region with. + * + * Throws outside the scheduled-export dialog's state providers. + * + * @internal + */ +export function useScheduledEmailDialogFiltersProps(): IScheduledEmailDialogFiltersProps { + const { widget } = useScheduledEmailDialogContext(); + const { + selectedFilters, + availableFilters, + storeFilters, + filtersByTab, + editedFiltersByTab, + onFiltersChange, + onFiltersByTabChange, + onStoreFiltersChange, + parametersEnabled, + visibleParametersByTab, + availableParametersByTab, + flatTabId, + onParameterAdd, + onParameterChange, + onParameterDelete, + onParameterAddByTab, + onParameterChangeByTab, + onParameterDeleteByTab, + } = useScheduledExportFilters(); + + return { + availableFilters, + selectedFilters, + onFiltersChange, + storeFilters, + onStoreFiltersChange, + isDashboardAutomation: !widget, + filtersByTab, + editedFiltersByTab, + onFiltersByTabChange, + parameters: flatTabId ? visibleParametersByTab[flatTabId] : undefined, + availableParameters: flatTabId ? availableParametersByTab[flatTabId] : undefined, + onParameterAdd, + onParameterChange, + onParameterDelete, + parametersByTab: visibleParametersByTab, + availableParametersByTab, + onParameterAddByTab, + onParameterChangeByTab, + onParameterDeleteByTab, + parametersEnabled, + }; +} + +/** + * The exact props the default scheduled-email dialog renders its destination region with. + * + * Throws outside the scheduled-export dialog's state providers. + * + * @internal + */ +export function useScheduledEmailDialogDestinationProps(): IAutomationDialogDestinationProps { + const { notificationChannels } = useScheduledEmailDialogContext(); + const { editedAutomation } = useScheduledExportDraft(); + const { onDestinationChange } = useScheduledExportActions(); + + return { + notificationChannels, + selectedNotificationChannelId: editedAutomation.notificationChannel, + onChange: onDestinationChange, + }; +} + +/** + * Inputs of {@link useScheduledEmailDialogRecipientsProps} that come from the dialog rather than its state. + * + * @internal + */ +export interface IUseScheduledEmailDialogRecipientsPropsInput { + /** + * Key-down handler of the recipients input; the dialog submits on Enter through it. + */ + onKeyDownSubmit: (event: KeyboardEvent) => void; +} + +/** + * The exact props the default scheduled-email dialog renders its recipients region with. + * + * Throws outside the scheduled-export dialog's state providers. + * + * @internal + */ +export function useScheduledEmailDialogRecipientsProps({ + onKeyDownSubmit, +}: IUseScheduledEmailDialogRecipientsPropsInput): IScheduledEmailDialogRecipientsProps { + const { maxAutomationsRecipients, externalRecipient: externalRecipientOverride } = + useAutomationsContext(); + const { notificationChannels } = useScheduledEmailDialogContext(); + const { editedAutomation } = useScheduledExportDraft(); + const { onRecipientsChange } = useScheduledExportActions(); + const { defaultUser } = useScheduledExportData(); + const { allowExternalRecipients, allowOnlyLoggedUserRecipients } = useScheduledExportDialogValidity(); + + return { + loggedUser: defaultUser, + value: editedAutomation.recipients ?? [], + onChange: onRecipientsChange, + allowEmptySelection: true, + allowOnlyLoggedUserRecipients, + allowExternalRecipients, + maxRecipients: maxAutomationsRecipients, + notificationChannels, + notificationChannelId: editedAutomation.notificationChannel, + onKeyDownSubmit, + externalRecipientOverride, + }; +} + +/** + * The exact props the default scheduled-email dialog renders its "Time zone" section with. + * + * Throws outside the scheduled-export dialog's state providers. + * + * @internal + */ +export function useScheduledEmailDialogTimezoneProps(): ScheduledEmailDialogTimezoneDefaultProps { + const { widget } = useScheduledEmailDialogContext(); + const { scheduleTimezoneSelection, defaultResolvedTimezone } = useScheduledExportDraft(); + const { onScheduleTimezoneChange } = useScheduledExportActions(); + + return { + isWidget: !!widget, + selection: scheduleTimezoneSelection, + defaultResolvedTimezone, + onTimezoneChange: onScheduleTimezoneChange, + }; +} + +/** + * Inputs of {@link useScheduledEmailDialogActionBarProps} that come from the dialog rather than its state. + * + * @internal + */ +export interface IUseScheduledEmailDialogActionBarPropsInput { + /** + * Closes the dialog without saving. + */ + onCancel?: () => void; + + /** + * Submits the automation; the dialog's single save path (see `useSaveScheduledEmailToBackend`). + */ + onSubmit: () => void; + + /** + * Whether a save is in flight. + */ + isSaving: boolean; + + /** + * Opens the delete confirmation. Rendered only when an existing schedule is being edited. + */ + onDelete?: () => void; +} + +/** + * The exact props the default scheduled-email dialog renders its action bar with. + * + * Throws outside the scheduled-export dialog's state providers. + * + * @internal + */ +export function useScheduledEmailDialogActionBarProps({ + onCancel, + onSubmit, + isSaving, + onDelete, +}: IUseScheduledEmailDialogActionBarPropsInput): IAutomationDialogActionBarProps { + const intl = useIntl(); + const { isWhiteLabeled, isExecutionTimestampMode } = useAutomationsContext(); + const { scheduledExportToEdit } = useScheduledEmailDialogContext(); + const { isSubmitDisabled } = useScheduledExportDialogValidity(); + + const helpTextId = isMobileView() + ? defineMessage({ id: "dialogs.schedule.email.footer.title.short" }).id + : defineMessage({ id: "dialogs.schedule.email.footer.title" }).id; + + return { + cancelButtonText: intl.formatMessage({ id: "cancel" }), + submitButtonText: scheduledExportToEdit + ? intl.formatMessage({ id: "dialogs.schedule.email.save" }) + : intl.formatMessage({ id: "dialogs.schedule.email.create" }), + onCancel: () => onCancel?.(), + onSubmit, + isSubmitDisabled: isSubmitDisabled || isSaving || isExecutionTimestampMode, + isSaving, + submitButtonTooltipText: isExecutionTimestampMode + ? intl.formatMessage({ id: "dialogs.schedule.email.save.executionTimestampMode" }) + : undefined, + ...(isWhiteLabeled + ? {} + : { + helpLinkText: intl.formatMessage({ id: helpTextId }), + helpLinkHref: + "https://www.gooddata.com/docs/cloud/create-dashboards/automation/scheduled-exports/#ScheduleExportsinDashboards-ScheduleExport", + }), + ...(scheduledExportToEdit && onDelete + ? { + deleteButtonText: intl.formatMessage({ id: "delete" }), + onDelete, + } + : {}), + }; +} diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/useScheduledEmailExportSettings.ts b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/useScheduledEmailExportSettings.ts index 7bf362f85c7..70541b26db6 100644 --- a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/useScheduledEmailExportSettings.ts +++ b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/useScheduledEmailExportSettings.ts @@ -30,6 +30,7 @@ import { newWidgetExportDefinitionMetadataObjectDefinition, withRebuiltExportDefinitions, } from "./exportDefinitions.js"; +import { type IScheduledEmailExportSettings } from "./types.js"; export interface IUseScheduledEmailExportSettingsProps { editedAutomation: IAutomationMetadataObjectDefinition; @@ -72,7 +73,7 @@ export function useScheduledEmailExportSettings({ effectiveWidgetFiltersWithInsight, defaultPdfPageSize, scheduleTimezone, -}: IUseScheduledEmailExportSettingsProps) { +}: IUseScheduledEmailExportSettingsProps): IScheduledEmailExportSettings { const { dashboardId, dashboardTitle } = useScheduledEmailDialogContext(); const { exportTimezoneId: defaultExportTimezoneId } = useExportTimezones(!!widget && !!insight); diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/useScheduledEmailFiltersModel.ts b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/useScheduledEmailFiltersModel.ts index 3764650f686..9bfa90dfc94 100644 --- a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/useScheduledEmailFiltersModel.ts +++ b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/useScheduledEmailFiltersModel.ts @@ -19,10 +19,7 @@ import { shallowEqualObjects } from "@gooddata/util"; import type { IAutomationFiltersTab } from "../../../../model/store/filtering/types.js"; import { useScheduledEmailDialogContext } from "../../contexts/ScheduledEmailDialogContext.js"; import { useValidateExistingAutomationFilters } from "../../shared/automationFilters/hooks/useValidateExistingAutomationFilters.js"; -import { - type IUseAutomationExportParameters, - useAutomationExportParameters, -} from "../../shared/automationFilters/useAutomationExportParameters.js"; +import { useAutomationExportParameters } from "../../shared/automationFilters/useAutomationExportParameters.js"; import { getDefaultSelectedFiltersFromFiltersByTab } from "../../shared/automationFilters/useAutomationFiltersSelect.js"; import { getAppliedDashboardFilters, @@ -31,6 +28,8 @@ import { getVisibleFiltersByFiltersByTab, } from "../../shared/filters/index.js"; +import { type IScheduledExportFiltersContextValue } from "./types.js"; + export interface IUseScheduledEmailFiltersModelProps { setEditedAutomation: Dispatch>; scheduledExportToEdit?: IAutomationMetadataObject; @@ -81,7 +80,7 @@ export function useScheduledEmailFiltersModel({ setStoreFilters, filtersForNewAutomation, setParametersWire, -}: IUseScheduledEmailFiltersModelProps): IUseScheduledEmailFiltersModel { +}: IUseScheduledEmailFiltersModelProps): IScheduledExportFiltersContextValue { const { hiddenFilters: dashboardHiddenFilters, commonDateFilterId, @@ -340,7 +339,7 @@ export function useScheduledEmailFiltersModel({ // Held stable member-wise, not just internally: this whole object is the filter context's value, // so its identity is what every consumer re-renders on. The model never reads the draft, so a // keystroke changes no member — and without this a fresh literal would re-render every consumer. - return useShallowStable({ + return useShallowStable({ selectedFilters: editedAutomationFilters, availableFilters, storeFilters, @@ -371,29 +370,3 @@ function useShallowStable(value: T): T { return previous.current; } - -/** - * Return type of {@link useScheduledEmailFiltersModel}: its own filter model plus the whole absorbed - * {@link useAutomationExportParameters} model, spread in verbatim so its member names stay canonical. - * @internal - */ -export interface IUseScheduledEmailFiltersModel extends IUseAutomationExportParameters { - selectedFilters: FilterContextItem[]; - availableFilters: FilterContextItem[] | undefined; - storeFilters: boolean; - filtersByTab: IAutomationFiltersTab[] | undefined; - editedFiltersByTab: Record | undefined; - onFiltersChange: (filters: FilterContextItem[], storeFiltersParam?: boolean) => void; - onFiltersByTabChange: ( - newFiltersByTab: Record, - storeFiltersParam?: boolean, - ) => void; - onApplyCurrentFilters: () => void; - onStoreFiltersChange: ( - value: boolean, - filters?: FilterContextItem[], - filtersByTabParam?: Record, - ) => void; - automationIsValid: boolean; - filtersAreStale: boolean; -} diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/useScheduledEmailFormState.ts b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/useScheduledEmailFormState.ts index 6525501c87d..49fd2728df2 100644 --- a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/useScheduledEmailFormState.ts +++ b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/state/useScheduledEmailFormState.ts @@ -39,6 +39,7 @@ import { newDashboardExportDefinitionMetadataObjectDefinition, newWidgetExportDefinitionMetadataObjectDefinition, } from "./exportDefinitions.js"; +import { type IScheduledEmailFormState } from "./types.js"; export interface IUseScheduledEmailFormStateProps { scheduledExportToEdit?: IAutomationMetadataObject; @@ -86,7 +87,7 @@ export function useScheduledEmailFormState({ effectiveVisibleDashboardFiltersByTab, parametersByTabForNewAutomation, defaultPdfPageSize, -}: IUseScheduledEmailFormStateProps) { +}: IUseScheduledEmailFormStateProps): IScheduledEmailFormState { const { timezone, currentUser, widgetLocalIdToTabIdMap: widgetTabMap } = useAutomationsContext(); const { dashboardId, dashboardTitle } = useScheduledEmailDialogContext(); diff --git a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/tests/scheduledEmail.test.helpers.ts b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/tests/scheduledEmail.test.helpers.ts index fa0118796a7..be92a6514d9 100644 --- a/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/tests/scheduledEmail.test.helpers.ts +++ b/libs/sdk-ui-dashboard/src/presentation/automations/scheduledEmail/tests/scheduledEmail.test.helpers.ts @@ -20,8 +20,10 @@ import { SENTINEL_WIDGET as SHARED_SENTINEL_WIDGET, WORKSPACE_PARAMETER as SHARED_WORKSPACE_PARAMETER, } from "../../tests/shared.test.helpers.js"; -import { type IScheduledExportDataContextValue } from "../state/ScheduledExportDataContext.js"; -import { type IScheduledExportDraftContextValue } from "../state/ScheduledExportDraftContext.js"; +import { + type IScheduledExportDataContextValue, + type IScheduledExportDraftContextValue, +} from "../state/types.js"; export const AUTOMATIONS_CONTEXT = SHARED_AUTOMATIONS_CONTEXT; export const CURRENT_USER = SHARED_CURRENT_USER; diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/de-DE.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/de-DE.json index 736bfd867fb..9939c50a7c6 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/de-DE.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/de-DE.json @@ -259,7 +259,6 @@ "dialogs.automation.applyCurrentFilters.schedule.filtersAndTimezone.confirm": "Warnung: Einige Filter und die beim Erstellen dieses Zeitplans verwendete Zeitzone sind nicht mehr verfügbar oder gültig. Beim Bearbeiten werden die aktuellen Dashboard-Filter und die aktuelle Zeitzone angewendet.", "dialogs.automation.filters.schedule.ariaLabel": "Informationen zur Option: Verwenden Sie diese Filter immer für zukünftige Exporte.", "dialogs.schedule.management.title": "Geplante exporte", - "dialogs.schedule.management.delete": "Löschen Sie den Zeitplan.", "dialogs.schedule.management.delete.dialog": "Zeitplan löschen?", "dialogs.schedule.management.delete.dialog.confirm": "Der Zeitplan wird unwiderruflich gelöscht.", "dialogs.schedule.management.list.title": "Ihre geplanten Exporte", diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/en-AU.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/en-AU.json index f83658977a1..fa39655a761 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/en-AU.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/en-AU.json @@ -259,7 +259,6 @@ "dialogs.automation.applyCurrentFilters.schedule.filtersAndTimezone.confirm": "Warning: Some filters and the time zone used when this schedule was created are no longer available or valid. Editing will apply the current dashboard filters and time zone.", "dialogs.automation.filters.schedule.ariaLabel": "Information about the option: Always apply these filters for future exports", "dialogs.schedule.management.title": "Scheduled exports", - "dialogs.schedule.management.delete": "Delete the schedule.", "dialogs.schedule.management.delete.dialog": "Delete schedule?", "dialogs.schedule.management.delete.dialog.confirm": "The schedule will be permanently deleted.", "dialogs.schedule.management.list.title": "Your scheduled exports", diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/en-GB.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/en-GB.json index 98150191d01..3326c7908a2 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/en-GB.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/en-GB.json @@ -259,7 +259,6 @@ "dialogs.automation.applyCurrentFilters.schedule.filtersAndTimezone.confirm": "Warning: Some filters and the time zone used when this schedule was created are no longer available or valid. Editing will apply the current dashboard filters and time zone.", "dialogs.automation.filters.schedule.ariaLabel": "Information about the option: Always apply these filters for future exports", "dialogs.schedule.management.title": "Scheduled exports", - "dialogs.schedule.management.delete": "Delete the schedule.", "dialogs.schedule.management.delete.dialog": "Delete schedule?", "dialogs.schedule.management.delete.dialog.confirm": "The schedule will be permanently deleted.", "dialogs.schedule.management.list.title": "Your scheduled exports", diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/es-419.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/es-419.json index 2feb0841247..67015e929e1 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/es-419.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/es-419.json @@ -259,7 +259,6 @@ "dialogs.automation.applyCurrentFilters.schedule.filtersAndTimezone.confirm": "Advertencia: Algunos filtros y la zona horaria utilizados cuando se creó este horario ya no están disponibles o no son válidos. La edición aplicará los filtros y la zona horaria actuales del panel.", "dialogs.automation.filters.schedule.ariaLabel": "Información sobre la opción: Utilice siempre estos filtros para futuras exportaciones.", "dialogs.schedule.management.title": "Exportaciones programadas", - "dialogs.schedule.management.delete": "Eliminar la programación", "dialogs.schedule.management.delete.dialog": "¿Eliminar la programación?", "dialogs.schedule.management.delete.dialog.confirm": "La programación se eliminará permanentemente.", "dialogs.schedule.management.list.title": "Sus exportaciones programadas", diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/es-ES.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/es-ES.json index 256cdf02009..d61a48c465c 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/es-ES.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/es-ES.json @@ -257,7 +257,6 @@ "dialogs.automation.applyCurrentFilters.schedule.confirm": "Advertencia: Algunos filtros utilizados cuando se creó este horario ya no están disponibles o no son válidos. La edición aplicará los filtros actuales del cuadro de mandos.", "dialogs.automation.filters.schedule.ariaLabel": "Información sobre la opción: Utilice siempre estos filtros para futuras exportaciones.", "dialogs.schedule.management.title": "Exportaciones programadas", - "dialogs.schedule.management.delete": "Eliminar la programación", "dialogs.schedule.management.delete.dialog": "¿Eliminar la programación?", "dialogs.schedule.management.delete.dialog.confirm": "La programación se eliminará permanentemente.", "dialogs.schedule.management.list.title": "Sus exportaciones programadas", diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/fi-FI.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/fi-FI.json index 6351de36cd0..4f8d8fdb09e 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/fi-FI.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/fi-FI.json @@ -259,7 +259,6 @@ "dialogs.automation.applyCurrentFilters.schedule.filtersAndTimezone.confirm": "Varoitus: Jotkin tätä aikataulua luotaessa käytetyt suodattimet ja aikavyöhyke eivät ole enää käytettävissä tai kelvollisia. Muokkauksessa käytetään koontinäytön nykyisiä suodattimia ja nykyistä aikavyöhykettä.", "dialogs.automation.filters.schedule.ariaLabel": "Tietoja vaihtoehdosta: Käytä näitä suodattimia aina tulevassa viennissä.", "dialogs.schedule.management.title": "Suunnitellut viennit", - "dialogs.schedule.management.delete": "Poista aikataulu.", "dialogs.schedule.management.delete.dialog": "Poista aikataulu?", "dialogs.schedule.management.delete.dialog.confirm": "Aikataulu poistetaan pysyvästi.", "dialogs.schedule.management.list.title": "Ajoitetut vientisi", diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/fr-CA.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/fr-CA.json index a63ef37f6dd..0a1caf57731 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/fr-CA.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/fr-CA.json @@ -259,7 +259,6 @@ "dialogs.automation.applyCurrentFilters.schedule.filtersAndTimezone.confirm": "Avertissement : Certains filtres et le fuseau horaire utilisés lors de la création de cet horaire ne sont plus disponibles ou valides. La modification appliquera les filtres de tableau de bord actuels et le fuseau horaire actuel.", "dialogs.automation.filters.schedule.ariaLabel": "Informations sur l'option : Utilisez toujours ces filtres pour les exportations futures.", "dialogs.schedule.management.title": "Exportations programmées", - "dialogs.schedule.management.delete": "Supprimer la planification.", "dialogs.schedule.management.delete.dialog": "Supprimer la planification ?", "dialogs.schedule.management.delete.dialog.confirm": "La planification sera supprimée de façon définitive.", "dialogs.schedule.management.list.title": "Vos exportations planifiées", diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/fr-FR.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/fr-FR.json index 78085c844c6..157c078de1c 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/fr-FR.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/fr-FR.json @@ -259,7 +259,6 @@ "dialogs.automation.applyCurrentFilters.schedule.filtersAndTimezone.confirm": "Avertissement : Certains filtres et le fuseau horaire utilisés lors de la création de cette programmation ne sont plus disponibles ou valides. La modification appliquera les filtres et le fuseau horaire actuels du tableau de bord.", "dialogs.automation.filters.schedule.ariaLabel": "Informations sur l'option : Utilisez toujours ces filtres pour les exportations futures.", "dialogs.schedule.management.title": "Exportations programmées", - "dialogs.schedule.management.delete": "Supprimer la planification.", "dialogs.schedule.management.delete.dialog": "Supprimer la planification ?", "dialogs.schedule.management.delete.dialog.confirm": "La planification sera supprimée de façon définitive.", "dialogs.schedule.management.list.title": "Vos exportations programmées", diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/id-ID.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/id-ID.json index 17aca70e0b3..b4b0f6fafc2 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/id-ID.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/id-ID.json @@ -259,7 +259,6 @@ "dialogs.automation.applyCurrentFilters.schedule.filtersAndTimezone.confirm": "Peringatan: Beberapa filter dan zona waktu yang digunakan saat jadwal ini dibuat tidak lagi tersedia atau valid. Pengeditan akan menerapkan filter dan zona waktu dasbor saat ini.", "dialogs.automation.filters.schedule.ariaLabel": "Informasi tentang opsi: Selalu gunakan filter ini untuk ekspor di masa mendatang.", "dialogs.schedule.management.title": "Ekspor terjadwal", - "dialogs.schedule.management.delete": "Hapus jadwal.", "dialogs.schedule.management.delete.dialog": "Hapus jadwal?", "dialogs.schedule.management.delete.dialog.confirm": "Jadwal akan dihapus secara permanen.", "dialogs.schedule.management.list.title": "Ekspor terjadwal Anda", diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/it-IT.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/it-IT.json index 86e45cb20d9..ba07b4e7265 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/it-IT.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/it-IT.json @@ -259,7 +259,6 @@ "dialogs.automation.applyCurrentFilters.schedule.filtersAndTimezone.confirm": "Avviso: Alcuni filtri e il fuso orario utilizzati quando è stata creata questa programmazione non sono più disponibili o validi. La modifica applicherà i filtri correnti del cruscotto e il fuso orario corrente.", "dialogs.automation.filters.schedule.ariaLabel": "Informazioni sull'opzione: Utilizzare sempre questi filtri per le esportazioni future.", "dialogs.schedule.management.title": "Esportazioni programmate", - "dialogs.schedule.management.delete": "Eliminare la programmazione.", "dialogs.schedule.management.delete.dialog": "Eliminare la programmazione?", "dialogs.schedule.management.delete.dialog.confirm": "La programmazione verrà eliminata in modo permanente.", "dialogs.schedule.management.list.title": "Le tue esportazioni programmate", diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/ja-JP.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/ja-JP.json index 33a2d54e9e8..d8aa6c4985b 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/ja-JP.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/ja-JP.json @@ -259,7 +259,6 @@ "dialogs.automation.applyCurrentFilters.schedule.filtersAndTimezone.confirm": "警告: このスケジュールの作成時に使用された一部のフィルターとタイムゾーンは、利用できなくなったか無効です。編集すると、現在のダッシュボードフィルターとタイムゾーンが適用されます。", "dialogs.automation.filters.schedule.ariaLabel": "オプションに関する情報:今後のエクスポートには常にこれらのフィルタを使用します。", "dialogs.schedule.management.title": "エクスポート予定", - "dialogs.schedule.management.delete": "スケジュールを削除。", "dialogs.schedule.management.delete.dialog": "スケジュールを削除しますか?", "dialogs.schedule.management.delete.dialog.confirm": "スケジュールは完全に削除されます。", "dialogs.schedule.management.list.title": "スケジュールされたエクスポート", diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/ko-KR.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/ko-KR.json index 10e688ebe14..125efc32bc4 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/ko-KR.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/ko-KR.json @@ -259,7 +259,6 @@ "dialogs.automation.applyCurrentFilters.schedule.filtersAndTimezone.confirm": "주의: 이 일정이 생성될 때 사용된 일부 필터와 표준 시간대가 더 이상 사용 가능하거나 유효하지 않습니다. 편집 시 현재 대시보드 필터와 표준 시간대가 적용됩니다.", "dialogs.automation.filters.schedule.ariaLabel": "옵션 정보: 향후 내보내기 시 항상 이 필터 사용", "dialogs.schedule.management.title": "예약된 내보내기 작업", - "dialogs.schedule.management.delete": "일정을 삭제하세요.", "dialogs.schedule.management.delete.dialog": "일정을 삭제하시겠습니까?", "dialogs.schedule.management.delete.dialog.confirm": "일정이 영구적으로 삭제됩니다.", "dialogs.schedule.management.list.title": "예약된 내보내기", diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/nl-NL.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/nl-NL.json index 1e475e65743..4319df78c10 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/nl-NL.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/nl-NL.json @@ -259,7 +259,6 @@ "dialogs.automation.applyCurrentFilters.schedule.filtersAndTimezone.confirm": "Waarschuwing: Sommige filters en de tijdzone die werden gebruikt toen dit schema werd gemaakt, zijn niet langer beschikbaar of geldig. Bewerken past de huidige dashboardfilters en tijdzone toe.", "dialogs.automation.filters.schedule.ariaLabel": "Informatie over de optie: Gebruik deze filters altijd voor toekomstige exports.", "dialogs.schedule.management.title": "Geplande export", - "dialogs.schedule.management.delete": "Verwijder het schema.", "dialogs.schedule.management.delete.dialog": "Schema verwijderen?", "dialogs.schedule.management.delete.dialog.confirm": "Het schema wordt voorgoed verwijderd.", "dialogs.schedule.management.list.title": "Uw geplande export", diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/pl-PL.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/pl-PL.json index 413eb3a473e..aaae7b5cdbd 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/pl-PL.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/pl-PL.json @@ -259,7 +259,6 @@ "dialogs.automation.applyCurrentFilters.schedule.filtersAndTimezone.confirm": "Ostrzeżenie: Niektóre filtry i strefa czasowa używane podczas tworzenia tego harmonogramu nie są już dostępne lub prawidłowe. Edycja spowoduje zastosowanie bieżących filtrów pulpitu i strefy czasowej.", "dialogs.automation.filters.schedule.ariaLabel": "Informacje o opcji: Zawsze używaj tych filtrów do przyszłych eksportów.", "dialogs.schedule.management.title": "Zaplanowane eksporty", - "dialogs.schedule.management.delete": "Usuń harmonogram.", "dialogs.schedule.management.delete.dialog": "Usunąć harmonogram?", "dialogs.schedule.management.delete.dialog.confirm": "Harmonogram zostanie trwale usunięty.", "dialogs.schedule.management.list.title": "Twoje zaplanowane eksporty", diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/pt-BR.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/pt-BR.json index 2a7bca1917b..bd28fdd573f 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/pt-BR.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/pt-BR.json @@ -259,7 +259,6 @@ "dialogs.automation.applyCurrentFilters.schedule.filtersAndTimezone.confirm": "Aviso: Alguns filtros e o fuso horário usados quando esta agenda foi criada não estão mais disponíveis ou não são mais válidos. A edição aplicará os filtros de painel e o fuso horário atuais.", "dialogs.automation.filters.schedule.ariaLabel": "Informações sobre a opção: Sempre use esses filtros para futuras exportações.", "dialogs.schedule.management.title": "Exportações agendadas", - "dialogs.schedule.management.delete": "Excluir o agendamento.", "dialogs.schedule.management.delete.dialog": "Excluir agendamento?", "dialogs.schedule.management.delete.dialog.confirm": "O agendamento será excluído permanentemente.", "dialogs.schedule.management.list.title": "Suas exportações programadas", diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/pt-PT.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/pt-PT.json index c4f4ee3bf0c..df62198f05a 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/pt-PT.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/pt-PT.json @@ -259,7 +259,6 @@ "dialogs.automation.applyCurrentFilters.schedule.filtersAndTimezone.confirm": "Aviso: Alguns filtros e o fuso horário usados quando esta programação foi criada já não estão disponíveis ou válidos. A edição aplicará os filtros e o fuso horário atuais do painel.", "dialogs.automation.filters.schedule.ariaLabel": "Informações sobre a opção: Sempre use esses filtros para futuras exportações.", "dialogs.schedule.management.title": "Exportações programadas", - "dialogs.schedule.management.delete": "Eliminar a programação.", "dialogs.schedule.management.delete.dialog": "Eliminar programação?", "dialogs.schedule.management.delete.dialog.confirm": "A programação será eliminada de forma permanente.", "dialogs.schedule.management.list.title": "As suas exportações programadas", diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/ru-RU.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/ru-RU.json index 67d44b49ceb..c55ac23fb6b 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/ru-RU.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/ru-RU.json @@ -259,7 +259,6 @@ "dialogs.automation.applyCurrentFilters.schedule.filtersAndTimezone.confirm": "Предупреждение: Некоторые фильтры и часовой пояс, использованные при создании этого расписания, больше недоступны или недействительны. При редактировании будут применены текущие фильтры и часовой пояс дашборда.", "dialogs.automation.filters.schedule.ariaLabel": "Информация о параметре: Всегда использовать эти фильтры для будущих экспортов.", "dialogs.schedule.management.title": "Запланированные экспорты", - "dialogs.schedule.management.delete": "Удалить расписание.", "dialogs.schedule.management.delete.dialog": "Удалить расписание?", "dialogs.schedule.management.delete.dialog.confirm": "Расписание будет удалено навсегда.", "dialogs.schedule.management.list.title": "Ваши запланированные экспорты", diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/sl-SI.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/sl-SI.json index d30be73b6ce..af0c555df7e 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/sl-SI.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/sl-SI.json @@ -259,7 +259,6 @@ "dialogs.automation.applyCurrentFilters.schedule.filtersAndTimezone.confirm": "Opozorilo: Nekateri filtri in časovni pas, uporabljeni pri ustvarjanju tega urnika, niso več na voljo ali veljavni. Urejanje bo uporabilo trenutne filtre in časovni pas nadzorne plošče.", "dialogs.automation.filters.schedule.ariaLabel": "Informacije o možnosti: Vedno uporabi te filtre za prihodnje izvoze.", "dialogs.schedule.management.title": "Načrtovani izvozi", - "dialogs.schedule.management.delete": "Izbriši urnik.", "dialogs.schedule.management.delete.dialog": "Izbrisati urnik?", "dialogs.schedule.management.delete.dialog.confirm": "Urnik bo trajno odstranjen.", "dialogs.schedule.management.list.title": "Vaši načrtovani izvozi", diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/th-TH.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/th-TH.json index 3b5c9bc6324..33b7a3e9af3 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/th-TH.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/th-TH.json @@ -259,7 +259,6 @@ "dialogs.automation.applyCurrentFilters.schedule.filtersAndTimezone.confirm": "คำเตือน: ตัวกรองบางรายการและเขตเวลาที่ใช้เมื่อสร้างกำหนดการนี้ไม่พร้อมใช้งานหรือไม่ถูกต้องอีกต่อไป การแก้ไขจะนำตัวกรองและเขตเวลาปัจจุบันของแดชบอร์ดไปใช้", "dialogs.automation.filters.schedule.ariaLabel": "ข้อมูลเกี่ยวกับตัวเลือก: ใช้ตัวกรองเหล่านี้เสมอสำหรับการส่งออกในอนาคต", "dialogs.schedule.management.title": "การส่งออกตามกำหนดการ", - "dialogs.schedule.management.delete": "ลบกำหนดการ", "dialogs.schedule.management.delete.dialog": "ลบกำหนดการหรือไม่?", "dialogs.schedule.management.delete.dialog.confirm": "กำหนดการจะถูกลบอย่างถาวร", "dialogs.schedule.management.list.title": "การส่งออกตามกำหนดการของคุณ", diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/tr-TR.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/tr-TR.json index 85c157f22f9..f3af8d0feae 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/tr-TR.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/tr-TR.json @@ -259,7 +259,6 @@ "dialogs.automation.applyCurrentFilters.schedule.filtersAndTimezone.confirm": "Uyarı: Bu program oluşturulduğunda kullanılan bazı filtreler ve saat dilimi artık kullanılamıyor veya geçerli değil. Düzenleme, geçerli gösterge tablosu filtrelerini ve saat dilimini uygulayacaktır.", "dialogs.automation.filters.schedule.ariaLabel": "Seçenek hakkında bilgi: Gelecekteki dışa aktarımlar için bu filtreleri her zaman kullan.", "dialogs.schedule.management.title": "Zamanlanmış dışa aktarımlar", - "dialogs.schedule.management.delete": "Programı sil.", "dialogs.schedule.management.delete.dialog": "Program silinsin mi?", "dialogs.schedule.management.delete.dialog.confirm": "Zamanlama kalıcı olarak silinecek.", "dialogs.schedule.management.list.title": "Planlanmış dışa aktarımlarınız", diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/uk-UA.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/uk-UA.json index 3e025d94ddf..87714a282bc 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/uk-UA.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/uk-UA.json @@ -259,7 +259,6 @@ "dialogs.automation.applyCurrentFilters.schedule.filtersAndTimezone.confirm": "Попередження: Деякі фільтри та часовий пояс, використані під час створення цього розкладу, більше недоступні або недійсні. Під час редагування буде застосовано поточні фільтри та часовий пояс інформаційної панелі.", "dialogs.automation.filters.schedule.ariaLabel": "Інформація про параметр: Завжди використовувати ці фільтри для майбутніх експортів.", "dialogs.schedule.management.title": "Заплановані експорти", - "dialogs.schedule.management.delete": "Видалити розклад.", "dialogs.schedule.management.delete.dialog": "Видалити розклад?", "dialogs.schedule.management.delete.dialog.confirm": "Розклад буде видалено назавжди.", "dialogs.schedule.management.list.title": "Ваші заплановані експорти", diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/vi-VN.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/vi-VN.json index 1d9b1c76eeb..a80c6ae7f73 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/vi-VN.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/vi-VN.json @@ -259,7 +259,6 @@ "dialogs.automation.applyCurrentFilters.schedule.filtersAndTimezone.confirm": "Cảnh báo: Một số bộ lọc và múi giờ được sử dụng khi lịch biểu này được tạo không còn khả dụng hoặc hợp lệ. Việc chỉnh sửa sẽ áp dụng các bộ lọc bảng điều khiển và múi giờ hiện tại.", "dialogs.automation.filters.schedule.ariaLabel": "Thông tin về tùy chọn: Luôn sử dụng các bộ lọc này cho các lần xuất trong tương lai.", "dialogs.schedule.management.title": "Các mục xuất theo lịch", - "dialogs.schedule.management.delete": "Xóa lịch biểu.", "dialogs.schedule.management.delete.dialog": "Xóa lịch biểu?", "dialogs.schedule.management.delete.dialog.confirm": "Lịch biểu sẽ bị xóa vĩnh viễn.", "dialogs.schedule.management.list.title": "Các mục xuất theo lịch của bạn", diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/zh-HK.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/zh-HK.json index 7309758effd..5aa20875c8e 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/zh-HK.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/zh-HK.json @@ -259,7 +259,6 @@ "dialogs.automation.applyCurrentFilters.schedule.filtersAndTimezone.confirm": "警告:建立此計劃時所用的部分篩選器和時區不再可用或有效。編輯將套用目前的儀表板篩選器和時區。", "dialogs.automation.filters.schedule.ariaLabel": "有關選項嘅信息:將來導出時,請始終使用呢啲過濾器。", "dialogs.schedule.management.title": "排程匯出", - "dialogs.schedule.management.delete": "刪除排程。", "dialogs.schedule.management.delete.dialog": "刪除排程?", "dialogs.schedule.management.delete.dialog.confirm": "該排程 將被永久刪除。", "dialogs.schedule.management.list.title": "您的排程匯出", diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/zh-Hans.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/zh-Hans.json index 5f3af575adc..3e2a1761fcd 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/zh-Hans.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/zh-Hans.json @@ -259,7 +259,6 @@ "dialogs.automation.applyCurrentFilters.schedule.filtersAndTimezone.confirm": "警告:创建此计划时使用的某些筛选器和时区不再可用或有效。编辑将应用仪表板当前的筛选器和时区。", "dialogs.automation.filters.schedule.ariaLabel": "有关该选项的信息:将来导出时始终使用这些过滤器。", "dialogs.schedule.management.title": "计划的导出", - "dialogs.schedule.management.delete": "删除此计划。", "dialogs.schedule.management.delete.dialog": "删除计划?", "dialogs.schedule.management.delete.dialog.confirm": "此计划将被永久删除。", "dialogs.schedule.management.list.title": "您的预定出口", diff --git a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/zh-Hant.json b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/zh-Hant.json index 153882e7a53..cda9cbed714 100644 --- a/libs/sdk-ui-dashboard/src/presentation/localization/bundles/zh-Hant.json +++ b/libs/sdk-ui-dashboard/src/presentation/localization/bundles/zh-Hant.json @@ -259,7 +259,6 @@ "dialogs.automation.applyCurrentFilters.schedule.filtersAndTimezone.confirm": "警告:建立此計劃時使用的某些過濾器和時區已不再可用或有效。編輯將套用目前儀表板過濾器和時區。", "dialogs.automation.filters.schedule.ariaLabel": "有關該選項的資訊:將來匯出時始終使用這些過濾器。", "dialogs.schedule.management.title": "預定匯出", - "dialogs.schedule.management.delete": "刪除時間表。", "dialogs.schedule.management.delete.dialog": "刪除日程?", "dialogs.schedule.management.delete.dialog.confirm": "該計劃將永久刪除。", "dialogs.schedule.management.list.title": "您排程的匯出作業", diff --git a/libs/sdk-ui-ext/package.json b/libs/sdk-ui-ext/package.json index b9816e9566b..1dd39c9e7c9 100644 --- a/libs/sdk-ui-ext/package.json +++ b/libs/sdk-ui-ext/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-ui-ext", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData.UI SDK - Extensions", "license": "LicenseRef-LICENSE", "author": "GoodData Corporation", @@ -111,19 +111,19 @@ "@gooddata/reference-workspace": "workspace:*", "@gooddata/sdk-backend-mockingbird": "workspace:*", "@gooddata/stylelint-config": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.0", "@testing-library/user-event": "14.6.1", "@types/debounce-promise": "^3.1.4", - "@types/js-yaml": "^4.0.9", + "@types/js-yaml": "4.0.9", "@types/lodash-es": "^4.17.12", "@types/node": "24.12.0", - "@types/raf": "^3.4.0", + "@types/raf": "3.4.3", "@types/react": "19.1.11", "@types/react-dom": "19.1.7", - "@types/react-lines-ellipsis": "^0.15.6", + "@types/react-lines-ellipsis": "0.15.6", "@types/react-measure": "2.0.12", "@typescript-eslint/eslint-plugin": "8.58.0", "@typescript-eslint/parser": "8.58.0", @@ -141,7 +141,7 @@ "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "5.2.0", "eslint-plugin-sonarjs": "3.0.6", - "full-icu": "^1.3.0", + "full-icu": "1.6.0", "happy-dom": "20.9.0", "npm-run-all": "^4.1.5", "oxfmt": "0.52.0", @@ -152,7 +152,7 @@ "react-dom": "19.1.1", "sass": "1.70.0", "stylelint": "^16.26.1", - "svgo": "^2.8.0", + "svgo": "2.8.3", "typescript": "5.9.3", "vitest": "4.1.8", "vitest-dom": "0.1.1" diff --git a/libs/sdk-ui-filters/package.json b/libs/sdk-ui-filters/package.json index a9b415f2d69..c41673bdd6d 100644 --- a/libs/sdk-ui-filters/package.json +++ b/libs/sdk-ui-filters/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-ui-filters", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData.UI SDK - Filter Components", "license": "MIT", "author": "GoodData Corporation", @@ -84,13 +84,13 @@ "@gooddata/sdk-backend-mockingbird": "workspace:*", "@gooddata/sdk-ui-theme-provider": "workspace:*", "@gooddata/stylelint-config": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.0", "@types/lodash-es": "^4.17.12", "@types/node": "24.12.0", - "@types/raf": "^3.4.0", + "@types/raf": "3.4.3", "@types/react": "19.1.11", "@types/react-dom": "19.1.7", "@typescript-eslint/eslint-plugin": "8.58.0", @@ -109,7 +109,7 @@ "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "5.2.0", "eslint-plugin-sonarjs": "3.0.6", - "full-icu": "^1.3.0", + "full-icu": "1.6.0", "happy-dom": "20.9.0", "npm-run-all": "^4.1.5", "oxfmt": "0.52.0", diff --git a/libs/sdk-ui-gen-ai/api/sdk-ui-gen-ai.api.md b/libs/sdk-ui-gen-ai/api/sdk-ui-gen-ai.api.md index 9ff5451051b..739f6395593 100644 --- a/libs/sdk-ui-gen-ai/api/sdk-ui-gen-ai.api.md +++ b/libs/sdk-ui-gen-ai/api/sdk-ui-gen-ai.api.md @@ -30,6 +30,7 @@ import { IInsight } from '@gooddata/sdk-model'; import { IListedDashboard } from '@gooddata/sdk-model'; import type { ISemanticSearchRelationship } from '@gooddata/sdk-model'; import type { ISemanticSearchResultItem } from '@gooddata/sdk-model'; +import { ISlotProps } from '@gooddata/sdk-ui-kit'; import { IUiButtonProps } from '@gooddata/sdk-ui-kit'; import { IUserWorkspaceSettings } from '@gooddata/sdk-backend-spi'; import { JSX } from 'react/jsx-runtime'; @@ -283,8 +284,7 @@ export type GenAIAssistantProps = Omit & { disableAnalyze?: boolean; disableFullControl?: boolean; dashboardSelector?: DashboardSelectorEvaluator; - LandingScreenComponentProvider?: () => ComponentType; - DisclaimerComponentProvider?: () => ComponentType | null; + slots?: IGenAIAssistantSlots; className?: string; mode?: GenAIAssistantMode; onModeChange?: (mode: GenAIAssistantMode) => void; @@ -386,6 +386,22 @@ export type IChatConversationSystemContent = { type: "system"; }; +// @public +export type IGenAIAssistantDisclaimerProps = Record; + +// @public +export type IGenAIAssistantLandingScreenProps = { + isFullscreen?: boolean; + isBigScreen?: boolean; + isSmallScreen?: boolean; +}; + +// @public +export interface IGenAIAssistantSlots { + Disclaimer?: ComponentType>; + LandingScreen?: ComponentType>; +} + // @public export interface IGenAiInteractionIntelligenceProps { initialCategory?: string; @@ -502,11 +518,8 @@ export const isChatUserMessageEvent: (event: ChatEvent) => event is ChatUserMess export const isChatVisualizationErrorEvent: (event: ChatEvent) => event is ChatVisualizationErrorEvent; // @beta (undocumented) -export type LandingScreenProps = { +export type LandingScreenProps = IGenAIAssistantLandingScreenProps & { LandingScreen?: ComponentType; - isFullscreen?: boolean; - isBigScreen?: boolean; - isSmallScreen?: boolean; }; // @public (undocumented) diff --git a/libs/sdk-ui-gen-ai/package.json b/libs/sdk-ui-gen-ai/package.json index acb678a1c99..2c8404ca1ad 100644 --- a/libs/sdk-ui-gen-ai/package.json +++ b/libs/sdk-ui-gen-ai/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-ui-gen-ai", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData GenAI SDK", "license": "MIT", "author": "GoodData Corporation", @@ -105,13 +105,13 @@ "@gooddata/reference-workspace": "workspace:*", "@gooddata/sdk-backend-mockingbird": "workspace:*", "@gooddata/stylelint-config": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.0", "@types/lodash-es": "^4.17.12", "@types/node": "24.12.0", - "@types/raf": "^3.4.0", + "@types/raf": "3.4.3", "@types/react": "19.1.11", "@types/react-dom": "19.1.7", "@types/react-redux": "7.1.34", @@ -141,7 +141,7 @@ "react-dom": "19.1.1", "sass": "1.70.0", "stylelint": "^16.26.1", - "svgo": "^2.8.0", + "svgo": "2.8.3", "typescript": "5.9.3", "vitest": "4.1.8", "vitest-dom": "0.1.1" diff --git a/libs/sdk-ui-gen-ai/src/components/CustomizationProvider.tsx b/libs/sdk-ui-gen-ai/src/components/CustomizationProvider.tsx index 3aa99b7e80e..af65e3888ea 100644 --- a/libs/sdk-ui-gen-ai/src/components/CustomizationProvider.tsx +++ b/libs/sdk-ui-gen-ai/src/components/CustomizationProvider.tsx @@ -1,88 +1,63 @@ // (C) 2025-2026 GoodData Corporation -import { type ComponentType, type PropsWithChildren, createContext, useContext, useMemo } from "react"; +import { type PropsWithChildren, createContext, useContext, useMemo } from "react"; import { DefaultDisclaimer } from "./customized/Disclaimer.js"; import { DefaultLandingScreen } from "./customized/LandingScreen.js"; +import { type IGenAIAssistantSlots } from "./customized/types.js"; import { useFullscreenCheck } from "./hooks/useFullscreenCheck.js"; export type CustomizationContext = { - landingScreenComponentProvider?: (props: { - isFullscreen: boolean; - isBigScreen: boolean; - isSmallScreen: boolean; - }) => ComponentType; - disclaimerComponentProvider?: () => ComponentType | null; + slots?: IGenAIAssistantSlots; }; const defaultCustomizationContext: Required = { - landingScreenComponentProvider: - (props: { isFullscreen: boolean; isBigScreen: boolean; isSmallScreen: boolean }) => () => ( - - ), - disclaimerComponentProvider: () => DefaultDisclaimer, + slots: {}, }; const customizationContext = createContext(defaultCustomizationContext); -export function CustomizationProvider({ - children, - landingScreenComponentProvider, - disclaimerComponentProvider, -}: PropsWithChildren) { +function LandingScreenSlotRenderer() { + const { slots } = useContext(customizationContext); const { isFullscreen, isBigScreen, isSmallScreen } = useFullscreenCheck(); + + const LandingScreenSlot = slots?.LandingScreen; + const defaultProps = { isFullscreen, isBigScreen, isSmallScreen }; + + if (LandingScreenSlot) { + return ; + } + return ; +} + +function DisclaimerSlotRenderer() { + const { slots } = useContext(customizationContext); + const DisclaimerSlot = slots?.Disclaimer; + const defaultProps = {}; + + if (DisclaimerSlot) { + return ; + } + return ; +} + +export function CustomizationProvider({ children, slots }: PropsWithChildren) { const value = useMemo( () => ({ - ...defaultCustomizationContext, - ...(landingScreenComponentProvider - ? { - landingScreenComponentProvider: () => () => ( - - ), - } - : {}), - ...(disclaimerComponentProvider ? { disclaimerComponentProvider } : {}), + slots: slots ?? {}, }), - [ - landingScreenComponentProvider, - disclaimerComponentProvider, - isFullscreen, - isBigScreen, - isSmallScreen, - ], + [slots], ); return {children}; } export const useCustomization = () => { - const { landingScreenComponentProvider, disclaimerComponentProvider } = useContext(customizationContext); - const { isFullscreen, isBigScreen, isSmallScreen } = useFullscreenCheck(); - return useMemo( () => ({ - LandingScreenComponent: landingScreenComponentProvider({ - isFullscreen, - isBigScreen, - isSmallScreen, - }), - DisclaimerComponent: disclaimerComponentProvider(), + LandingScreenComponent: LandingScreenSlotRenderer, + DisclaimerComponent: DisclaimerSlotRenderer, }), - [ - landingScreenComponentProvider, - disclaimerComponentProvider, - isFullscreen, - isBigScreen, - isSmallScreen, - ], + [], ); }; diff --git a/libs/sdk-ui-gen-ai/src/components/GenAIChat.tsx b/libs/sdk-ui-gen-ai/src/components/GenAIChat.tsx index 4cb1784222f..069ac7b7085 100644 --- a/libs/sdk-ui-gen-ai/src/components/GenAIChat.tsx +++ b/libs/sdk-ui-gen-ai/src/components/GenAIChat.tsx @@ -1,7 +1,5 @@ // (C) 2024-2026 GoodData Corporation -import { type ComponentType } from "react"; - import { BackendProvider, WorkspaceProvider, useBackendStrict, useWorkspaceStrict } from "@gooddata/sdk-ui"; import { type DashboardSelectorEvaluator } from "@gooddata/sdk-ui-dashboard"; @@ -12,6 +10,7 @@ import { usePermissions } from "../permissions/usePermissions.js"; import { ConfigProvider, type GenAIAssistantMode, type LinkHandlerEvent } from "./ConfigContext.js"; import { CustomizationProvider } from "./CustomizationProvider.js"; +import { type IGenAIAssistantSlots } from "./customized/types.js"; import { GenAIChatWrapper } from "./GenAIChatWrapper.js"; import { GenAiStore, type GenAiStoreProps } from "./GenAiStore.js"; @@ -60,14 +59,9 @@ export type GenAIAssistantProps = Omit & { dashboardSelector?: DashboardSelectorEvaluator; /** - * Custom React node rendered when no conversation exists yet. - */ - LandingScreenComponentProvider?: () => ComponentType; - - /** - * Custom React component rendered below the input as a disclaimer. + * Customizations for the Gen AI assistant. */ - DisclaimerComponentProvider?: () => ComponentType | null; + slots?: IGenAIAssistantSlots; /** * Additional class name applied to the root element. @@ -166,13 +160,7 @@ export function GenAIAssistant(props: GenAIAssistantProps) { export const GenAIChat = GenAIAssistant; function GenAIContent(props: GenAIChatProps) { - const { - LandingScreenComponentProvider, - DisclaimerComponentProvider, - dashboardSelector, - className, - catalogItems, - } = props; + const { slots, dashboardSelector, className, catalogItems } = props; const { permissions, loading } = usePermissions(); useDashboardAmbientContext(dashboardSelector); @@ -184,10 +172,7 @@ function GenAIContent(props: GenAIChatProps) { canManage={props.disableManage ? false : (permissions.canManageProject ?? false)} canAnalyze={props.disableAnalyze ? false : (permissions.canCreateVisualization ?? false)} > - + diff --git a/libs/sdk-ui-gen-ai/src/components/GenAIChatDialog.tsx b/libs/sdk-ui-gen-ai/src/components/GenAIChatDialog.tsx index 687ccf99243..70b57cefe14 100644 --- a/libs/sdk-ui-gen-ai/src/components/GenAIChatDialog.tsx +++ b/libs/sdk-ui-gen-ai/src/components/GenAIChatDialog.tsx @@ -1,6 +1,6 @@ // (C) 2024-2026 GoodData Corporation -import { type ComponentType, type RefObject, useCallback, useEffect, useMemo, useRef } from "react"; +import { type RefObject, useCallback, useEffect, useMemo, useRef } from "react"; import { type EnhancedStore } from "@reduxjs/toolkit"; @@ -16,6 +16,7 @@ import { getIsOpened } from "../store/localStorage.js"; import { ConfigProvider, type LinkHandlerEvent } from "./ConfigContext.js"; import { CustomizationProvider } from "./CustomizationProvider.js"; +import { type IGenAIAssistantSlots } from "./customized/types.js"; import { GenAIChatOverlay } from "./GenAIChatOverlay.js"; import { GenAiStore, type GenAiStoreProps } from "./GenAiStore.js"; import { KeyDriverAnalysis } from "./KeyDriverAnalysis.js"; @@ -33,8 +34,12 @@ export type GenAIChatDialogProps = Omit & { onClose: () => void; returnFocusTo?: RefObject | string; onLinkClick?: (linkClickEvent: LinkHandlerEvent) => string | undefined; - LandingScreenComponentProvider?: () => ComponentType; - DisclaimerComponentProvider?: () => ComponentType | null; + + /** + * Customizations for the Gen AI assistant. + */ + slots?: IGenAIAssistantSlots; + /** * Set to false while the host renders its own overlay above the chat, so that Escape aimed at * that overlay does not also close the chat (see GenAIChatOverlayExternalProps.closeOnEscape). @@ -74,8 +79,7 @@ export function GenAIChatDialog({ onLinkClick, onDispatcher, mode, - LandingScreenComponentProvider, - DisclaimerComponentProvider, + slots, closeOnEscape, }: GenAIChatDialogProps) { const effectiveBackend = useBackendStrict(backend); @@ -117,8 +121,7 @@ export function GenAIChatDialog({ canManage={canManage} canAnalyze={canAnalyze} canFullControl={canFullControl} - LandingScreenComponentProvider={LandingScreenComponentProvider} - DisclaimerComponentProvider={DisclaimerComponentProvider} + slots={slots} /> )} @@ -143,8 +146,7 @@ type GenAIChatDialogContentProps = { canManage: boolean; canAnalyze: boolean; canFullControl: boolean; - LandingScreenComponentProvider?: () => ComponentType; - DisclaimerComponentProvider?: () => ComponentType | null; + slots?: IGenAIAssistantSlots; closeOnEscape?: boolean; }; @@ -165,8 +167,7 @@ function GenAIChatDialogContent({ canManage, canAnalyze, canFullControl, - LandingScreenComponentProvider, - DisclaimerComponentProvider, + slots, closeOnEscape, }: GenAIChatDialogContentProps) { const open = useRef(onOpen); @@ -232,10 +233,7 @@ function GenAIChatDialogContent({ canAnalyze={canAnalyze} canFullControl={canFullControl} > - + + parts: (content.parts ?? []).map((part: IChatConversationMultipartLocalPart) => parseReferences(part, catalogItems), ), }; diff --git a/libs/sdk-ui-gen-ai/src/components/customized/Disclaimer.tsx b/libs/sdk-ui-gen-ai/src/components/customized/Disclaimer.tsx index 42dfeb91530..21eede32f24 100644 --- a/libs/sdk-ui-gen-ai/src/components/customized/Disclaimer.tsx +++ b/libs/sdk-ui-gen-ai/src/components/customized/Disclaimer.tsx @@ -4,10 +4,12 @@ import { FormattedMessage } from "react-intl"; import { Typography } from "@gooddata/sdk-ui-kit"; +import { type IGenAIAssistantDisclaimerProps } from "./types.js"; + /** * @beta */ -export function DefaultDisclaimer() { +export function DefaultDisclaimer(_props: IGenAIAssistantDisclaimerProps) { return ( diff --git a/libs/sdk-ui-gen-ai/src/components/customized/LandingScreen.tsx b/libs/sdk-ui-gen-ai/src/components/customized/LandingScreen.tsx index 869db572ea6..1cd5c82fab4 100644 --- a/libs/sdk-ui-gen-ai/src/components/customized/LandingScreen.tsx +++ b/libs/sdk-ui-gen-ai/src/components/customized/LandingScreen.tsx @@ -7,6 +7,7 @@ import { FormattedMessage, defineMessage, useIntl } from "react-intl"; import { DefaultLandingContainer, DefaultLandingQuestions } from "./LandingContainer.js"; import { DefaultLandingQuestion } from "./LandingQuestion.js"; import { DefaultLandingTitle, DefaultLandingTitleAscent } from "./LandingTitle.js"; +import { type IGenAIAssistantLandingScreenProps } from "./types.js"; const quickOptions = [ { @@ -32,11 +33,12 @@ const quickOptions = [ /** * @beta */ -export type LandingScreenProps = { +export type LandingScreenProps = IGenAIAssistantLandingScreenProps & { + /** + * Custom React node rendered when no conversation exists yet. + * @deprecated Use slots.LandingScreen instead. + */ LandingScreen?: ComponentType; - isFullscreen?: boolean; - isBigScreen?: boolean; - isSmallScreen?: boolean; }; /** diff --git a/libs/sdk-ui-gen-ai/src/components/customized/types.ts b/libs/sdk-ui-gen-ai/src/components/customized/types.ts new file mode 100644 index 00000000000..c8fc8f5e084 --- /dev/null +++ b/libs/sdk-ui-gen-ai/src/components/customized/types.ts @@ -0,0 +1,47 @@ +// (C) 2024-2026 GoodData Corporation + +import { type ComponentType } from "react"; + +import { type ISlotProps } from "@gooddata/sdk-ui-kit"; + +/** + * Properties for the LandingScreen slot. + * @public + */ +export type IGenAIAssistantLandingScreenProps = { + /** + * Whether the chat is in fullscreen mode. + */ + isFullscreen?: boolean; + /** + * Whether the chat is in big screen mode (greater or equal to md) + */ + isBigScreen?: boolean; + /** + * Whether the chat is in small screen mode (less than md) + */ + isSmallScreen?: boolean; +}; + +/** + * Properties for the Disclaimer slot. + * @public + */ +export type IGenAIAssistantDisclaimerProps = Record; + +/** + * Customizations for the Gen AI assistant. + * @public + */ +export interface IGenAIAssistantSlots { + /** + * Custom React node rendered when no conversation exists yet or user has click + * on "New conversation" button. + */ + LandingScreen?: ComponentType>; + + /** + * Custom React component rendered below the input as a disclaimer. + */ + Disclaimer?: ComponentType>; +} diff --git a/libs/sdk-ui-gen-ai/src/components/messages/contents/useKdaDefinition.ts b/libs/sdk-ui-gen-ai/src/components/messages/contents/useKdaDefinition.ts index a1c50cda448..81af1ebbd0d 100644 --- a/libs/sdk-ui-gen-ai/src/components/messages/contents/useKdaDefinition.ts +++ b/libs/sdk-ui-gen-ai/src/components/messages/contents/useKdaDefinition.ts @@ -34,7 +34,7 @@ export function useKdaDefinition(content: ChangeAnalysisContents, format?: strin return createKdaDefinition( measure, content.params.dateAttribute, - content.params.filters + (content.params.filters ?? []) .map(getDashboardAttributeFilter) .filter(Boolean) as IDashboardAttributeFilter[], "previous_period", @@ -83,7 +83,7 @@ export function createKdaDefinition( ...measure, measure: { ...measure.measure, - title: measure.measure.title ?? measure.measure.alias ?? measure.measure.localIdentifier, + title: measure.measure?.title ?? measure.measure?.alias ?? measure.measure?.localIdentifier, }, }, metrics: [], diff --git a/libs/sdk-ui-gen-ai/src/index.ts b/libs/sdk-ui-gen-ai/src/index.ts index 104e0738463..ba80f194f37 100644 --- a/libs/sdk-ui-gen-ai/src/index.ts +++ b/libs/sdk-ui-gen-ai/src/index.ts @@ -139,3 +139,8 @@ export { DefaultLandingContainer, type ILandingContentProps, } from "./components/customized/LandingContainer.js"; +export { + type IGenAIAssistantSlots, + type IGenAIAssistantLandingScreenProps, + type IGenAIAssistantDisclaimerProps, +} from "./components/customized/types.js"; diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/de-DE.json b/libs/sdk-ui-gen-ai/src/localization/bundles/de-DE.json index aa62565f1d8..871ca3e7510 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/de-DE.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/de-DE.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "Attribut", "gd.gen-ai.autocomplete.metric": "Metrik", "gd.gen-ai.autocomplete.fact": "Fakt", - "gd.gen-ai.autocomplete.date": "Datum", + "gd.gen-ai.autocomplete.date": "Dataset", + "gd.gen-ai.autocomplete.insight": "Visualisierung", + "gd.gen-ai.autocomplete.analyticalDashboard": "Dashboard", + "gd.gen-ai.autocomplete.displayForm": "Beschriftung ", "gd.gen-ai.autocomplete.input-info.mac": "@ für Vorschläge eingeben", "gd.gen-ai.autocomplete.input-info.win": "@ für Vorschläge eingeben", "gd.gen-ai.visualisation.menu": "Weitere Aktionen", diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/en-AU.json b/libs/sdk-ui-gen-ai/src/localization/bundles/en-AU.json index b4d8e795432..c8edea741b9 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/en-AU.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/en-AU.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "Attribute", "gd.gen-ai.autocomplete.metric": "Metric", "gd.gen-ai.autocomplete.fact": "Fact", - "gd.gen-ai.autocomplete.date": "Date", + "gd.gen-ai.autocomplete.date": "Dataset", + "gd.gen-ai.autocomplete.insight": "Visualisation", + "gd.gen-ai.autocomplete.analyticalDashboard": "Dashboard", + "gd.gen-ai.autocomplete.displayForm": "Label", "gd.gen-ai.autocomplete.input-info.mac": "Type @ for suggestions", "gd.gen-ai.autocomplete.input-info.win": "Type @ for suggestions", "gd.gen-ai.visualisation.menu": "More actions", diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/en-GB.json b/libs/sdk-ui-gen-ai/src/localization/bundles/en-GB.json index c96cbe44572..ce8e747f4d4 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/en-GB.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/en-GB.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "Attribute", "gd.gen-ai.autocomplete.metric": "Metric", "gd.gen-ai.autocomplete.fact": "Fact", - "gd.gen-ai.autocomplete.date": "Date", + "gd.gen-ai.autocomplete.date": "Dataset", + "gd.gen-ai.autocomplete.insight": "Visualisation", + "gd.gen-ai.autocomplete.analyticalDashboard": "Dashboard", + "gd.gen-ai.autocomplete.displayForm": "Label", "gd.gen-ai.autocomplete.input-info.mac": "Type @ for suggestions", "gd.gen-ai.autocomplete.input-info.win": "Type @ for suggestions", "gd.gen-ai.visualisation.menu": "More actions", diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/es-419.json b/libs/sdk-ui-gen-ai/src/localization/bundles/es-419.json index 3aaf9942875..71db6e5c8d0 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/es-419.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/es-419.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "Atributo", "gd.gen-ai.autocomplete.metric": "Métrica", "gd.gen-ai.autocomplete.fact": "Hecho", - "gd.gen-ai.autocomplete.date": "Fecha", + "gd.gen-ai.autocomplete.date": "Conjunto de datos", + "gd.gen-ai.autocomplete.insight": "Visualización", + "gd.gen-ai.autocomplete.analyticalDashboard": "Panel", + "gd.gen-ai.autocomplete.displayForm": "Etiqueta", "gd.gen-ai.autocomplete.input-info.mac": "Escriba @ para ver sugerencias", "gd.gen-ai.autocomplete.input-info.win": "Escriba @ para ver sugerencias", "gd.gen-ai.visualisation.menu": "Más acciones", diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/es-ES.json b/libs/sdk-ui-gen-ai/src/localization/bundles/es-ES.json index df29c2386df..b5e05c60a31 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/es-ES.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/es-ES.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "Atributo", "gd.gen-ai.autocomplete.metric": "Métrica", "gd.gen-ai.autocomplete.fact": "Hecho", - "gd.gen-ai.autocomplete.date": "Fecha", + "gd.gen-ai.autocomplete.date": "Conjunto de datos", + "gd.gen-ai.autocomplete.insight": "Visualización", + "gd.gen-ai.autocomplete.analyticalDashboard": "Panel", + "gd.gen-ai.autocomplete.displayForm": "Etiqueta", "gd.gen-ai.autocomplete.input-info.mac": "Escriba @ para sugerencias", "gd.gen-ai.autocomplete.input-info.win": "Escriba @ para sugerencias", "gd.gen-ai.visualisation.menu": "Más acciones", diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/fi-FI.json b/libs/sdk-ui-gen-ai/src/localization/bundles/fi-FI.json index beb27d05188..7654fb36b10 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/fi-FI.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/fi-FI.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "Attribuutti", "gd.gen-ai.autocomplete.metric": "Metriikka", "gd.gen-ai.autocomplete.fact": "Fakta", - "gd.gen-ai.autocomplete.date": "Päivämäärä", + "gd.gen-ai.autocomplete.date": "Tietojoukko", + "gd.gen-ai.autocomplete.insight": "Visualisointi", + "gd.gen-ai.autocomplete.analyticalDashboard": "Kojelauta", + "gd.gen-ai.autocomplete.displayForm": "Tunniste", "gd.gen-ai.autocomplete.input-info.mac": "Kirjoita @ nähdäksesi ehdotukset", "gd.gen-ai.autocomplete.input-info.win": "Kirjoita @ nähdäksesi ehdotukset", "gd.gen-ai.visualisation.menu": "Lisää toimintoja", diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/fr-CA.json b/libs/sdk-ui-gen-ai/src/localization/bundles/fr-CA.json index 332bf5a9402..dcc8e281d21 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/fr-CA.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/fr-CA.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "Attribut", "gd.gen-ai.autocomplete.metric": "Métrique", "gd.gen-ai.autocomplete.fact": "Fait", - "gd.gen-ai.autocomplete.date": "Date", + "gd.gen-ai.autocomplete.date": "Ensemble de données", + "gd.gen-ai.autocomplete.insight": "Visualisation", + "gd.gen-ai.autocomplete.analyticalDashboard": "Tableau de bord", + "gd.gen-ai.autocomplete.displayForm": "Libellé", "gd.gen-ai.autocomplete.input-info.mac": "Tapez @ pour obtenir des suggestions", "gd.gen-ai.autocomplete.input-info.win": "Tapez @ pour obtenir des suggestions", "gd.gen-ai.visualisation.menu": "Plus d'actions", diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/fr-FR.json b/libs/sdk-ui-gen-ai/src/localization/bundles/fr-FR.json index a823cbb863e..2515395bee0 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/fr-FR.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/fr-FR.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "Attribut", "gd.gen-ai.autocomplete.metric": "Métrique", "gd.gen-ai.autocomplete.fact": "Fait", - "gd.gen-ai.autocomplete.date": "Date", + "gd.gen-ai.autocomplete.date": "Ensemble de données", + "gd.gen-ai.autocomplete.insight": "Visualisation", + "gd.gen-ai.autocomplete.analyticalDashboard": "Tableau de bord", + "gd.gen-ai.autocomplete.displayForm": "Libellé", "gd.gen-ai.autocomplete.input-info.mac": "Saisissez @ pour obtenir des suggestions", "gd.gen-ai.autocomplete.input-info.win": "Saisissez @ pour obtenir des suggestions", "gd.gen-ai.visualisation.menu": "Plus d'actions", diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/id-ID.json b/libs/sdk-ui-gen-ai/src/localization/bundles/id-ID.json index d293a81c751..b7992f0c97a 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/id-ID.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/id-ID.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "Atribut", "gd.gen-ai.autocomplete.metric": "Metrik", "gd.gen-ai.autocomplete.fact": "Fakta", - "gd.gen-ai.autocomplete.date": "Tanggal", + "gd.gen-ai.autocomplete.date": "Dataset", + "gd.gen-ai.autocomplete.insight": "Visualisasi", + "gd.gen-ai.autocomplete.analyticalDashboard": "Dasbor", + "gd.gen-ai.autocomplete.displayForm": "Label", "gd.gen-ai.autocomplete.input-info.mac": "Ketik @ untuk saran", "gd.gen-ai.autocomplete.input-info.win": "Ketik @ untuk saran", "gd.gen-ai.visualisation.menu": "Tindakan lainnya", diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/it-IT.json b/libs/sdk-ui-gen-ai/src/localization/bundles/it-IT.json index f5c7d9b40e4..00ed68b6f87 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/it-IT.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/it-IT.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "Attributo", "gd.gen-ai.autocomplete.metric": "Metrica", "gd.gen-ai.autocomplete.fact": "Dato concreto", - "gd.gen-ai.autocomplete.date": "Data", + "gd.gen-ai.autocomplete.date": "Set di dati", + "gd.gen-ai.autocomplete.insight": "Visualizzazione", + "gd.gen-ai.autocomplete.analyticalDashboard": "Cruscotto", + "gd.gen-ai.autocomplete.displayForm": "Etichetta", "gd.gen-ai.autocomplete.input-info.mac": "Digita @ per i suggerimenti", "gd.gen-ai.autocomplete.input-info.win": "Digita @ per i suggerimenti", "gd.gen-ai.visualisation.menu": "Altre azioni", diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/ja-JP.json b/libs/sdk-ui-gen-ai/src/localization/bundles/ja-JP.json index 23fa75494f9..c1079482f86 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/ja-JP.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/ja-JP.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "アトリビュート", "gd.gen-ai.autocomplete.metric": "メトリック", "gd.gen-ai.autocomplete.fact": "ファクト", - "gd.gen-ai.autocomplete.date": "日付", + "gd.gen-ai.autocomplete.date": "データセット", + "gd.gen-ai.autocomplete.insight": "ビジュアライゼーション", + "gd.gen-ai.autocomplete.analyticalDashboard": "ダッシュボード", + "gd.gen-ai.autocomplete.displayForm": "ラベル", "gd.gen-ai.autocomplete.input-info.mac": "候補を表示するには @ を入力", "gd.gen-ai.autocomplete.input-info.win": "候補を表示するには @ を入力", "gd.gen-ai.visualisation.menu": "その他のアクション", diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/ko-KR.json b/libs/sdk-ui-gen-ai/src/localization/bundles/ko-KR.json index a38629a4473..e6389e765db 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/ko-KR.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/ko-KR.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "속성", "gd.gen-ai.autocomplete.metric": "메트릭", "gd.gen-ai.autocomplete.fact": "팩트", - "gd.gen-ai.autocomplete.date": "날짜", + "gd.gen-ai.autocomplete.date": "데이터셋", + "gd.gen-ai.autocomplete.insight": "시각화", + "gd.gen-ai.autocomplete.analyticalDashboard": "대시보드", + "gd.gen-ai.autocomplete.displayForm": "레이블", "gd.gen-ai.autocomplete.input-info.mac": "제안을 보려면 @ 입력", "gd.gen-ai.autocomplete.input-info.win": "제안을 보려면 @ 입력", "gd.gen-ai.visualisation.menu": "추가 작업", diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/nl-NL.json b/libs/sdk-ui-gen-ai/src/localization/bundles/nl-NL.json index 0598280ce1b..af907652a61 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/nl-NL.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/nl-NL.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "Attribuut", "gd.gen-ai.autocomplete.metric": "Meetgegeven", "gd.gen-ai.autocomplete.fact": "Feit", - "gd.gen-ai.autocomplete.date": "Datum", + "gd.gen-ai.autocomplete.date": "Dataset", + "gd.gen-ai.autocomplete.insight": "Weergave", + "gd.gen-ai.autocomplete.analyticalDashboard": "Dashboard", + "gd.gen-ai.autocomplete.displayForm": "Label", "gd.gen-ai.autocomplete.input-info.mac": "Typ @ voor suggesties", "gd.gen-ai.autocomplete.input-info.win": "Typ @ voor suggesties", "gd.gen-ai.visualisation.menu": "Meer acties", diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/pl-PL.json b/libs/sdk-ui-gen-ai/src/localization/bundles/pl-PL.json index dbb216bedf5..5d911c8b2d4 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/pl-PL.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/pl-PL.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "Atrybut", "gd.gen-ai.autocomplete.metric": "Metryka", "gd.gen-ai.autocomplete.fact": "Fakt", - "gd.gen-ai.autocomplete.date": "Data", + "gd.gen-ai.autocomplete.date": "Zestaw danych", + "gd.gen-ai.autocomplete.insight": "Wizualizacja", + "gd.gen-ai.autocomplete.analyticalDashboard": "Pulpit nawigacyjny", + "gd.gen-ai.autocomplete.displayForm": "Etykieta", "gd.gen-ai.autocomplete.input-info.mac": "Wpisz @, aby wyświetlić sugestie", "gd.gen-ai.autocomplete.input-info.win": "Wpisz @, aby wyświetlić sugestie", "gd.gen-ai.visualisation.menu": "Więcej akcji", diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/pt-BR.json b/libs/sdk-ui-gen-ai/src/localization/bundles/pt-BR.json index 1f7b676557b..84feeb91a5e 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/pt-BR.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/pt-BR.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "Atributo", "gd.gen-ai.autocomplete.metric": "Métrica", "gd.gen-ai.autocomplete.fact": "Fato", - "gd.gen-ai.autocomplete.date": "Data", + "gd.gen-ai.autocomplete.date": "Conjunto de Dados", + "gd.gen-ai.autocomplete.insight": "Visualização", + "gd.gen-ai.autocomplete.analyticalDashboard": "Painel", + "gd.gen-ai.autocomplete.displayForm": "Rótulo", "gd.gen-ai.autocomplete.input-info.mac": "Digite @ para sugestões", "gd.gen-ai.autocomplete.input-info.win": "Digite @ para sugestões", "gd.gen-ai.visualisation.menu": "Mais ações", diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/pt-PT.json b/libs/sdk-ui-gen-ai/src/localization/bundles/pt-PT.json index 21608e54c78..9efc7c3cd5d 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/pt-PT.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/pt-PT.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "Atributo", "gd.gen-ai.autocomplete.metric": "Métrica", "gd.gen-ai.autocomplete.fact": "Facto", - "gd.gen-ai.autocomplete.date": "Data", + "gd.gen-ai.autocomplete.date": "Conjunto de dados", + "gd.gen-ai.autocomplete.insight": "Visualização", + "gd.gen-ai.autocomplete.analyticalDashboard": "Painel", + "gd.gen-ai.autocomplete.displayForm": "Etiqueta", "gd.gen-ai.autocomplete.input-info.mac": "Digite @ para sugestões", "gd.gen-ai.autocomplete.input-info.win": "Digite @ para sugestões", "gd.gen-ai.visualisation.menu": "Mais ações", diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/ru-RU.json b/libs/sdk-ui-gen-ai/src/localization/bundles/ru-RU.json index 73fc474aaa3..b440843b851 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/ru-RU.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/ru-RU.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "Атрибут", "gd.gen-ai.autocomplete.metric": "Метрика", "gd.gen-ai.autocomplete.fact": "Факт", - "gd.gen-ai.autocomplete.date": "Дата", + "gd.gen-ai.autocomplete.date": "Датасет", + "gd.gen-ai.autocomplete.insight": "Визуализация", + "gd.gen-ai.autocomplete.analyticalDashboard": "Панель управления", + "gd.gen-ai.autocomplete.displayForm": "Метка", "gd.gen-ai.autocomplete.input-info.mac": "Введите @ для подсказок", "gd.gen-ai.autocomplete.input-info.win": "Введите @ для подсказок", "gd.gen-ai.visualisation.menu": "Дополнительные действия", diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/sl-SI.json b/libs/sdk-ui-gen-ai/src/localization/bundles/sl-SI.json index 6e6f73e7403..7062d28e139 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/sl-SI.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/sl-SI.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "Atribut", "gd.gen-ai.autocomplete.metric": "Metrika", "gd.gen-ai.autocomplete.fact": "Dejstvo", - "gd.gen-ai.autocomplete.date": "Datum", + "gd.gen-ai.autocomplete.date": "Podatkovni sklop", + "gd.gen-ai.autocomplete.insight": "Vizualizacija", + "gd.gen-ai.autocomplete.analyticalDashboard": "Nadzorna plošča", + "gd.gen-ai.autocomplete.displayForm": "Oznaka", "gd.gen-ai.autocomplete.input-info.mac": "Vnesite @ za predloge", "gd.gen-ai.autocomplete.input-info.win": "Vnesite @ za predloge", "gd.gen-ai.visualisation.menu": "Več dejanj", diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/th-TH.json b/libs/sdk-ui-gen-ai/src/localization/bundles/th-TH.json index 49eb33e3525..82ce4201a82 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/th-TH.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/th-TH.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "แอตทริบิวต์", "gd.gen-ai.autocomplete.metric": "เมตริก", "gd.gen-ai.autocomplete.fact": "ข้อเท็จจริง", - "gd.gen-ai.autocomplete.date": "วันที่", + "gd.gen-ai.autocomplete.date": "ชุดข้อมูล", + "gd.gen-ai.autocomplete.insight": "การแสดงภาพข้อมูล", + "gd.gen-ai.autocomplete.analyticalDashboard": "แดชบอร์ด", + "gd.gen-ai.autocomplete.displayForm": "ป้ายกำกับ", "gd.gen-ai.autocomplete.input-info.mac": "พิมพ์ @ เพื่อดูคำแนะนำ", "gd.gen-ai.autocomplete.input-info.win": "พิมพ์ @ เพื่อดูคำแนะนำ", "gd.gen-ai.visualisation.menu": "การดำเนินการเพิ่มเติม", diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/tr-TR.json b/libs/sdk-ui-gen-ai/src/localization/bundles/tr-TR.json index 950e22094dc..ad7dceb455c 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/tr-TR.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/tr-TR.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "Öznitelik", "gd.gen-ai.autocomplete.metric": "Metrik", "gd.gen-ai.autocomplete.fact": "Olgu", - "gd.gen-ai.autocomplete.date": "Tarih", + "gd.gen-ai.autocomplete.date": "Veri kümesi", + "gd.gen-ai.autocomplete.insight": "Görselleştirme", + "gd.gen-ai.autocomplete.analyticalDashboard": "Gösterge Tablosu", + "gd.gen-ai.autocomplete.displayForm": "Etiket", "gd.gen-ai.autocomplete.input-info.mac": "Öneriler için @ yazın", "gd.gen-ai.autocomplete.input-info.win": "Öneriler için @ yazın", "gd.gen-ai.visualisation.menu": "Daha fazla işlem", diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/uk-UA.json b/libs/sdk-ui-gen-ai/src/localization/bundles/uk-UA.json index d2d7dbe3576..4ffa847f3bd 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/uk-UA.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/uk-UA.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "Атрибут", "gd.gen-ai.autocomplete.metric": "Метрика", "gd.gen-ai.autocomplete.fact": "Факт", - "gd.gen-ai.autocomplete.date": "Дата", + "gd.gen-ai.autocomplete.date": "Набір даних", + "gd.gen-ai.autocomplete.insight": "Візуалізація", + "gd.gen-ai.autocomplete.analyticalDashboard": "Дашборд", + "gd.gen-ai.autocomplete.displayForm": "Мітка", "gd.gen-ai.autocomplete.input-info.mac": "Введіть @, щоб побачити підказки", "gd.gen-ai.autocomplete.input-info.win": "Введіть @, щоб побачити підказки", "gd.gen-ai.visualisation.menu": "Більше дій", diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/vi-VN.json b/libs/sdk-ui-gen-ai/src/localization/bundles/vi-VN.json index 84ff4196881..061c1af4b1b 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/vi-VN.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/vi-VN.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "Thuộc tính", "gd.gen-ai.autocomplete.metric": "Thước đo", "gd.gen-ai.autocomplete.fact": "Fact", - "gd.gen-ai.autocomplete.date": "Ngày", + "gd.gen-ai.autocomplete.date": "Tập dữ liệu", + "gd.gen-ai.autocomplete.insight": "Trực quan hóa", + "gd.gen-ai.autocomplete.analyticalDashboard": "Bảng điều khiển", + "gd.gen-ai.autocomplete.displayForm": "Nhãn", "gd.gen-ai.autocomplete.input-info.mac": "Nhập @ để xem gợi ý", "gd.gen-ai.autocomplete.input-info.win": "Nhập @ để xem gợi ý", "gd.gen-ai.visualisation.menu": "Thêm hành động", diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/zh-HK.json b/libs/sdk-ui-gen-ai/src/localization/bundles/zh-HK.json index c27443d8f00..2efb4f6f9ec 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/zh-HK.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/zh-HK.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "屬性", "gd.gen-ai.autocomplete.metric": "計量", "gd.gen-ai.autocomplete.fact": "事實", - "gd.gen-ai.autocomplete.date": "日期", + "gd.gen-ai.autocomplete.date": "數據", + "gd.gen-ai.autocomplete.insight": "可視化", + "gd.gen-ai.autocomplete.analyticalDashboard": "儀錶板", + "gd.gen-ai.autocomplete.displayForm": "標籤", "gd.gen-ai.autocomplete.input-info.mac": "輸入 @ 以取得建議", "gd.gen-ai.autocomplete.input-info.win": "輸入 @ 以取得建議", "gd.gen-ai.visualisation.menu": "更多作", diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/zh-Hans.json b/libs/sdk-ui-gen-ai/src/localization/bundles/zh-Hans.json index 9a0bab360c7..9fb5130c333 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/zh-Hans.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/zh-Hans.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "属性", "gd.gen-ai.autocomplete.metric": "指标", "gd.gen-ai.autocomplete.fact": "事实", - "gd.gen-ai.autocomplete.date": "日期", + "gd.gen-ai.autocomplete.date": "数据集", + "gd.gen-ai.autocomplete.insight": "可视化", + "gd.gen-ai.autocomplete.analyticalDashboard": "控制面板", + "gd.gen-ai.autocomplete.displayForm": "标签", "gd.gen-ai.autocomplete.input-info.mac": "输入 @ 以获取建议", "gd.gen-ai.autocomplete.input-info.win": "输入 @ 以获取建议", "gd.gen-ai.visualisation.menu": "更多操作", diff --git a/libs/sdk-ui-gen-ai/src/localization/bundles/zh-Hant.json b/libs/sdk-ui-gen-ai/src/localization/bundles/zh-Hant.json index e180bc1843c..c6d72afd1ab 100644 --- a/libs/sdk-ui-gen-ai/src/localization/bundles/zh-Hant.json +++ b/libs/sdk-ui-gen-ai/src/localization/bundles/zh-Hant.json @@ -180,7 +180,10 @@ "gd.gen-ai.autocomplete.attribute": "屬性", "gd.gen-ai.autocomplete.metric": "指標", "gd.gen-ai.autocomplete.fact": "事實", - "gd.gen-ai.autocomplete.date": "日期", + "gd.gen-ai.autocomplete.date": "數據集", + "gd.gen-ai.autocomplete.insight": "視覺化", + "gd.gen-ai.autocomplete.analyticalDashboard": "儀表板", + "gd.gen-ai.autocomplete.displayForm": "標籤", "gd.gen-ai.autocomplete.input-info.mac": "輸入 @ 以取得建議", "gd.gen-ai.autocomplete.input-info.win": "輸入 @ 以取得建議", "gd.gen-ai.visualisation.menu": "更多操作", diff --git a/libs/sdk-ui-gen-ai/src/store/messages/messagesSlice.ts b/libs/sdk-ui-gen-ai/src/store/messages/messagesSlice.ts index 96d0c4570ef..d63d9b74182 100644 --- a/libs/sdk-ui-gen-ai/src/store/messages/messagesSlice.ts +++ b/libs/sdk-ui-gen-ai/src/store/messages/messagesSlice.ts @@ -569,6 +569,17 @@ const messagesSlice = createSlice({ conversationId?: string; }>, ) => { + // Always clear the async process first to avoid stranding the UI + const data = getConversationData(state.conversationsData, payload.conversationId); + if (data) { + delete data.asyncProcess; + } + delete state.messageAsyncProcess; + + if (!getMessageExists(state, payload.assistantMessageId, payload.conversationId)) { + return; + } + const assistantMessage = getAssistantMessageStrict( state, payload.assistantMessageId, @@ -579,12 +590,9 @@ const messagesSlice = createSlice({ assistantMessage.complete = true; assistantMessage.streaming = false; assistantMessage.content = makeErrorContent(payload.error); - const data = getConversationData(state.conversationsData, payload.conversationId); - delete data?.asyncProcess; } else { assistantMessage.complete = true; assistantMessage.content.push(makeErrorContents(payload.error)); - delete state.messageAsyncProcess; } }, /** @@ -708,19 +716,31 @@ const messagesSlice = createSlice({ }: PayloadAction<{ assistantMessageId: string; conversationId?: string; + cancelled?: boolean; }>, ) => { + // Always clear the async process first to avoid stranding the UI + setConversationInProgress(state, false, payload.conversationId); + const data = getConversationData(state.conversationsData, payload.conversationId); + if (data) { + delete data.asyncProcess; + } + delete state.messageAsyncProcess; + + if (!getMessageExists(state, payload.assistantMessageId, payload.conversationId)) { + return; + } + const assistantMessage = getAssistantMessageStrict( state, payload.assistantMessageId, payload.conversationId, ); assistantMessage.complete = true; - setConversationInProgress(state, false, payload.conversationId); - - const data = getConversationData(state.conversationsData, payload.conversationId); - delete data?.asyncProcess; - delete state.messageAsyncProcess; + assistantMessage.cancelled = payload.cancelled ?? assistantMessage.cancelled; + if (isChatConversationLocalItem(assistantMessage)) { + assistantMessage.streaming = false; + } }, setMessagesAction: ( state, diff --git a/libs/sdk-ui-gen-ai/src/store/sideEffects/converters/toLocalContent.ts b/libs/sdk-ui-gen-ai/src/store/sideEffects/converters/toLocalContent.ts index 937ad2da1f8..48d45aa4bac 100644 --- a/libs/sdk-ui-gen-ai/src/store/sideEffects/converters/toLocalContent.ts +++ b/libs/sdk-ui-gen-ai/src/store/sideEffects/converters/toLocalContent.ts @@ -12,7 +12,7 @@ export function convertToLocalContent(content: IChatConversationContent): IChatC ...content, ...(content.type === "multipart" ? { - parts: convertToLocalMultipartContent(content.parts), + parts: convertToLocalMultipartContent(content.parts ?? []), } : {}), }; diff --git a/libs/sdk-ui-gen-ai/src/store/sideEffects/onUserMessage.ts b/libs/sdk-ui-gen-ai/src/store/sideEffects/onUserMessage.ts index 4a425b4c2d3..a36e0f33dcf 100644 --- a/libs/sdk-ui-gen-ai/src/store/sideEffects/onUserMessage.ts +++ b/libs/sdk-ui-gen-ai/src/store/sideEffects/onUserMessage.ts @@ -152,7 +152,7 @@ function* threadUserMessage(message: Message) { // Mark the last assistant message as complete const messageToComplete = lastAssistantMessage ?? initialAssistantMessage; - if (messageToComplete && !wasCanceled) { + if (messageToComplete) { // Check if the message still exists before marking it complete // (it may have been removed if the chat was cleared) const currentMessages: Message[] = yield select(messagesSelector); @@ -162,6 +162,7 @@ function* threadUserMessage(message: Message) { yield put( evaluateMessageCompleteAction({ assistantMessageId: messageToComplete.localId, + cancelled: wasCanceled, }), ); } @@ -467,7 +468,7 @@ function* conversationUserMessage(message: IChatConversationLocalItem) { // Mark the last assistant message as complete const messageToComplete = lastAssistantMessage ?? initialAssistantMessage; - if (messageToComplete && !wasCanceled) { + if (messageToComplete) { // Check if the message still exists before marking it complete // (it may have been removed if the chat was cleared) const currentMessages: Message[] = yield select( @@ -481,6 +482,7 @@ function* conversationUserMessage(message: IChatConversationLocalItem) { evaluateMessageCompleteAction({ assistantMessageId: messageToComplete.localId, conversationId: conversation?.localId, + cancelled: wasCanceled, }), ); } diff --git a/libs/sdk-ui-geo/package.json b/libs/sdk-ui-geo/package.json index d310a7e032e..6dd9e54d342 100644 --- a/libs/sdk-ui-geo/package.json +++ b/libs/sdk-ui-geo/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-ui-geo", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData.UI SDK - Geo Charts", "license": "MIT", "author": "GoodData Corporation", @@ -79,13 +79,13 @@ "@gooddata/oxlint-config": "workspace:*", "@gooddata/sdk-backend-mockingbird": "workspace:*", "@gooddata/stylelint-config": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.0", "@types/geojson": "^7946.0.16", "@types/lodash-es": "^4.17.12", - "@types/mapbox-gl": "^2.6.0", + "@types/mapbox-gl": "2.7.21", "@types/maplibre-gl": "^1.14.0", "@types/node": "24.12.0", "@types/react": "19.1.11", diff --git a/libs/sdk-ui-kit/package.json b/libs/sdk-ui-kit/package.json index f2161d1fe6e..752ae94e43d 100644 --- a/libs/sdk-ui-kit/package.json +++ b/libs/sdk-ui-kit/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-ui-kit", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData SDK - UI Building Components", "license": "MIT", "author": "GoodData Corporation", @@ -116,21 +116,21 @@ "@gooddata/reference-workspace": "workspace:*", "@gooddata/sdk-backend-mockingbird": "workspace:*", "@gooddata/stylelint-config": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.0", "@testing-library/user-event": "14.6.1", "@types/debounce-promise": "^3.1.4", - "@types/kefir": "^3.8.5", + "@types/kefir": "3.8.11", "@types/lodash-es": "^4.17.12", "@types/node": "24.12.0", - "@types/raf": "^3.4.0", + "@types/raf": "3.4.3", "@types/react": "19.1.11", "@types/react-dom": "19.1.7", "@types/react-measure": "2.0.12", "@types/react-portal": "4.0.7", - "@types/tinycolor2": "^1.4.2", + "@types/tinycolor2": "1.4.6", "@typescript-eslint/eslint-plugin": "8.58.0", "@typescript-eslint/parser": "8.58.0", "@typescript/native-preview": "7.0.0-dev.20260202.1", @@ -148,7 +148,7 @@ "eslint-plugin-react-hooks": "5.2.0", "eslint-plugin-sonarjs": "3.0.6", "happy-dom": "20.9.0", - "kefir": "^3.8.8", + "kefir": "3.8.8", "npm-run-all": "^4.1.5", "oxfmt": "0.52.0", "oxlint": "1.51.0", @@ -158,7 +158,7 @@ "react-dom": "19.1.1", "sass": "1.70.0", "stylelint": "^16.26.1", - "svgo": "^2.8.0", + "svgo": "2.8.3", "typescript": "5.9.3", "vitest": "4.1.8", "vitest-dom": "0.1.1" diff --git a/libs/sdk-ui-loaders/package.json b/libs/sdk-ui-loaders/package.json index ee3f13ae1db..83ae93fe266 100644 --- a/libs/sdk-ui-loaders/package.json +++ b/libs/sdk-ui-loaders/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-ui-loaders", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData SDK Runtime Component Loaders", "license": "LicenseRef-LICENSE", "author": "GoodData Corporation", @@ -57,11 +57,11 @@ "devDependencies": { "@gooddata/eslint-config": "workspace:*", "@gooddata/oxlint-config": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@types/lodash-es": "^4.17.12", "@types/node": "24.12.0", - "@types/raf": "^3.4.0", + "@types/raf": "3.4.3", "@types/react": "19.1.11", "@types/react-dom": "19.1.7", "@types/semver": "7.3.13", diff --git a/libs/sdk-ui-pivot/package.json b/libs/sdk-ui-pivot/package.json index ec0bb451604..7f82e248496 100644 --- a/libs/sdk-ui-pivot/package.json +++ b/libs/sdk-ui-pivot/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-ui-pivot", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData.UI SDK - Pivot Table", "license": "MIT", "author": "GoodData Corporation", @@ -80,13 +80,13 @@ "@gooddata/sdk-backend-base": "workspace:*", "@gooddata/sdk-backend-mockingbird": "workspace:*", "@gooddata/stylelint-config": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.0", "@types/lodash-es": "^4.17.12", "@types/node": "24.12.0", - "@types/raf": "^3.4.0", + "@types/raf": "3.4.3", "@types/react": "19.1.11", "@types/react-dom": "19.1.7", "@typescript-eslint/eslint-plugin": "8.58.0", @@ -105,7 +105,7 @@ "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "5.2.0", "eslint-plugin-sonarjs": "3.0.6", - "full-icu": "^1.3.0", + "full-icu": "1.6.0", "happy-dom": "20.9.0", "npm-run-all": "^4.1.5", "oxfmt": "0.52.0", diff --git a/libs/sdk-ui-pluggable-application/package.json b/libs/sdk-ui-pluggable-application/package.json index 7f8a46fe582..137f63da08d 100644 --- a/libs/sdk-ui-pluggable-application/package.json +++ b/libs/sdk-ui-pluggable-application/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-ui-pluggable-application", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData SDK React helpers for pluggable applications", "license": "MIT", "author": "GoodData Corporation", @@ -53,7 +53,7 @@ "devDependencies": { "@gooddata/eslint-config": "workspace:*", "@gooddata/oxlint-config": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@types/node": "24.12.0", "@types/react": "19.1.11", diff --git a/libs/sdk-ui-pluggable-host/package.json b/libs/sdk-ui-pluggable-host/package.json index 482a843b75d..c493bddd241 100644 --- a/libs/sdk-ui-pluggable-host/package.json +++ b/libs/sdk-ui-pluggable-host/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-ui-pluggable-host", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData SDK runtime for hosting pluggable applications — registry, loader, routing, platform context, default UI chrome", "license": "MIT", "author": "GoodData Corporation", @@ -65,7 +65,7 @@ "devDependencies": { "@gooddata/eslint-config": "workspace:*", "@gooddata/oxlint-config": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.0", diff --git a/libs/sdk-ui-semantic-search/package.json b/libs/sdk-ui-semantic-search/package.json index d83bb9a8d23..7b1b24d23dc 100644 --- a/libs/sdk-ui-semantic-search/package.json +++ b/libs/sdk-ui-semantic-search/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-ui-semantic-search", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData SDK TypeScript & React skeleton", "license": "MIT", "author": "GoodData Corporation", @@ -73,12 +73,12 @@ "@gooddata/oxlint-config": "workspace:*", "@gooddata/sdk-backend-mockingbird": "workspace:*", "@gooddata/stylelint-config": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.0", "@types/lodash-es": "^4.17.12", - "@types/raf": "^3.4.0", + "@types/raf": "3.4.3", "@types/react": "19.1.11", "@types/react-dom": "19.1.7", "@typescript-eslint/eslint-plugin": "8.58.0", diff --git a/libs/sdk-ui-tests-app/package.json b/libs/sdk-ui-tests-app/package.json index c419c8c91bb..ba3fa9aafe7 100644 --- a/libs/sdk-ui-tests-app/package.json +++ b/libs/sdk-ui-tests-app/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-ui-tests-app", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "private": true, "description": "GoodData SDK e2e tests", "license": "LicenseRef-LICENSE", diff --git a/libs/sdk-ui-tests-e2e/package.json b/libs/sdk-ui-tests-e2e/package.json index 1801d1ce021..c7ce3185c96 100644 --- a/libs/sdk-ui-tests-e2e/package.json +++ b/libs/sdk-ui-tests-e2e/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-ui-tests-e2e", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "private": true, "description": "GoodData SDK e2e tests", "license": "LicenseRef-LICENSE", diff --git a/libs/sdk-ui-tests-reference-workspace/package.json b/libs/sdk-ui-tests-reference-workspace/package.json index 09d08692f48..3298c3a5dbf 100644 --- a/libs/sdk-ui-tests-reference-workspace/package.json +++ b/libs/sdk-ui-tests-reference-workspace/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-ui-tests-reference-workspace", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "private": true, "description": "GoodData SDK reference workspace", "license": "LicenseRef-LICENSE", diff --git a/libs/sdk-ui-tests-scenarios/package.json b/libs/sdk-ui-tests-scenarios/package.json index 2040ee2a20d..2e79075af66 100644 --- a/libs/sdk-ui-tests-scenarios/package.json +++ b/libs/sdk-ui-tests-scenarios/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-ui-tests-scenarios", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData React components scenarios project", "license": "LicenseRef-LICENSE", "author": "GoodData Corporation", @@ -63,7 +63,7 @@ "@types/node": "24.12.0", "@types/react": "19.1.11", "@types/react-dom": "19.1.7", - "@types/spark-md5": "^3.0.1", + "@types/spark-md5": "3.0.5", "@typescript-eslint/eslint-plugin": "8.58.0", "@typescript-eslint/parser": "8.58.0", "@typescript/native-preview": "7.0.0-dev.20260202.1", diff --git a/libs/sdk-ui-tests-storybook/package.json b/libs/sdk-ui-tests-storybook/package.json index 609a4b556f2..eec46ea3e61 100644 --- a/libs/sdk-ui-tests-storybook/package.json +++ b/libs/sdk-ui-tests-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-ui-tests-storybook", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData React components storybook", "license": "LicenseRef-LICENSE", "author": "GoodData Corporation", @@ -73,7 +73,7 @@ "@types/node": "24.12.0", "@types/react": "19.1.11", "@types/react-dom": "19.1.7", - "@types/spark-md5": "^3.0.1", + "@types/spark-md5": "3.0.5", "@typescript-eslint/eslint-plugin": "8.58.0", "@typescript-eslint/parser": "8.58.0", "@typescript/native-preview": "7.0.0-dev.20260202.1", diff --git a/libs/sdk-ui-theme-provider/package.json b/libs/sdk-ui-theme-provider/package.json index caba373f48c..1d9a823e523 100644 --- a/libs/sdk-ui-theme-provider/package.json +++ b/libs/sdk-ui-theme-provider/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-ui-theme-provider", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData SDK - Theme provider", "license": "MIT", "author": "GoodData Corporation", @@ -61,13 +61,13 @@ "@gooddata/oxlint-config": "workspace:*", "@gooddata/reference-workspace": "workspace:*", "@gooddata/sdk-backend-mockingbird": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.0", "@types/lodash-es": "^4.17.12", "@types/node": "24.12.0", - "@types/raf": "^3.4.0", + "@types/raf": "3.4.3", "@types/react": "19.1.11", "@types/react-dom": "19.1.7", "@typescript-eslint/eslint-plugin": "8.58.0", diff --git a/libs/sdk-ui-vis-commons/package.json b/libs/sdk-ui-vis-commons/package.json index 559240d24e7..4bf5bb54559 100644 --- a/libs/sdk-ui-vis-commons/package.json +++ b/libs/sdk-ui-vis-commons/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-ui-vis-commons", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData.UI SDK - common functionality for different types of visualizations", "license": "MIT", "author": "GoodData Corporation", @@ -70,7 +70,7 @@ "@gooddata/reference-workspace": "workspace:*", "@gooddata/sdk-backend-mockingbird": "workspace:*", "@gooddata/stylelint-config": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.0", diff --git a/libs/sdk-ui-web-components/package.json b/libs/sdk-ui-web-components/package.json index 126055fb4aa..d1cec0d22dd 100644 --- a/libs/sdk-ui-web-components/package.json +++ b/libs/sdk-ui-web-components/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-ui-web-components", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData Web Components wrapper for GoodData.UI", "license": "LicenseRef-LICENSE", "author": "GoodData Corporation", @@ -80,11 +80,11 @@ "@gooddata/eslint-config": "workspace:*", "@gooddata/oxlint-config": "workspace:*", "@gooddata/sdk-backend-mockingbird": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@types/lodash-es": "^4.17.12", "@types/node": "24.12.0", - "@types/raf": "^3.4.0", + "@types/raf": "3.4.3", "@types/react": "19.1.11", "@types/react-dom": "19.1.7", "@typescript-eslint/eslint-plugin": "8.58.0", @@ -111,7 +111,7 @@ "react": "19.1.1", "react-dom": "19.1.1", "rolldown": "1.0.3", - "rollup": "^4.51.0", + "rollup": "4.62.3", "typescript": "5.9.3", "vite": "8.0.16", "vite-plugin-css-injected-by-js": "3.1.1", diff --git a/libs/sdk-ui/package.json b/libs/sdk-ui/package.json index 4112485a2e2..ab396ce3abb 100644 --- a/libs/sdk-ui/package.json +++ b/libs/sdk-ui/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/sdk-ui", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData.UI SDK - Core", "license": "MIT", "author": "GoodData Corporation", @@ -77,14 +77,14 @@ "@gooddata/sdk-backend-base": "workspace:*", "@gooddata/sdk-backend-mockingbird": "workspace:*", "@gooddata/stylelint-config": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.0", "@types/hoist-non-react-statics": "^3.3.1", "@types/lodash-es": "^4.17.12", "@types/node": "24.12.0", - "@types/raf": "^3.4.0", + "@types/raf": "3.4.3", "@types/react": "19.1.11", "@types/react-dom": "19.1.7", "@typescript-eslint/eslint-plugin": "8.58.0", @@ -103,7 +103,7 @@ "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "5.2.0", "eslint-plugin-sonarjs": "3.0.6", - "full-icu": "^1.3.0", + "full-icu": "1.6.0", "happy-dom": "20.9.0", "npm-run-all": "^4.1.5", "oxfmt": "0.52.0", @@ -113,7 +113,7 @@ "react": "19.1.1", "react-dom": "19.1.1", "stylelint": "^16.26.1", - "tsd": "^0.32.0", + "tsd": "0.32.0", "typescript": "5.9.3", "vitest": "4.1.8", "vitest-dom": "0.1.1" diff --git a/libs/sdk-ui/src/base/localization/bundles/de-DE.json b/libs/sdk-ui/src/base/localization/bundles/de-DE.json index f38be3ff2e9..9d47c516f5d 100644 --- a/libs/sdk-ui/src/base/localization/bundles/de-DE.json +++ b/libs/sdk-ui/src/base/localization/bundles/de-DE.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "Dateien hochladen", "fileUpload.action.retryFile": "{fileName} wiederholen", "fileUpload.action.removeFile": "{fileName} entfernen", + "csvDelimiterPicker.option.inherit": "Übernehmen", "csvDelimiterPicker.option.comma": "Komma", "csvDelimiterPicker.option.semicolon": "Semikolon", "csvDelimiterPicker.option.pipe": "Pipe", diff --git a/libs/sdk-ui/src/base/localization/bundles/en-AU.json b/libs/sdk-ui/src/base/localization/bundles/en-AU.json index a250687f0ab..542f1aa5630 100644 --- a/libs/sdk-ui/src/base/localization/bundles/en-AU.json +++ b/libs/sdk-ui/src/base/localization/bundles/en-AU.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "Upload files", "fileUpload.action.retryFile": "Retry {fileName}", "fileUpload.action.removeFile": "Remove {fileName}", + "csvDelimiterPicker.option.inherit": "Inherit", "csvDelimiterPicker.option.comma": "Comma", "csvDelimiterPicker.option.semicolon": "Semicolon", "csvDelimiterPicker.option.pipe": "Pipe", diff --git a/libs/sdk-ui/src/base/localization/bundles/en-GB.json b/libs/sdk-ui/src/base/localization/bundles/en-GB.json index 21e3a3866d7..8185d3fac3b 100644 --- a/libs/sdk-ui/src/base/localization/bundles/en-GB.json +++ b/libs/sdk-ui/src/base/localization/bundles/en-GB.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "Upload files", "fileUpload.action.retryFile": "Retry {fileName}", "fileUpload.action.removeFile": "Remove {fileName}", + "csvDelimiterPicker.option.inherit": "Inherit", "csvDelimiterPicker.option.comma": "Comma", "csvDelimiterPicker.option.semicolon": "Semicolon", "csvDelimiterPicker.option.pipe": "Pipe", diff --git a/libs/sdk-ui/src/base/localization/bundles/es-419.json b/libs/sdk-ui/src/base/localization/bundles/es-419.json index 02f478717dc..750694dd9b2 100644 --- a/libs/sdk-ui/src/base/localization/bundles/es-419.json +++ b/libs/sdk-ui/src/base/localization/bundles/es-419.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "Cargar archivos", "fileUpload.action.retryFile": "Reintentar {fileName}", "fileUpload.action.removeFile": "Eliminar {fileName}", + "csvDelimiterPicker.option.inherit": "Heredar", "csvDelimiterPicker.option.comma": "Coma", "csvDelimiterPicker.option.semicolon": "Punto y coma", "csvDelimiterPicker.option.pipe": "Barra vertical", diff --git a/libs/sdk-ui/src/base/localization/bundles/es-ES.json b/libs/sdk-ui/src/base/localization/bundles/es-ES.json index 6ddab84b1a0..b7c17bf17cf 100644 --- a/libs/sdk-ui/src/base/localization/bundles/es-ES.json +++ b/libs/sdk-ui/src/base/localization/bundles/es-ES.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "Cargar archivos", "fileUpload.action.retryFile": "Reintentar {fileName}", "fileUpload.action.removeFile": "Eliminar {fileName}", + "csvDelimiterPicker.option.inherit": "Heredado", "csvDelimiterPicker.option.comma": "Coma", "csvDelimiterPicker.option.semicolon": "Punto y coma", "csvDelimiterPicker.option.pipe": "Barra vertical", diff --git a/libs/sdk-ui/src/base/localization/bundles/fi-FI.json b/libs/sdk-ui/src/base/localization/bundles/fi-FI.json index 9e5b1aa9765..7aab7179f9f 100644 --- a/libs/sdk-ui/src/base/localization/bundles/fi-FI.json +++ b/libs/sdk-ui/src/base/localization/bundles/fi-FI.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "Lataa tiedostoja", "fileUpload.action.retryFile": "Yritä uudelleen {fileName}", "fileUpload.action.removeFile": "Poista {fileName}", + "csvDelimiterPicker.option.inherit": "Peri", "csvDelimiterPicker.option.comma": "Pilkku", "csvDelimiterPicker.option.semicolon": "Puolipiste", "csvDelimiterPicker.option.pipe": "Pystyviiva", diff --git a/libs/sdk-ui/src/base/localization/bundles/fr-CA.json b/libs/sdk-ui/src/base/localization/bundles/fr-CA.json index 810b8362e50..f73e024256a 100644 --- a/libs/sdk-ui/src/base/localization/bundles/fr-CA.json +++ b/libs/sdk-ui/src/base/localization/bundles/fr-CA.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "Téléverser des fichiers", "fileUpload.action.retryFile": "Réessayer {fileName}", "fileUpload.action.removeFile": "Supprimer {fileName}", + "csvDelimiterPicker.option.inherit": "Hériter", "csvDelimiterPicker.option.comma": "Virgule", "csvDelimiterPicker.option.semicolon": "Point-virgule", "csvDelimiterPicker.option.pipe": "Barre verticale", diff --git a/libs/sdk-ui/src/base/localization/bundles/fr-FR.json b/libs/sdk-ui/src/base/localization/bundles/fr-FR.json index 8cd55a67a63..ad366f2d2fc 100644 --- a/libs/sdk-ui/src/base/localization/bundles/fr-FR.json +++ b/libs/sdk-ui/src/base/localization/bundles/fr-FR.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "Téléverser des fichiers", "fileUpload.action.retryFile": "Réessayer {fileName}", "fileUpload.action.removeFile": "Supprimer {fileName}", + "csvDelimiterPicker.option.inherit": "Hériter", "csvDelimiterPicker.option.comma": "Virgule", "csvDelimiterPicker.option.semicolon": "Point-virgule", "csvDelimiterPicker.option.pipe": "Barre verticale", diff --git a/libs/sdk-ui/src/base/localization/bundles/id-ID.json b/libs/sdk-ui/src/base/localization/bundles/id-ID.json index 5c6cadaea56..a43441893fd 100644 --- a/libs/sdk-ui/src/base/localization/bundles/id-ID.json +++ b/libs/sdk-ui/src/base/localization/bundles/id-ID.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "Unggah file", "fileUpload.action.retryFile": "Coba lagi {fileName}", "fileUpload.action.removeFile": "Hapus {fileName}", + "csvDelimiterPicker.option.inherit": "Wariskan", "csvDelimiterPicker.option.comma": "Koma", "csvDelimiterPicker.option.semicolon": "Titik koma", "csvDelimiterPicker.option.pipe": "Garis vertikal", diff --git a/libs/sdk-ui/src/base/localization/bundles/it-IT.json b/libs/sdk-ui/src/base/localization/bundles/it-IT.json index 4b91bf4b217..155e7dd6d50 100644 --- a/libs/sdk-ui/src/base/localization/bundles/it-IT.json +++ b/libs/sdk-ui/src/base/localization/bundles/it-IT.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "Carica file", "fileUpload.action.retryFile": "Riprova {fileName}", "fileUpload.action.removeFile": "Rimuovi {fileName}", + "csvDelimiterPicker.option.inherit": "Eredita", "csvDelimiterPicker.option.comma": "Virgola", "csvDelimiterPicker.option.semicolon": "Punto e virgola", "csvDelimiterPicker.option.pipe": "Barra verticale", diff --git a/libs/sdk-ui/src/base/localization/bundles/ja-JP.json b/libs/sdk-ui/src/base/localization/bundles/ja-JP.json index 74fb171d314..dc5149dd8c9 100644 --- a/libs/sdk-ui/src/base/localization/bundles/ja-JP.json +++ b/libs/sdk-ui/src/base/localization/bundles/ja-JP.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "ファイルをアップロード", "fileUpload.action.retryFile": "{fileName} を再試行", "fileUpload.action.removeFile": "{fileName} を削除", + "csvDelimiterPicker.option.inherit": "継承", "csvDelimiterPicker.option.comma": "コンマ", "csvDelimiterPicker.option.semicolon": "セミコロン", "csvDelimiterPicker.option.pipe": "パイプ", diff --git a/libs/sdk-ui/src/base/localization/bundles/ko-KR.json b/libs/sdk-ui/src/base/localization/bundles/ko-KR.json index 59fc6af5fdd..0430679ce9c 100644 --- a/libs/sdk-ui/src/base/localization/bundles/ko-KR.json +++ b/libs/sdk-ui/src/base/localization/bundles/ko-KR.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "파일 업로드", "fileUpload.action.retryFile": "{fileName} 재시도", "fileUpload.action.removeFile": "{fileName} 제거", + "csvDelimiterPicker.option.inherit": "상속", "csvDelimiterPicker.option.comma": "쉼표", "csvDelimiterPicker.option.semicolon": "세미콜론", "csvDelimiterPicker.option.pipe": "파이프", diff --git a/libs/sdk-ui/src/base/localization/bundles/nl-NL.json b/libs/sdk-ui/src/base/localization/bundles/nl-NL.json index bef41685293..16a5afd66fa 100644 --- a/libs/sdk-ui/src/base/localization/bundles/nl-NL.json +++ b/libs/sdk-ui/src/base/localization/bundles/nl-NL.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "Bestanden uploaden", "fileUpload.action.retryFile": "{fileName} opnieuw proberen", "fileUpload.action.removeFile": "{fileName} verwijderen", + "csvDelimiterPicker.option.inherit": "Erven", "csvDelimiterPicker.option.comma": "Komma", "csvDelimiterPicker.option.semicolon": "Puntkomma", "csvDelimiterPicker.option.pipe": "Pipe", diff --git a/libs/sdk-ui/src/base/localization/bundles/pl-PL.json b/libs/sdk-ui/src/base/localization/bundles/pl-PL.json index d285e3481ec..09f1f966294 100644 --- a/libs/sdk-ui/src/base/localization/bundles/pl-PL.json +++ b/libs/sdk-ui/src/base/localization/bundles/pl-PL.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "Prześlij pliki", "fileUpload.action.retryFile": "Ponów próbę: {fileName}", "fileUpload.action.removeFile": "Usuń {fileName}", + "csvDelimiterPicker.option.inherit": "Dziedziczenie", "csvDelimiterPicker.option.comma": "Przecinek", "csvDelimiterPicker.option.semicolon": "Średnik", "csvDelimiterPicker.option.pipe": "Pionowa kreska", diff --git a/libs/sdk-ui/src/base/localization/bundles/pt-BR.json b/libs/sdk-ui/src/base/localization/bundles/pt-BR.json index 2193e2c27b0..f87bb29f826 100644 --- a/libs/sdk-ui/src/base/localization/bundles/pt-BR.json +++ b/libs/sdk-ui/src/base/localization/bundles/pt-BR.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "Fazer upload de arquivos", "fileUpload.action.retryFile": "Tentar novamente {fileName}", "fileUpload.action.removeFile": "Remover {fileName}", + "csvDelimiterPicker.option.inherit": "Herdado", "csvDelimiterPicker.option.comma": "Vírgula", "csvDelimiterPicker.option.semicolon": "Ponto e vírgula", "csvDelimiterPicker.option.pipe": "Barra vertical", diff --git a/libs/sdk-ui/src/base/localization/bundles/pt-PT.json b/libs/sdk-ui/src/base/localization/bundles/pt-PT.json index 0ed336c026d..4524f965948 100644 --- a/libs/sdk-ui/src/base/localization/bundles/pt-PT.json +++ b/libs/sdk-ui/src/base/localization/bundles/pt-PT.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "Carregar ficheiros", "fileUpload.action.retryFile": "Tentar novamente {fileName}", "fileUpload.action.removeFile": "Remover {fileName}", + "csvDelimiterPicker.option.inherit": "Herdar", "csvDelimiterPicker.option.comma": "Vírgula", "csvDelimiterPicker.option.semicolon": "Ponto e vírgula", "csvDelimiterPicker.option.pipe": "Barra vertical", diff --git a/libs/sdk-ui/src/base/localization/bundles/ru-RU.json b/libs/sdk-ui/src/base/localization/bundles/ru-RU.json index a0e422b37b2..92a125e6355 100644 --- a/libs/sdk-ui/src/base/localization/bundles/ru-RU.json +++ b/libs/sdk-ui/src/base/localization/bundles/ru-RU.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "Загрузить файлы", "fileUpload.action.retryFile": "Повторить {fileName}", "fileUpload.action.removeFile": "Удалить {fileName}", + "csvDelimiterPicker.option.inherit": "Унаследованное", "csvDelimiterPicker.option.comma": "Запятая", "csvDelimiterPicker.option.semicolon": "Точка с запятой", "csvDelimiterPicker.option.pipe": "Вертикальная черта", diff --git a/libs/sdk-ui/src/base/localization/bundles/sl-SI.json b/libs/sdk-ui/src/base/localization/bundles/sl-SI.json index aa4a645f7c9..cb825a57991 100644 --- a/libs/sdk-ui/src/base/localization/bundles/sl-SI.json +++ b/libs/sdk-ui/src/base/localization/bundles/sl-SI.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "Naloži datoteke", "fileUpload.action.retryFile": "Poskusi znova {fileName}", "fileUpload.action.removeFile": "Odstrani {fileName}", + "csvDelimiterPicker.option.inherit": "Dediščina", "csvDelimiterPicker.option.comma": "Vejica", "csvDelimiterPicker.option.semicolon": "Podpičje", "csvDelimiterPicker.option.pipe": "Navpična črta", diff --git a/libs/sdk-ui/src/base/localization/bundles/th-TH.json b/libs/sdk-ui/src/base/localization/bundles/th-TH.json index 5c7c49d42c8..cd822f92baf 100644 --- a/libs/sdk-ui/src/base/localization/bundles/th-TH.json +++ b/libs/sdk-ui/src/base/localization/bundles/th-TH.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "อัปโหลดไฟล์", "fileUpload.action.retryFile": "ลองใหม่ {fileName}", "fileUpload.action.removeFile": "นำ {fileName} ออก", + "csvDelimiterPicker.option.inherit": "สืบทอด", "csvDelimiterPicker.option.comma": "จุลภาค", "csvDelimiterPicker.option.semicolon": "อัฒภาค", "csvDelimiterPicker.option.pipe": "ไปป์", diff --git a/libs/sdk-ui/src/base/localization/bundles/tr-TR.json b/libs/sdk-ui/src/base/localization/bundles/tr-TR.json index 382f2240a1a..437f4d3072b 100644 --- a/libs/sdk-ui/src/base/localization/bundles/tr-TR.json +++ b/libs/sdk-ui/src/base/localization/bundles/tr-TR.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "Dosyaları yükle", "fileUpload.action.retryFile": "{fileName} öğesini yeniden dene", "fileUpload.action.removeFile": "{fileName} öğesini kaldır", + "csvDelimiterPicker.option.inherit": "Devral", "csvDelimiterPicker.option.comma": "Virgül", "csvDelimiterPicker.option.semicolon": "Noktalı virgül", "csvDelimiterPicker.option.pipe": "Dikey çizgi", diff --git a/libs/sdk-ui/src/base/localization/bundles/uk-UA.json b/libs/sdk-ui/src/base/localization/bundles/uk-UA.json index f644e73d454..ab7d6824e94 100644 --- a/libs/sdk-ui/src/base/localization/bundles/uk-UA.json +++ b/libs/sdk-ui/src/base/localization/bundles/uk-UA.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "Завантажити файли", "fileUpload.action.retryFile": "Повторити {fileName}", "fileUpload.action.removeFile": "Видалити {fileName}", + "csvDelimiterPicker.option.inherit": "Успадкувати", "csvDelimiterPicker.option.comma": "Кома", "csvDelimiterPicker.option.semicolon": "Крапка з комою", "csvDelimiterPicker.option.pipe": "Вертикальна риска", diff --git a/libs/sdk-ui/src/base/localization/bundles/vi-VN.json b/libs/sdk-ui/src/base/localization/bundles/vi-VN.json index e61a390ee30..059511a1909 100644 --- a/libs/sdk-ui/src/base/localization/bundles/vi-VN.json +++ b/libs/sdk-ui/src/base/localization/bundles/vi-VN.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "Tải lên tệp", "fileUpload.action.retryFile": "Thử lại {fileName}", "fileUpload.action.removeFile": "Loại bỏ {fileName}", + "csvDelimiterPicker.option.inherit": "Kế thừa", "csvDelimiterPicker.option.comma": "Dấu phẩy", "csvDelimiterPicker.option.semicolon": "Dấu chấm phẩy", "csvDelimiterPicker.option.pipe": "Dấu gạch đứng", diff --git a/libs/sdk-ui/src/base/localization/bundles/zh-HK.json b/libs/sdk-ui/src/base/localization/bundles/zh-HK.json index 7a0017b6fba..3bad112ffda 100644 --- a/libs/sdk-ui/src/base/localization/bundles/zh-HK.json +++ b/libs/sdk-ui/src/base/localization/bundles/zh-HK.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "上傳檔案", "fileUpload.action.retryFile": "重試 {fileName}", "fileUpload.action.removeFile": "移除 {fileName}", + "csvDelimiterPicker.option.inherit": "繼承", "csvDelimiterPicker.option.comma": "逗號", "csvDelimiterPicker.option.semicolon": "分號", "csvDelimiterPicker.option.pipe": "豎線", diff --git a/libs/sdk-ui/src/base/localization/bundles/zh-Hans.json b/libs/sdk-ui/src/base/localization/bundles/zh-Hans.json index 381104b3d33..e7bedf83d8b 100644 --- a/libs/sdk-ui/src/base/localization/bundles/zh-Hans.json +++ b/libs/sdk-ui/src/base/localization/bundles/zh-Hans.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "上传文件", "fileUpload.action.retryFile": "重试 {fileName}", "fileUpload.action.removeFile": "移除 {fileName}", + "csvDelimiterPicker.option.inherit": "继承", "csvDelimiterPicker.option.comma": "逗号", "csvDelimiterPicker.option.semicolon": "分号", "csvDelimiterPicker.option.pipe": "竖线", diff --git a/libs/sdk-ui/src/base/localization/bundles/zh-Hant.json b/libs/sdk-ui/src/base/localization/bundles/zh-Hant.json index db1c5027b52..510ad51bb2d 100644 --- a/libs/sdk-ui/src/base/localization/bundles/zh-Hant.json +++ b/libs/sdk-ui/src/base/localization/bundles/zh-Hant.json @@ -1192,6 +1192,7 @@ "fileUpload.list.label": "上傳檔案", "fileUpload.action.retryFile": "重試 {fileName}", "fileUpload.action.removeFile": "移除 {fileName}", + "csvDelimiterPicker.option.inherit": "繼承", "csvDelimiterPicker.option.comma": "逗號", "csvDelimiterPicker.option.semicolon": "分號", "csvDelimiterPicker.option.pipe": "豎線", diff --git a/libs/util/package.json b/libs/util/package.json index ae051a55849..2a898ff18c1 100644 --- a/libs/util/package.json +++ b/libs/util/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/util", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData Utility Functions", "license": "MIT", "author": "GoodData", @@ -47,7 +47,7 @@ "devDependencies": { "@gooddata/eslint-config": "workspace:*", "@gooddata/oxlint-config": "workspace:*", - "@microsoft/api-documenter": "^7.17.0", + "@microsoft/api-documenter": "7.30.10", "@microsoft/api-extractor": "^7.55.2", "@types/lodash-es": "^4.17.12", "@typescript-eslint/eslint-plugin": "8.58.0", diff --git a/templates/pluggable-module/harness/package.json b/templates/pluggable-module/harness/package.json index 2daf9aee89c..fa6b93a2f34 100644 --- a/templates/pluggable-module/harness/package.json +++ b/templates/pluggable-module/harness/package.json @@ -1,6 +1,6 @@ { "name": "gdc-app-template-name-harness", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "private": true, "description": "Standalone harness for gdc-app-template-name module", "license": "UNLICENSED", diff --git a/templates/pluggable-module/module/package.json b/templates/pluggable-module/module/package.json index 6aeec82f041..f16e40c9b80 100644 --- a/templates/pluggable-module/module/package.json +++ b/templates/pluggable-module/module/package.json @@ -1,6 +1,6 @@ { "name": "gdc-app-template-name-module", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "private": true, "description": "{applicationTemplateTitle} pluggable application module", "license": "UNLICENSED", diff --git a/tools/app-toolkit/package.json b/tools/app-toolkit/package.json index 88800080e7c..2b7bd7419f0 100644 --- a/tools/app-toolkit/package.json +++ b/tools/app-toolkit/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/app-toolkit", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "CLI with useful tools for creating and maintaining GoodData web applications.", "license": "LicenseRef-LICENSE", "author": "GoodData", @@ -64,7 +64,7 @@ "strip-ansi": "^6.0.0", "tar": "^7.5.11", "tslib": "2.8.1", - "validate-npm-package-name": "^5.0.0", + "validate-npm-package-name": "5.0.1", "webpack-cli": "7.0.3", "webpack-dev-server": "5.2.4" }, @@ -73,7 +73,7 @@ "@gooddata/oxlint-config": "workspace:*", "@types/columnify": "^1.5.1", "@types/cross-spawn": "^6.0.2", - "@types/fs-extra": "^9.0.13", + "@types/fs-extra": "9.0.13", "@types/inquirer": "^9.0.3", "@types/lodash-es": "^4.17.12", "@types/node": "24.12.0", diff --git a/tools/catalog-export/package.json b/tools/catalog-export/package.json index 51b8ee2b481..ce31d0858f3 100644 --- a/tools/catalog-export/package.json +++ b/tools/catalog-export/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/catalog-export", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData SDK Catalog Export tooling", "license": "MIT", "author": "GoodData", diff --git a/tools/create-pluggable-module/package.json b/tools/create-pluggable-module/package.json index 965b2994279..2248f6f8e76 100644 --- a/tools/create-pluggable-module/package.json +++ b/tools/create-pluggable-module/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/create-pluggable-module", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "Scaffolder for GoodData pluggable applications. Invoked via `npm init @gooddata/pluggable-module`.", "license": "MIT", "author": "GoodData Corporation", diff --git a/tools/dashboard-plugin-template/package.json b/tools/dashboard-plugin-template/package.json index f935346de09..b3817efe547 100644 --- a/tools/dashboard-plugin-template/package.json +++ b/tools/dashboard-plugin-template/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/dashboard-plugin-template", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "private": true, "description": "GoodData Template for Dashboard Plugins", "license": "LicenseRef-LICENSE", @@ -57,7 +57,7 @@ "devDependencies": { "@babel/core": "7.28.4", "@babel/preset-env": "7.29.7", - "@babel/preset-react": "^7.23.3", + "@babel/preset-react": "7.29.7", "@gooddata/catalog-export": "workspace:*", "@gooddata/eslint-config": "workspace:*", "@gooddata/oxlint-config": "workspace:*", @@ -76,19 +76,19 @@ "@gooddata/sdk-ui-pivot": "workspace:*", "@types/lodash-es": "^4.17.12", "@types/node": "24.12.0", - "@types/raf": "^3.4.0", + "@types/raf": "3.4.3", "@types/react": "19.1.11", "@types/react-dom": "19.1.7", "@typescript-eslint/eslint-plugin": "8.58.0", "@typescript-eslint/parser": "8.58.0", "@typescript/native-preview": "7.0.0-dev.20260202.1", - "babel-loader": "^8.0.5", - "case-sensitive-paths-webpack-plugin": "^2.4.0", + "babel-loader": "8.4.1", + "case-sensitive-paths-webpack-plugin": "2.4.0", "css-loader": "6.11.0", "dependency-cruiser": "17.3.10", "dotenv": "16.4.5", - "dotenv-webpack": "^7.0.2", - "esbuild-loader": "^4.0.2", + "dotenv-webpack": "7.1.1", + "esbuild-loader": "4.5.0", "eslint": "^9.39.2", "eslint-import-resolver-typescript": "4.4.4", "eslint-plugin-headers": "1.3.3", @@ -107,9 +107,9 @@ "react": "19.1.1", "react-dom": "19.1.1", "react-intl": "7.1.11", - "source-map-loader": "^4.0.0", + "source-map-loader": "4.0.2", "style-loader": "3.3.4", - "ts-loader": "^8.3.0", + "ts-loader": "8.4.0", "typescript": "5.9.3", "vitest": "4.1.8", "webpack": "5.107.2", diff --git a/tools/eslint-config/package.json b/tools/eslint-config/package.json index c62136bf607..8f3b63c91e4 100644 --- a/tools/eslint-config/package.json +++ b/tools/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/eslint-config", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "license": "MIT", "author": "GoodData", "repository": { @@ -165,7 +165,7 @@ "react-dom": "19.1.1", "storybook": "^10.5.6", "typescript": "5.9.3", - "vite-node": "^5.2.0" + "vite-node": "5.3.0" }, "peerDependencies": { "@typescript-eslint/eslint-plugin": "8.58.0", diff --git a/tools/i18n-toolkit/package.json b/tools/i18n-toolkit/package.json index a50b42c3a35..0621828c784 100644 --- a/tools/i18n-toolkit/package.json +++ b/tools/i18n-toolkit/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/i18n-toolkit", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "Localization validator to validate localization complexity and intl and html format.", "license": "MIT", "author": "GoodData", @@ -73,7 +73,7 @@ "eslint-plugin-jsdoc": "62.1.0", "eslint-plugin-no-barrel-files": "1.2.2", "eslint-plugin-sonarjs": "3.0.6", - "mkdirp": "^1.0.4", + "mkdirp": "1.0.4", "npm-run-all": "^4.1.5", "oxfmt": "0.52.0", "oxlint": "1.51.0", diff --git a/tools/lint-config/package.json b/tools/lint-config/package.json index 1710ecb9af4..88897ebc2eb 100644 --- a/tools/lint-config/package.json +++ b/tools/lint-config/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/lint-config", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "Linter-agnostic shared rules for @gooddata/eslint-config and @gooddata/oxlint-config", "license": "MIT", "author": "GoodData", diff --git a/tools/mock-handling/package.json b/tools/mock-handling/package.json index a1801acfb35..2f2193bb18b 100644 --- a/tools/mock-handling/package.json +++ b/tools/mock-handling/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/mock-handling", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData SDK Mock data capture and management tool", "license": "MIT", "author": "GoodData", diff --git a/tools/oxlint-config/package.json b/tools/oxlint-config/package.json index d2bdbbe532a..cbdff381f05 100644 --- a/tools/oxlint-config/package.json +++ b/tools/oxlint-config/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/oxlint-config", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "license": "MIT", "author": "GoodData", "repository": { @@ -78,7 +78,7 @@ "react-dom": "19.1.1", "storybook": "^10.5.6", "typescript": "5.9.3", - "vite-node": "^5.2.0" + "vite-node": "5.3.0" }, "peerDependencies": { "eslint-plugin-headers": "1.3.3", diff --git a/tools/plugin-toolkit/package.json b/tools/plugin-toolkit/package.json index e3e9dc01889..8cabcb21b54 100644 --- a/tools/plugin-toolkit/package.json +++ b/tools/plugin-toolkit/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/plugin-toolkit", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData Set of Tools for working with Plugins", "license": "LicenseRef-LICENSE", "author": "GoodData", @@ -65,14 +65,14 @@ "strip-ansi": "^6.0.0", "tar": "^7.5.11", "tslib": "2.8.1", - "validate-npm-package-name": "^5.0.0" + "validate-npm-package-name": "5.0.1" }, "devDependencies": { "@gooddata/eslint-config": "workspace:*", "@gooddata/oxlint-config": "workspace:*", "@types/columnify": "^1.5.1", "@types/cross-spawn": "^6.0.2", - "@types/fs-extra": "^9.0.13", + "@types/fs-extra": "9.0.13", "@types/inquirer": "^9.0.3", "@types/lodash-es": "^4.17.12", "@types/node": "24.12.0", diff --git a/tools/react-app-template/package.json b/tools/react-app-template/package.json index 6f8c764ad95..f67406f69de 100644 --- a/tools/react-app-template/package.json +++ b/tools/react-app-template/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/react-app-template", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "private": true, "description": "GoodData Template for React App", "license": "LicenseRef-LICENSE", @@ -66,11 +66,11 @@ "@types/react-dom": "19.1.7", "@typescript-eslint/eslint-plugin": "8.58.0", "@typescript-eslint/parser": "8.58.0", - "case-sensitive-paths-webpack-plugin": "^2.4.0", + "case-sensitive-paths-webpack-plugin": "2.4.0", "css-loader": "6.11.0", "dependency-cruiser": "17.3.10", "dotenv": "16.4.5", - "esbuild-loader": "^4.0.2", + "esbuild-loader": "4.5.0", "eslint": "^9.39.2", "eslint-import-resolver-typescript": "4.4.4", "eslint-plugin-headers": "1.3.3", @@ -85,10 +85,10 @@ "oxfmt": "0.52.0", "oxlint": "1.51.0", "oxlint-tsgolint": "0.15.0", - "process": "^0.11.10", - "source-map-loader": "^4.0.0", + "process": "0.11.10", + "source-map-loader": "4.0.2", "style-loader": "3.3.4", - "ts-loader": "^8.3.0", + "ts-loader": "8.4.0", "typescript": "5.9.3", "util": "^0.12.3", "webpack": "5.107.2", diff --git a/tools/reference-workspace-mgmt/package.json b/tools/reference-workspace-mgmt/package.json index 7a96873232a..c8fb42dec0a 100644 --- a/tools/reference-workspace-mgmt/package.json +++ b/tools/reference-workspace-mgmt/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/reference-workspace-mgmt", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "Command line tools for reference workspace management", "license": "MIT", "author": "GoodData", diff --git a/tools/reference-workspace/package.json b/tools/reference-workspace/package.json index c0bb90a1149..a8055b6401a 100644 --- a/tools/reference-workspace/package.json +++ b/tools/reference-workspace/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/reference-workspace", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData SDK - Reference Workspace for tests", "license": "MIT", "author": "GoodData", diff --git a/tools/stylelint-config/package.json b/tools/stylelint-config/package.json index 90906497da0..81cba55a79a 100644 --- a/tools/stylelint-config/package.json +++ b/tools/stylelint-config/package.json @@ -1,6 +1,6 @@ { "name": "@gooddata/stylelint-config", - "version": "11.54.0-alpha.6", + "version": "11.54.0-alpha.7", "description": "GoodData CSS Style Guide", "keywords": [ "config",