From cb53745d43ca8f058544eae8354c263781a23623 Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Tue, 5 May 2026 12:07:46 -0400 Subject: [PATCH] feat: semantic layer extension (#37815) --- .../workflows/superset-python-unittest.yml | 1 + UPDATING.md | 7 + docker/pythonpath_dev/superset_config.py | 8 +- .../extensions/contribution-types.md | 49 + docs/static/feature-flags.json | 6 + pyproject.toml | 1 + superset-core/pyproject.toml | 2 + .../superset_core/semantic_layers/config.py | 73 + .../src/superset_core/semantic_layers/daos.py | 169 + .../semantic_layers/decorators.py | 102 + .../superset_core/semantic_layers/layer.py | 129 + .../superset_core/semantic_layers/models.py | 85 + .../superset_core/semantic_layers/types.py | 209 ++ .../src/superset_core/semantic_layers/view.py | 113 + superset-frontend/package-lock.json | 217 +- superset-frontend/package.json | 7 + .../Label/reusable/DatasetTypeLabel.tsx | 18 +- .../src/query/DatasourceKey.ts | 12 +- .../src/query/types/Datasource.ts | 8 + .../src/utils/featureFlags.ts | 1 + .../test/connection/SupersetClient.test.ts | 11 +- .../test/query/types/Datasource.test.ts | 3 +- .../test/time-format/TimeFormatter.test.ts | 10 +- .../test/utils/series.test.ts | 4 +- .../src/components/Chart/Chart.tsx | 4 +- .../Chart/DrillDetail/DrillDetailPane.tsx | 3 +- .../src/components/DatabaseSelector/index.tsx | 24 +- .../ChangeDatasourceModal.test.tsx | 34 + .../ChangeDatasourceModal/index.tsx | 50 +- .../DatasetNotFoundErrorMessage.tsx | 3 +- .../src/components/ListView/Filters/index.tsx | 6 + .../src/components/ListView/ListView.tsx | 31 +- .../components/AddSliceCard/AddSliceCard.tsx | 3 +- .../src/dashboard/components/SliceAdder.tsx | 3 +- .../FilterControls/GroupByFilterCard.tsx | 16 +- .../FiltersConfigForm/DatasetSelect.tsx | 11 +- .../FiltersConfigForm/FiltersConfigForm.tsx | 21 +- .../transformers/filterTransformer.ts | 11 +- .../explore/actions/exploreActions.test.ts | 105 + .../src/explore/actions/exploreActions.ts | 85 + .../src/explore/actions/saveModalActions.ts | 7 +- .../DataTablesPane/components/SamplesPane.tsx | 6 +- .../DatasourcePanelDragOption.test.tsx | 4 +- .../DatasourcePanelDragOption/index.tsx | 40 +- .../DatasourcePanelItem.test.tsx | 2 +- .../components/DatasourcePanel/index.tsx | 6 +- .../components/ExploreViewContainer/index.tsx | 43 + .../ColumnConfigConstants.test.tsx | 20 +- .../ColumnConfigControl/constants.tsx | 2 - .../controls/DatasourceControl/index.tsx | 51 +- .../ColumnSelectPopover.tsx | 43 +- .../DndAdhocFilterOption.tsx | 6 +- .../DndColumnMetricSelect.tsx | 20 +- .../DndColumnSelect.tsx | 25 +- .../DndFilterSelect.test.tsx | 2 +- .../DndMetricSelect.test.tsx | 20 +- .../DndMetricSelect.tsx | 37 +- .../AdhocFilterEditPopover/index.tsx | 34 +- .../AdhocMetricEditPopover.test.tsx | 80 +- .../AdhocMetricEditPopover/index.tsx | 39 +- .../MetricControl/AdhocMetricOption.test.tsx | 6 +- .../MetricControl/MetricsControl.test.tsx | 7 +- superset-frontend/src/explore/controls.tsx | 3 +- .../src/explore/reducers/exploreReducer.ts | 20 + superset-frontend/src/explore/types.ts | 5 + superset-frontend/src/features/home/Menu.tsx | 13 +- .../src/features/home/SubMenu.tsx | 29 +- .../SemanticLayerModal.test.tsx | 130 + .../semanticLayers/SemanticLayerModal.tsx | 408 +++ .../semanticLayers/jsonFormsHelpers.test.ts | 150 + .../semanticLayers/jsonFormsHelpers.tsx | 386 +++ .../src/features/semanticLayers/label.ts | 65 + .../AddSemanticViewModal.test.tsx | 264 ++ .../semanticViews/AddSemanticViewModal.tsx | 541 ++++ .../SemanticViewEditModal.test.tsx | 267 ++ .../semanticViews/SemanticViewEditModal.tsx | 241 ++ .../src/pages/ChartCreation/index.tsx | 19 +- .../src/pages/ChartList/index.tsx | 5 +- .../src/pages/DatabaseList/index.tsx | 503 ++- .../DatasetList/DatasetList.behavior.test.tsx | 39 +- .../DatasetList.integration.test.tsx | 23 +- .../DatasetList/DatasetList.listview.test.tsx | 229 +- .../DatasetList.permissions.test.tsx | 17 +- .../pages/DatasetList/DatasetList.test.tsx | 78 +- .../DatasetList/DatasetList.testHelpers.tsx | 28 +- .../src/pages/DatasetList/index.tsx | 801 ++++- .../src/pages/FileHandler/index.test.tsx | 30 +- .../src/theme/ThemeController.ts | 2 +- superset-frontend/src/types/Dataset.ts | 11 +- superset/commands/datasource/__init__.py | 16 + superset/commands/datasource/list.py | 245 ++ superset/commands/explore/get.py | 6 +- superset/commands/semantic_layer/__init__.py | 16 + superset/commands/semantic_layer/create.py | 104 + superset/commands/semantic_layer/delete.py | 115 + .../commands/semantic_layer/exceptions.py | 76 + superset/commands/semantic_layer/update.py | 126 + superset/config.py | 3 + superset/connectors/sqla/models.py | 27 +- superset/core/api/core_api_injection.py | 35 + superset/daos/datasource.py | 160 +- superset/daos/semantic_layer.py | 220 ++ superset/datasource/api.py | 183 +- superset/datasource/schemas.py | 155 + superset/db_engine_specs/mysql.py | 8 +- superset/explorables/base.py | 168 +- superset/initialization/__init__.py | 11 +- ...7e0e21daa_add_semantic_layers_and_views.py | 168 + superset/models/sql_lab.py | 6 +- superset/security/manager.py | 318 +- superset/semantic_layers/__init__.py | 16 + superset/semantic_layers/api.py | 1147 +++++++ superset/semantic_layers/labels.py | 105 + superset/semantic_layers/mapper.py | 912 ++++++ superset/semantic_layers/models.py | 556 ++++ superset/semantic_layers/registry.py | 24 + superset/semantic_layers/schemas.py | 45 + superset/superset_typing.py | 51 +- superset/utils/core.py | 33 +- tests/integration_tests/charts/api_tests.py | 6 +- .../integration_tests/datasource/api_tests.py | 52 + .../commands/datasource/__init__.py | 16 + .../commands/datasource/list_test.py | 162 + .../commands/importers/v1/assets_test.py | 19 +- .../commands/semantic_layer/__init__.py | 16 + .../commands/semantic_layer/create_test.py | 230 ++ .../commands/semantic_layer/delete_test.py | 164 + .../semantic_layer/exceptions_test.py | 91 + .../commands/semantic_layer/update_test.py | 326 ++ tests/unit_tests/datasource/dao_tests.py | 29 + .../test_column_name_normalization.py | 13 + tests/unit_tests/models/core_test.py | 6 +- tests/unit_tests/security/api_test.py | 2 +- tests/unit_tests/semantic_layers/api_test.py | 2321 ++++++++++++++ tests/unit_tests/semantic_layers/dao_test.py | 85 + .../semantic_layers/decorators_test.py | 103 + .../unit_tests/semantic_layers/labels_test.py | 52 + .../unit_tests/semantic_layers/mapper_test.py | 2743 +++++++++++++++++ .../unit_tests/semantic_layers/models_test.py | 1267 ++++++++ .../semantic_layers/schemas_test.py | 296 ++ tests/unit_tests/sql_lab_test.py | 2 +- 141 files changed, 18851 insertions(+), 667 deletions(-) create mode 100644 superset-core/src/superset_core/semantic_layers/config.py create mode 100644 superset-core/src/superset_core/semantic_layers/daos.py create mode 100644 superset-core/src/superset_core/semantic_layers/decorators.py create mode 100644 superset-core/src/superset_core/semantic_layers/layer.py create mode 100644 superset-core/src/superset_core/semantic_layers/models.py create mode 100644 superset-core/src/superset_core/semantic_layers/types.py create mode 100644 superset-core/src/superset_core/semantic_layers/view.py create mode 100644 superset-frontend/src/features/semanticLayers/SemanticLayerModal.test.tsx create mode 100644 superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx create mode 100644 superset-frontend/src/features/semanticLayers/jsonFormsHelpers.test.ts create mode 100644 superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx create mode 100644 superset-frontend/src/features/semanticLayers/label.ts create mode 100644 superset-frontend/src/features/semanticViews/AddSemanticViewModal.test.tsx create mode 100644 superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx create mode 100644 superset-frontend/src/features/semanticViews/SemanticViewEditModal.test.tsx create mode 100644 superset-frontend/src/features/semanticViews/SemanticViewEditModal.tsx create mode 100644 superset/commands/datasource/__init__.py create mode 100644 superset/commands/datasource/list.py create mode 100644 superset/commands/semantic_layer/__init__.py create mode 100644 superset/commands/semantic_layer/create.py create mode 100644 superset/commands/semantic_layer/delete.py create mode 100644 superset/commands/semantic_layer/exceptions.py create mode 100644 superset/commands/semantic_layer/update.py create mode 100644 superset/daos/semantic_layer.py create mode 100644 superset/datasource/schemas.py create mode 100644 superset/migrations/versions/2025-11-04_11-26_33d7e0e21daa_add_semantic_layers_and_views.py create mode 100644 superset/semantic_layers/__init__.py create mode 100644 superset/semantic_layers/api.py create mode 100644 superset/semantic_layers/labels.py create mode 100644 superset/semantic_layers/mapper.py create mode 100644 superset/semantic_layers/models.py create mode 100644 superset/semantic_layers/registry.py create mode 100644 superset/semantic_layers/schemas.py create mode 100644 tests/unit_tests/commands/datasource/__init__.py create mode 100644 tests/unit_tests/commands/datasource/list_test.py create mode 100644 tests/unit_tests/commands/semantic_layer/__init__.py create mode 100644 tests/unit_tests/commands/semantic_layer/create_test.py create mode 100644 tests/unit_tests/commands/semantic_layer/delete_test.py create mode 100644 tests/unit_tests/commands/semantic_layer/exceptions_test.py create mode 100644 tests/unit_tests/commands/semantic_layer/update_test.py create mode 100644 tests/unit_tests/semantic_layers/api_test.py create mode 100644 tests/unit_tests/semantic_layers/dao_test.py create mode 100644 tests/unit_tests/semantic_layers/decorators_test.py create mode 100644 tests/unit_tests/semantic_layers/labels_test.py create mode 100644 tests/unit_tests/semantic_layers/mapper_test.py create mode 100644 tests/unit_tests/semantic_layers/models_test.py create mode 100644 tests/unit_tests/semantic_layers/schemas_test.py diff --git a/.github/workflows/superset-python-unittest.yml b/.github/workflows/superset-python-unittest.yml index 4065e81d86c..18077700bd2 100644 --- a/.github/workflows/superset-python-unittest.yml +++ b/.github/workflows/superset-python-unittest.yml @@ -54,6 +54,7 @@ jobs: SUPERSET_SECRET_KEY: not-a-secret run: | pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100 + pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100 - name: Upload code coverage uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5 with: diff --git a/UPDATING.md b/UPDATING.md index 27fc3428b9e..3d42f2b3d4e 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -46,6 +46,13 @@ The Deck.gl MapBox chart's **Opacity**, **Default longitude**, **Default latitud **To restore fit-to-data behavior:** Open the chart in Explore, clear the **Default longitude**, **Default latitude**, and **Zoom** fields in the Viewport section, and re-save the chart. +### Combined datasource list endpoint + +Added a new combined datasource list endpoint at `GET /api/v1/datasource/` to serve datasets and semantic views in one response. + +- The endpoint is available to users with at least one of `can_read` on `Dataset` or `SemanticView`. +- Semantic views are included only when the `SEMANTIC_LAYERS` feature flag is enabled. +- The endpoint enforces strict `order_column` validation and returns `400` for invalid sort columns. ### ClickHouse minimum driver version bump The minimum required version of `clickhouse-connect` has been raised to `>=0.13.0`. If you are using the ClickHouse connector, please upgrade your `clickhouse-connect` package. The `_mutate_label` workaround that appended hash suffixes to column aliases has also been removed, as it is no longer needed with modern versions of the driver. diff --git a/docker/pythonpath_dev/superset_config.py b/docker/pythonpath_dev/superset_config.py index 108305cf900..ce7f1998708 100644 --- a/docker/pythonpath_dev/superset_config.py +++ b/docker/pythonpath_dev/superset_config.py @@ -105,7 +105,13 @@ class CeleryConfig: CELERY_CONFIG = CeleryConfig -FEATURE_FLAGS = {"ALERT_REPORTS": True, "DATASET_FOLDERS": True} +FEATURE_FLAGS = { + "ALERT_REPORTS": True, + "DATASET_FOLDERS": True, + "ENABLE_EXTENSIONS": True, + "SEMANTIC_LAYERS": True, +} +EXTENSIONS_PATH = "/app/docker/extensions" ALERT_REPORTS_NOTIFICATION_DRY_RUN = True WEBDRIVER_BASEURL = f"http://superset_app{os.environ.get('SUPERSET_APP_ROOT', '/')}/" # When using docker compose baseurl should be http://superset_nginx{ENV{BASEPATH}}/ # noqa: E501 # The base URL for the email report hyperlinks. diff --git a/docs/developer_docs/extensions/contribution-types.md b/docs/developer_docs/extensions/contribution-types.md index 6fa8bf7f9a5..6e66aa8a67c 100644 --- a/docs/developer_docs/extensions/contribution-types.md +++ b/docs/developer_docs/extensions/contribution-types.md @@ -224,3 +224,52 @@ async def analysis_guide(ctx: Context) -> str: ``` See [MCP Integration](./mcp) for implementation details. + +### Semantic Layers + +Extensions can register custom semantic layer implementations that allow Superset to connect to external data modeling frameworks. Each semantic layer defines how to authenticate, discover semantic views (tables/metrics/dimensions), and execute queries against the external system. + +```python +from superset_core.semantic_layers.decorators import semantic_layer +from superset_core.semantic_layers.layer import SemanticLayer + +from my_extension.config import MyConfig +from my_extension.view import MySemanticView + + +@semantic_layer( + id="my_platform", + name="My Data Platform", + description="Connect to My Data Platform's semantic layer", +) +class MySemanticLayer(SemanticLayer[MyConfig, MySemanticView]): + configuration_class = MyConfig + + @classmethod + def from_configuration(cls, configuration: dict) -> "MySemanticLayer": + config = MyConfig.model_validate(configuration) + return cls(config) + + @classmethod + def get_configuration_schema(cls, configuration=None) -> dict: + return MyConfig.model_json_schema() + + @classmethod + def get_runtime_schema(cls, configuration=None, runtime_data=None) -> dict: + return {"type": "object", "properties": {}} + + def get_semantic_views(self, runtime_configuration: dict) -> set[MySemanticView]: + # Return available views from the external platform + ... + + def get_semantic_view(self, name: str, additional_configuration: dict) -> MySemanticView: + # Return a specific view by name + ... +``` + +**Note**: The `@semantic_layer` decorator automatically detects context and applies appropriate ID prefixing: + +- **Extension context**: ID prefixed as `extensions.{publisher}.{name}.{id}` +- **Host context**: Original ID used as-is + +The decorator registers the class in the semantic layers registry, making it available in the UI for users to create connections. The `configuration_class` should be a Pydantic model that defines the fields needed to connect (credentials, project, database, etc.). Superset uses the model's JSON schema to render the configuration form dynamically. diff --git a/docs/static/feature-flags.json b/docs/static/feature-flags.json index 516115ac0ae..0da6171b46f 100644 --- a/docs/static/feature-flags.json +++ b/docs/static/feature-flags.json @@ -81,6 +81,12 @@ "lifecycle": "development", "description": "Expand nested types in Presto into extra columns/arrays. Experimental, doesn't work with all nested types." }, + { + "name": "SEMANTIC_LAYERS", + "default": false, + "lifecycle": "development", + "description": "Enable semantic layers and show semantic views alongside datasets" + }, { "name": "TABLE_V2_TIME_COMPARISON_ENABLED", "default": false, diff --git a/pyproject.toml b/pyproject.toml index 970cfee3bc6..142fc4d4c1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -288,6 +288,7 @@ module = [ "superset.tags.filters", "superset.commands.security.update", "superset.commands.security.create", + "superset.semantic_layers.api", ] warn_unused_ignores = false diff --git a/superset-core/pyproject.toml b/superset-core/pyproject.toml index 3af044c90d5..a3ee4bcf45e 100644 --- a/superset-core/pyproject.toml +++ b/superset-core/pyproject.toml @@ -43,6 +43,8 @@ classifiers = [ ] dependencies = [ "flask-appbuilder>=5.0.2,<6", + "isodate>=0.7.0", + "pyarrow>=16.0.0", "pydantic>=2.8.0", "sqlalchemy>=1.4.0,<2.0", "sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris diff --git a/superset-core/src/superset_core/semantic_layers/config.py b/superset-core/src/superset_core/semantic_layers/config.py new file mode 100644 index 00000000000..7c9aa520b11 --- /dev/null +++ b/superset-core/src/superset_core/semantic_layers/config.py @@ -0,0 +1,73 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel + + +def build_configuration_schema( + config_class: type[BaseModel], + configuration: BaseModel | None = None, +) -> dict[str, Any]: + """ + Build a JSON schema from a Pydantic configuration class. + + Handles generic boilerplate that any semantic layer with dynamic fields needs: + + - Reorders properties to match model field order (Pydantic sorts alphabetically) + - When ``configuration`` is None, sets ``enum: []`` on all ``x-dynamic`` properties + so the frontend renders them as empty dropdowns + + Semantic layer implementations call this instead of + ``model_json_schema()`` directly, + then only need to add their own dynamic population logic. + """ + schema = config_class.model_json_schema() + + # Pydantic sorts properties alphabetically; restore model field order + field_order = [ + field.alias or name for name, field in config_class.model_fields.items() + ] + schema["properties"] = { + key: schema["properties"][key] + for key in field_order + if key in schema["properties"] + } + + if configuration is None: + for prop_schema in schema["properties"].values(): + if prop_schema.get("x-dynamic"): + prop_schema["enum"] = [] + + return schema + + +def check_dependencies( + prop_schema: dict[str, Any], + configuration: BaseModel, +) -> bool: + """ + Check whether a dynamic property's dependencies are satisfied. + + Reads the ``x-dependsOn`` list from the property schema and returns ``True`` + when every referenced attribute on ``configuration`` is truthy. + """ + dependencies = prop_schema.get("x-dependsOn", []) + return all(getattr(configuration, dep, None) for dep in dependencies) diff --git a/superset-core/src/superset_core/semantic_layers/daos.py b/superset-core/src/superset_core/semantic_layers/daos.py new file mode 100644 index 00000000000..4b55231d0b2 --- /dev/null +++ b/superset-core/src/superset_core/semantic_layers/daos.py @@ -0,0 +1,169 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +""" +Semantic layer DAO interfaces for superset-core. + +Provides abstract DAO classes for semantic layers and views that define the +interface contract. Host implementations replace these with concrete classes +backed by SQLAlchemy during initialization. + +Usage: + from superset_core.semantic_layers.daos import ( + AbstractSemanticLayerDAO, + AbstractSemanticViewDAO, + ) +""" + +from __future__ import annotations + +from abc import abstractmethod +from typing import Any, ClassVar + +from superset_core.common.daos import BaseDAO +from superset_core.semantic_layers.models import SemanticLayerModel, SemanticViewModel + + +class AbstractSemanticLayerDAO(BaseDAO[SemanticLayerModel]): + """ + Abstract DAO interface for SemanticLayer. + + Host implementations will replace this class during initialization + with a concrete DAO providing actual database access. + """ + + model_cls: ClassVar[type[Any] | None] = None + base_filter = None + id_column_name = "uuid" + uuid_column_name = "uuid" + + @classmethod + @abstractmethod + def validate_uniqueness(cls, name: str) -> bool: + """ + Validate that a semantic layer name is unique. + + :param name: Semantic layer name to validate + :return: True if the name is unique, False otherwise + """ + ... + + @classmethod + @abstractmethod + def validate_update_uniqueness(cls, layer_uuid: str, name: str) -> bool: + """ + Validate that a semantic layer name is unique for an update operation, + excluding the layer being updated. + + :param layer_uuid: UUID of the semantic layer being updated + :param name: New name to validate + :return: True if the name is unique, False otherwise + """ + ... + + @classmethod + @abstractmethod + def find_by_name(cls, name: str) -> SemanticLayerModel | None: + """ + Find a semantic layer by name. + + :param name: Semantic layer name + :return: SemanticLayerModel instance or None + """ + ... + + @classmethod + @abstractmethod + def get_semantic_views(cls, layer_uuid: str) -> list[SemanticViewModel]: + """ + Get all semantic views associated with a semantic layer. + + :param layer_uuid: UUID of the semantic layer + :return: List of SemanticViewModel instances + """ + ... + + +class AbstractSemanticViewDAO(BaseDAO[SemanticViewModel]): + """ + Abstract DAO interface for SemanticView. + + Host implementations will replace this class during initialization + with a concrete DAO providing actual database access. + """ + + model_cls: ClassVar[type[Any] | None] = None + base_filter = None + id_column_name = "id" + uuid_column_name = "uuid" + + @classmethod + @abstractmethod + def validate_uniqueness( + cls, + name: str, + layer_uuid: str, + configuration: dict[str, Any], + ) -> bool: + """ + Validate that a semantic view is unique within a semantic layer. + + Uniqueness is determined by the combination of name, layer UUID, and + configuration. + + :param name: View name + :param layer_uuid: UUID of the parent semantic layer + :param configuration: Configuration dict to compare + :return: True if unique, False otherwise + """ + ... + + @classmethod + @abstractmethod + def validate_update_uniqueness( + cls, + view_uuid: str, + name: str, + layer_uuid: str, + configuration: dict[str, Any], + ) -> bool: + """ + Validate that a semantic view is unique within a semantic layer for an + update operation, excluding the view being updated. + + :param view_uuid: UUID of the view being updated + :param name: New name to validate + :param layer_uuid: UUID of the parent semantic layer + :param configuration: Configuration dict to compare + :return: True if unique, False otherwise + """ + ... + + @classmethod + @abstractmethod + def find_by_name(cls, name: str, layer_uuid: str) -> SemanticViewModel | None: + """ + Find a semantic view by name within a semantic layer. + + :param name: View name + :param layer_uuid: UUID of the parent semantic layer + :return: SemanticViewModel instance or None + """ + ... + + +__all__ = ["AbstractSemanticLayerDAO", "AbstractSemanticViewDAO"] diff --git a/superset-core/src/superset_core/semantic_layers/decorators.py b/superset-core/src/superset_core/semantic_layers/decorators.py new file mode 100644 index 00000000000..50dd975702c --- /dev/null +++ b/superset-core/src/superset_core/semantic_layers/decorators.py @@ -0,0 +1,102 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +""" +Semantic layer registration decorator for Superset. + +This module provides a decorator interface to register semantic layer +implementations with the host application, enabling automatic discovery +by the extensions framework. + +Usage: + from superset_core.semantic_layers.decorators import semantic_layer + + @semantic_layer( + id="snowflake", + name="Snowflake Cortex", + description="Snowflake semantic layer via Cortex Analyst", + ) + class SnowflakeSemanticLayer(SemanticLayer[SnowflakeConfig, SnowflakeView]): + ... + + # Or with minimal arguments: + @semantic_layer(id="dbt", name="dbt Semantic Layer") + class DbtSemanticLayer(SemanticLayer[DbtConfig, DbtView]): + ... +""" + +from __future__ import annotations + +from typing import Callable, TypeVar + +# Type variable for decorated semantic layer classes +T = TypeVar("T") + + +def semantic_layer( + id: str, + name: str, + description: str | None = None, +) -> Callable[[T], T]: + """ + Decorator to register a semantic layer implementation. + + Automatically detects extension context and applies appropriate + namespacing to prevent ID conflicts between host and extension + semantic layers. + + Host implementations will replace this function during initialization + with a concrete implementation providing actual functionality. + + Args: + id: Unique semantic layer type identifier (e.g., "snowflake", + "dbt"). Used as the key in the semantic layers registry and + stored in the ``type`` column of the ``SemanticLayer`` model. + name: Human-readable display name (e.g., "Snowflake Cortex"). + Shown in the UI when listing available semantic layer types. + description: Optional description for documentation and UI + tooltips. + + Returns: + Decorated semantic layer class registered with the host + application. + + Raises: + NotImplementedError: If called before host implementation is + initialized. + + Example: + from superset_core.semantic_layers.decorators import semantic_layer + from superset_core.semantic_layers.layer import SemanticLayer + + @semantic_layer( + id="snowflake", + name="Snowflake Cortex", + description="Connect to Snowflake Cortex Analyst", + ) + class SnowflakeSemanticLayer( + SemanticLayer[SnowflakeConfig, SnowflakeView] + ): + ... + """ + raise NotImplementedError( + "Semantic layer decorator not initialized. " + "This decorator should be replaced during Superset startup." + ) + + +__all__ = ["semantic_layer"] diff --git a/superset-core/src/superset_core/semantic_layers/layer.py b/superset-core/src/superset_core/semantic_layers/layer.py new file mode 100644 index 00000000000..2dbffdec003 --- /dev/null +++ b/superset-core/src/superset_core/semantic_layers/layer.py @@ -0,0 +1,129 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Generic, TypeVar + +from pydantic import BaseModel +from superset_core.semantic_layers.view import SemanticView + +ConfigT = TypeVar("ConfigT", bound=BaseModel) +SemanticViewT = TypeVar("SemanticViewT", bound="SemanticView") + + +class SemanticLayer(ABC, Generic[ConfigT, SemanticViewT]): + """ + Abstract base class for semantic layers. + """ + + configuration_class: type[BaseModel] + + @classmethod + @abstractmethod + def from_configuration( + cls, + configuration: dict[str, Any], + ) -> SemanticLayer[ConfigT, SemanticViewT]: + """ + Create a semantic layer from its configuration. + """ + raise NotImplementedError( + "Semantic layers must implement the from_configuration method" + ) + + @classmethod + @abstractmethod + def get_configuration_schema( + cls, + configuration: ConfigT | None = None, + ) -> dict[str, Any]: + """ + Get the JSON schema for the configuration needed to add the semantic layer. + + A partial configuration `configuration` can be sent to improve the schema, + allowing for progressive validation and better UX. For example, a semantic + layer might require: + + - auth information + - a database + + If the user provides the auth information, a client can send the partial + configuration to this method, and the resulting JSON schema would include + the list of databases the user has access to, allowing a dropdown to be + populated. + + The Snowflake semantic layer has an example implementation of this method, where + database and schema names are populated based on the provided connection info. + """ + raise NotImplementedError( + "Semantic layers must implement the get_configuration_schema method" + ) + + @classmethod + @abstractmethod + def get_runtime_schema( + cls, + configuration: ConfigT, + runtime_data: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """ + Get the JSON schema for the runtime parameters needed to load semantic views. + + This returns the schema needed to connect to a semantic view given the + configuration for the semantic layer. For example, a semantic layer might + be configured by: + + - auth information + - an optional database + + If the user does not provide a database when creating the semantic layer, the + runtime schema would require the database name to be provided before loading any + semantic views. This allows users to create semantic layers that connect to a + specific database (or project, account, etc.), or that allow users to select it + at query time. + + The Snowflake semantic layer has an example implementation of this method, where + database and schema names are required if they were not provided in the initial + configuration. + """ + raise NotImplementedError( + "Semantic layers must implement the get_runtime_schema method" + ) + + @abstractmethod + def get_semantic_views( + self, + runtime_configuration: dict[str, Any], + ) -> set[SemanticViewT]: + """ + Get the semantic views available in the semantic layer. + + The runtime configuration can provide information like a given project or + schema, used to restrict the semantic views returned. + """ + + @abstractmethod + def get_semantic_view( + self, + name: str, + additional_configuration: dict[str, Any], + ) -> SemanticViewT: + """ + Get a specific semantic view by its name and additional configuration. + """ diff --git a/superset-core/src/superset_core/semantic_layers/models.py b/superset-core/src/superset_core/semantic_layers/models.py new file mode 100644 index 00000000000..e132a108499 --- /dev/null +++ b/superset-core/src/superset_core/semantic_layers/models.py @@ -0,0 +1,85 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +""" +Semantic layer model interfaces for superset-core. + +Provides abstract model classes for semantic layers and views that will be +replaced by the host implementation's concrete SQLAlchemy models during +initialization. + +Usage: + from superset_core.semantic_layers.models import ( + SemanticLayerModel, + SemanticViewModel, + ) +""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from superset_core.common.models import CoreModel + + +class SemanticLayerModel(CoreModel): + """ + Abstract interface for the SemanticLayer database model. + + Host implementations will replace this class during initialization + with a concrete SQLAlchemy model providing actual persistence. + """ + + __abstract__ = True + + # Type hints for expected column attributes + uuid: UUID + name: str + description: str | None + type: str + configuration: str + configuration_version: int + cache_timeout: int | None + created_on: datetime | None + changed_on: datetime | None + + +class SemanticViewModel(CoreModel): + """ + Abstract interface for the SemanticView database model. + + Host implementations will replace this class during initialization + with a concrete SQLAlchemy model providing actual persistence. + """ + + __abstract__ = True + + # Type hints for expected column attributes + id: int + uuid: UUID + name: str + description: str | None + configuration: str + configuration_version: int + cache_timeout: int | None + semantic_layer_uuid: UUID + created_on: datetime | None + changed_on: datetime | None + + +__all__ = ["SemanticLayerModel", "SemanticViewModel"] diff --git a/superset-core/src/superset_core/semantic_layers/types.py b/superset-core/src/superset_core/semantic_layers/types.py new file mode 100644 index 00000000000..1239c1303be --- /dev/null +++ b/superset-core/src/superset_core/semantic_layers/types.py @@ -0,0 +1,209 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +import enum +from dataclasses import dataclass +from datetime import date, datetime, time, timedelta + +import isodate +import pyarrow as pa + + +@dataclass(frozen=True) +class Grain: + """ + Represents a time grain (e.g., day, month, year). + + Attributes: + name: Human-readable name of the grain (e.g., "Second") + representation: ISO 8601 duration (e.g., "PT1S", "P1D", "P1M") + """ + + name: str + representation: str + + def __post_init__(self) -> None: + isodate.parse_duration(self.representation) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Grain): + return self.representation == other.representation + return NotImplemented + + def __hash__(self) -> int: + return hash(self.representation) + + +class Grains: + """Pre-defined common grains and factory for custom ones.""" + + SECOND = Grain("Second", "PT1S") + MINUTE = Grain("Minute", "PT1M") + HOUR = Grain("Hour", "PT1H") + DAY = Grain("Day", "P1D") + WEEK = Grain("Week", "P1W") + MONTH = Grain("Month", "P1M") + QUARTER = Grain("Quarter", "P3M") + YEAR = Grain("Year", "P1Y") + + _REGISTRY: dict[str, Grain] = { + "PT1S": SECOND, + "PT1M": MINUTE, + "PT1H": HOUR, + "P1D": DAY, + "P1W": WEEK, + "P1M": MONTH, + "P3M": QUARTER, + "P1Y": YEAR, + } + + @classmethod + def get(cls, representation: str, name: str | None = None) -> Grain: + """Return a pre-defined grain or create a custom one.""" + if grain := cls._REGISTRY.get(representation): + return grain + return Grain(name or representation, representation) + + +@dataclass(frozen=True) +class Dimension: + id: str + name: str + type: pa.DataType + + definition: str | None = None + description: str | None = None + grain: Grain | None = None + + +@dataclass(frozen=True) +class Metric: + id: str + name: str + type: pa.DataType + + definition: str + description: str | None = None + + +@dataclass(frozen=True) +class AdhocExpression: + id: str + definition: str + + +class Operator(str, enum.Enum): + EQUALS = "=" + NOT_EQUALS = "!=" + GREATER_THAN = ">" + LESS_THAN = "<" + GREATER_THAN_OR_EQUAL = ">=" + LESS_THAN_OR_EQUAL = "<=" + IN = "IN" + NOT_IN = "NOT IN" + LIKE = "LIKE" + NOT_LIKE = "NOT LIKE" + IS_NULL = "IS NULL" + IS_NOT_NULL = "IS NOT NULL" + ADHOC = "ADHOC" + + +FilterValues = str | int | float | bool | datetime | date | time | timedelta | None + + +class PredicateType(enum.Enum): + WHERE = "WHERE" + HAVING = "HAVING" + + +@dataclass(frozen=True, order=True) +class Filter: + type: PredicateType + column: Dimension | Metric | None + operator: Operator + value: FilterValues | frozenset[FilterValues] + + +class OrderDirection(enum.Enum): + ASC = "ASC" + DESC = "DESC" + + +OrderTuple = tuple[Metric | Dimension | AdhocExpression, OrderDirection] + + +@dataclass(frozen=True) +class GroupLimit: + """ + Limit query to top/bottom N combinations of specified dimensions. + + The `filters` parameter allows specifying separate filter constraints for the + group limit subquery. This is useful when you want to determine the top N groups + using different criteria (e.g., a different time range) than the main query. + + For example, you might want to find the top 10 products by sales over the last + 30 days, but then show daily sales for those products over the last 7 days. + """ + + dimensions: list[Dimension] + top: int + metric: Metric | None + direction: OrderDirection = OrderDirection.DESC + group_others: bool = False + filters: set[Filter] | None = None + + +@dataclass(frozen=True) +class SemanticRequest: + """ + Represents a request made to obtain semantic results. + + This could be a SQL query, an HTTP request, etc. + """ + + type: str + definition: str + + +@dataclass(frozen=True) +class SemanticResult: + """ + Represents the results of a semantic query. + + This includes any requests (SQL queries, HTTP requests) that were performed in order + to obtain the results, in order to help troubleshooting. + """ + + requests: list[SemanticRequest] + results: pa.Table + + +@dataclass(frozen=True) +class SemanticQuery: + """ + Represents a semantic query. + """ + + metrics: list[Metric] + dimensions: list[Dimension] + filters: set[Filter] | None = None + order: list[OrderTuple] | None = None + limit: int | None = None + offset: int | None = None + group_limit: GroupLimit | None = None diff --git a/superset-core/src/superset_core/semantic_layers/view.py b/superset-core/src/superset_core/semantic_layers/view.py new file mode 100644 index 00000000000..73ee35aee85 --- /dev/null +++ b/superset-core/src/superset_core/semantic_layers/view.py @@ -0,0 +1,113 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +import enum +from abc import ABC, abstractmethod + +from superset_core.semantic_layers.types import ( + Dimension, + Filter, + Metric, + SemanticQuery, + SemanticResult, +) + + +# TODO (betodealmeida): move to the extension JSON +class SemanticViewFeature(enum.Enum): + """ + Custom features supported by semantic layers. + """ + + ADHOC_EXPRESSIONS_IN_ORDERBY = "ADHOC_EXPRESSIONS_IN_ORDERBY" + GROUP_LIMIT = "GROUP_LIMIT" + GROUP_OTHERS = "GROUP_OTHERS" + + +class SemanticView(ABC): + """ + Abstract base class for semantic views. + """ + + features: frozenset[SemanticViewFeature] + + # Implementations must expose a display name for the view. + # Declared here as a type annotation (not abstract) so that existing + # implementations are not required to add a formal @abstractmethod. + name: str + + @abstractmethod + def uid(self) -> str: + """ + Returns a unique identifier for the semantic view. + """ + + @abstractmethod + def get_dimensions(self) -> set[Dimension]: + """ + Get the dimensions defined in the semantic view. + """ + + @abstractmethod + def get_metrics(self) -> set[Metric]: + """ + Get the metrics defined in the semantic view. + """ + + @abstractmethod + def get_values( + self, + dimension: Dimension, + filters: set[Filter] | None = None, + ) -> SemanticResult: + """ + Return distinct values for a dimension. + """ + + @abstractmethod + def get_table(self, query: SemanticQuery) -> SemanticResult: + """ + Execute a semantic query and return the results. + """ + + @abstractmethod + def get_row_count(self, query: SemanticQuery) -> SemanticResult: + """ + Execute a query and return the number of rows the result would have. + """ + + @abstractmethod + def get_compatible_metrics( + self, + selected_metrics: set[Metric], + selected_dimensions: set[Dimension], + ) -> set[Metric]: + """ + Return metrics compatible with the selected dimensions. + """ + + @abstractmethod + def get_compatible_dimensions( + self, + selected_metrics: set[Metric], + selected_dimensions: set[Dimension], + ) -> set[Dimension]: + """ + Return dimensions compatible with the selected metrics. + """ diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 8f8c052d3e5..f14e47afebf 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -28,8 +28,14 @@ "@emotion/cache": "^11.4.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", + "@fontsource/fira-code": "^5.2.7", "@fontsource/ibm-plex-mono": "^5.2.7", + "@fontsource/inter": "^5.2.8", "@googleapis/sheets": "^13.0.1", + "@great-expectations/jsonforms-antd-renderers": "^2.2.10", + "@jsonforms/core": "^3.7.0", + "@jsonforms/react": "^3.7.0", + "@jsonforms/vanilla-renderers": "^3.7.0", "@luma.gl/constants": "~9.2.5", "@luma.gl/core": "~9.2.5", "@luma.gl/engine": "~9.2.5", @@ -37,6 +43,7 @@ "@luma.gl/shadertools": "~9.2.5", "@luma.gl/webgl": "~9.2.5", "@reduxjs/toolkit": "^1.9.3", + "@rjsf/antd": "^5.24.13", "@rjsf/core": "^5.24.13", "@rjsf/utils": "^5.24.3", "@rjsf/validator-ajv8": "^5.24.13", @@ -3913,6 +3920,15 @@ } } }, + "node_modules/@fontsource/fira-code": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource/fira-code/-/fira-code-5.2.7.tgz", + "integrity": "sha512-tnB9NNund9TwIym8/7DMJe573nlPEQb+fKUV5GL8TBYXjIhDvL0D7mgmNVNQUPhXp+R7RylQeiBdkA4EbOHPGQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource/ibm-plex-mono": { "version": "5.2.7", "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.7.tgz", @@ -3927,7 +3943,6 @@ "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz", "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==", "license": "OFL-1.1", - "peer": true, "funding": { "url": "https://github.com/sponsors/ayuhito" } @@ -3954,6 +3969,26 @@ "node": ">=12.0.0" } }, + "node_modules/@great-expectations/jsonforms-antd-renderers": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@great-expectations/jsonforms-antd-renderers/-/jsonforms-antd-renderers-2.3.5.tgz", + "integrity": "sha512-nWJQCX6zg2mQNk+QT5SFZUkaq2SNDRO5H7zoJmNvlndd0Byoq6AaB+UTdGt/SpO1knJFe80mmiWwh99fY/go3A==", + "license": "MIT", + "dependencies": { + "lodash.isempty": "^4.4.0", + "lodash.merge": "^4.6.2", + "lodash.range": "^3.2.0", + "lodash.startcase": "^4.4.0" + }, + "peerDependencies": { + "@ant-design/icons": "^5.3.0", + "@jsonforms/core": "^3.3.0", + "@jsonforms/react": "^3.3.0", + "antd": "^5.14.0", + "dayjs": "^1", + "react": "^17 || ^18" + } + }, "node_modules/@hapi/address": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", @@ -6324,6 +6359,45 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsonforms/core": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-3.7.0.tgz", + "integrity": "sha512-CE9viWtwi9QWLqlWLeOul1/R1GRAyOA9y6OoUpsCc0FhyR+g5p29F3k0fUExHWxL0Sf4KHcXYkfhtqfRBPS8ww==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.3", + "ajv": "^8.6.1", + "ajv-formats": "^2.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/@jsonforms/react": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@jsonforms/react/-/react-3.7.0.tgz", + "integrity": "sha512-HkY7qAx8vW97wPEgZ7GxCB3iiXG1c95GuObxtcDHGPBJWMwnxWBnVYJmv5h7nthrInKsQKHZL5OusnC/sj/1GQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "@jsonforms/core": "3.7.0", + "react": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@jsonforms/vanilla-renderers": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@jsonforms/vanilla-renderers/-/vanilla-renderers-3.7.0.tgz", + "integrity": "sha512-RdXQGsheARUJVbaTe6SqGw9W4/yrm0BgUok6OKUj8krp1NF4fqXc5UbYGHFksMR/p7LCuoYHCtQzKLXEfxJbDw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "@jsonforms/core": "3.7.0", + "@jsonforms/react": "3.7.0", + "react": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@jsonjoy.com/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", @@ -9494,6 +9568,89 @@ "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==", "license": "MIT" }, + "node_modules/@rjsf/antd": { + "version": "5.24.13", + "resolved": "https://registry.npmjs.org/@rjsf/antd/-/antd-5.24.13.tgz", + "integrity": "sha512-UiWE8xoBxxCoe/SEkdQEmL5E6z3I1pw0+y0dTyGt8SHfAxxFc4/OWn7tKOAiNsKCXgf83t0JKn6CHWLD01sAdQ==", + "license": "Apache-2.0", + "dependencies": { + "classnames": "^2.5.1", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "rc-picker": "2.7.6" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@ant-design/icons": "^4.0.0 || ^5.0.0", + "@rjsf/core": "^5.24.x", + "@rjsf/utils": "^5.24.x", + "antd": "^4.24.0 || ^5.8.5", + "dayjs": "^1.8.0", + "react": "^16.14.0 || >=17" + } + }, + "node_modules/@rjsf/antd/node_modules/rc-picker": { + "version": "2.7.6", + "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-2.7.6.tgz", + "integrity": "sha512-H9if/BUJUZBOhPfWcPeT15JUI3/ntrG9muzERrXDkSoWmDj4yzmBvumozpxYrHwjcKnjyDGAke68d+whWwvhHA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "date-fns": "2.x", + "dayjs": "1.x", + "moment": "^2.24.0", + "rc-trigger": "^5.0.4", + "rc-util": "^5.37.0", + "shallowequal": "^1.1.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rjsf/antd/node_modules/rc-picker/node_modules/rc-trigger": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-5.3.4.tgz", + "integrity": "sha512-mQv+vas0TwKcjAO2izNPkqR4j86OemLRmvL2nOzdP9OWNWA1ivoTt5hzFqYNW9zACwmTezRiN8bttrC7cZzYSw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.6", + "rc-align": "^4.0.0", + "rc-motion": "^2.0.0", + "rc-util": "^5.19.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rjsf/antd/node_modules/rc-picker/node_modules/rc-trigger/node_modules/rc-align": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/rc-align/-/rc-align-4.0.15.tgz", + "integrity": "sha512-wqJtVH60pka/nOX7/IspElA8gjPNQKIx/ZqJ6heATCkXpe1Zg4cPVrMD2vC96wjsFFL8WsmhPbx9tdMo1qqlIA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "dom-align": "^1.7.0", + "rc-util": "^5.26.0", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "node_modules/@rjsf/core": { "version": "5.24.13", "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-5.24.13.tgz", @@ -20795,6 +20952,22 @@ "topojson": "^1.6.19" } }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/dateformat": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.2.tgz", @@ -21402,6 +21575,12 @@ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "license": "MIT" }, + "node_modules/dom-align": { + "version": "1.12.4", + "resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz", + "integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==", + "license": "MIT" + }, "node_modules/dom-converter": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", @@ -33114,6 +33293,12 @@ "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", "license": "MIT" }, + "node_modules/lodash.isempty": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz", + "integrity": "sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==", + "license": "MIT" + }, "node_modules/lodash.isequal": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", @@ -33145,7 +33330,18 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, + "license": "MIT" + }, + "node_modules/lodash.range": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/lodash.range/-/lodash.range-3.2.0.tgz", + "integrity": "sha512-Fgkb7SinmuzqgIhNhAElo0BL/R1rHCnhwSZf78omqSwvWqD0kD2ssOAutQonDKH/ldS8BxA72ORYI09qAY9CYg==", + "license": "MIT" + }, + "node_modules/lodash.startcase": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", + "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", "license": "MIT" }, "node_modules/lodash.uniq": { @@ -36356,6 +36552,15 @@ "node": ">=0.10.0" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/monaco-editor": { "version": "0.52.2", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", @@ -43365,6 +43570,12 @@ "integrity": "sha512-b6i4ZpVuUxB9h5gfCxPiusKYkqTMOjEbBs4wMaFbkfia4yFv92UKZ6Df8WXcKbn08JNL/abvg3FnMAOfakDvUw==", "license": "MIT" }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT" + }, "node_modules/shapefile": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/shapefile/-/shapefile-0.3.1.tgz", @@ -50432,7 +50643,7 @@ "acorn": "^8.16.0", "d3-array": "^3.2.4", "lodash": "^4.18.1", - "zod": "^4.4.1" + "zod": "^4.4.3" }, "peerDependencies": { "@apache-superset/core": "*", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index e29796d314b..81a76450626 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -117,7 +117,14 @@ "@luma.gl/gltf": "~9.2.5", "@luma.gl/shadertools": "~9.2.5", "@luma.gl/webgl": "~9.2.5", + "@fontsource/fira-code": "^5.2.7", + "@fontsource/inter": "^5.2.8", + "@great-expectations/jsonforms-antd-renderers": "^2.2.10", + "@jsonforms/core": "^3.7.0", + "@jsonforms/react": "^3.7.0", + "@jsonforms/vanilla-renderers": "^3.7.0", "@reduxjs/toolkit": "^1.9.3", + "@rjsf/antd": "^5.24.13", "@rjsf/core": "^5.24.13", "@rjsf/utils": "^5.24.3", "@rjsf/validator-ajv8": "^5.24.13", diff --git a/superset-frontend/packages/superset-ui-core/src/components/Label/reusable/DatasetTypeLabel.tsx b/superset-frontend/packages/superset-ui-core/src/components/Label/reusable/DatasetTypeLabel.tsx index cab54c35e8a..3eb02f57139 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Label/reusable/DatasetTypeLabel.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Label/reusable/DatasetTypeLabel.tsx @@ -23,7 +23,7 @@ import { Label } from '..'; // Define the prop types for DatasetTypeLabel interface DatasetTypeLabelProps { - datasetType: 'physical' | 'virtual'; // Accepts only 'physical' or 'virtual' + datasetType: 'physical' | 'virtual' | 'semantic_view'; } const SIZE = 's'; // Define the size as a constant @@ -32,6 +32,22 @@ export const DatasetTypeLabel: React.FC = ({ datasetType, }) => { const theme = useTheme(); + if (datasetType === 'semantic_view') { + return ( + + ); + } const isPhysical = datasetType === 'physical'; const label: string = isPhysical ? t('Physical') : t('Virtual'); const labelType = isPhysical ? 'primary' : 'default'; diff --git a/superset-frontend/packages/superset-ui-core/src/query/DatasourceKey.ts b/superset-frontend/packages/superset-ui-core/src/query/DatasourceKey.ts index 38a38e10b13..3b7ac256a35 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/DatasourceKey.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/DatasourceKey.ts @@ -19,6 +19,15 @@ import { DatasourceType } from './types/Datasource'; +const DATASOURCE_TYPE_MAP: Record = { + table: DatasourceType.Table, + query: DatasourceType.Query, + dataset: DatasourceType.Dataset, + sl_table: DatasourceType.SlTable, + saved_query: DatasourceType.SavedQuery, + semantic_view: DatasourceType.SemanticView, +}; + export default class DatasourceKey { readonly id: number; @@ -27,8 +36,7 @@ export default class DatasourceKey { constructor(key: string) { const [idStr, typeStr] = key.split('__'); this.id = parseInt(idStr, 10); - this.type = DatasourceType.Table; // default to SqlaTable model - this.type = typeStr === 'query' ? DatasourceType.Query : this.type; + this.type = DATASOURCE_TYPE_MAP[typeStr] ?? DatasourceType.Table; } public toString() { diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts index 47902cf07ae..1341c334e03 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts @@ -26,6 +26,7 @@ export enum DatasourceType { Dataset = 'dataset', SlTable = 'sl_table', SavedQuery = 'saved_query', + SemanticView = 'semantic_view', } export interface Currency { @@ -40,6 +41,13 @@ export interface Datasource { id: number; name: string; type: DatasourceType; + /** + * The parent resource that owns this datasource. + * For SQL-based datasets this is the database; for semantic views it is the + * semantic layer. Use this field instead of the legacy `database` field when + * you only need the display name. + */ + parent?: { name: string }; columns: Column[]; metrics: Metric[]; description?: string; diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts index 8f21d83738b..35c0a37d562 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts @@ -61,6 +61,7 @@ export enum FeatureFlag { ListviewsDefaultCardView = 'LISTVIEWS_DEFAULT_CARD_VIEW', Matrixify = 'MATRIXIFY', ScheduledQueries = 'SCHEDULED_QUERIES', + SemanticLayers = 'SEMANTIC_LAYERS', SqllabBackendPersistence = 'SQLLAB_BACKEND_PERSISTENCE', SqlValidatorsByEngine = 'SQL_VALIDATORS_BY_ENGINE', SshTunneling = 'SSH_TUNNELING', diff --git a/superset-frontend/packages/superset-ui-core/test/connection/SupersetClient.test.ts b/superset-frontend/packages/superset-ui-core/test/connection/SupersetClient.test.ts index 97b8f7907a6..85aadb3910e 100644 --- a/superset-frontend/packages/superset-ui-core/test/connection/SupersetClient.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/connection/SupersetClient.test.ts @@ -19,6 +19,7 @@ import fetchMock from 'fetch-mock'; import { SupersetClient, SupersetClientClass } from '@superset-ui/core'; +import type { SupersetClientInterface } from '@superset-ui/core'; import { LOGIN_GLOB } from './fixtures/constants'; beforeAll(() => fetchMock.mockGlobal()); @@ -31,6 +32,10 @@ describe('SupersetClient', () => { afterEach(() => SupersetClient.reset()); + const clientWithGetUrl = SupersetClient as SupersetClientInterface & { + getUrl: (...args: unknown[]) => string; + }; + test('exposes configure, init, get, post, postForm, delete, put, request, reset, getGuestToken, getCSRFToken, getUrl, isAuthenticated, and reAuthenticate methods', () => { expect(typeof SupersetClient.configure).toBe('function'); expect(typeof SupersetClient.init).toBe('function'); @@ -43,7 +48,7 @@ describe('SupersetClient', () => { expect(typeof SupersetClient.reset).toBe('function'); expect(typeof SupersetClient.getGuestToken).toBe('function'); expect(typeof SupersetClient.getCSRFToken).toBe('function'); - expect(typeof SupersetClient.getUrl).toBe('function'); + expect(typeof clientWithGetUrl.getUrl).toBe('function'); expect(typeof SupersetClient.isAuthenticated).toBe('function'); expect(typeof SupersetClient.reAuthenticate).toBe('function'); }); @@ -58,7 +63,7 @@ describe('SupersetClient', () => { expect(SupersetClient.request).toThrow(); expect(SupersetClient.getGuestToken).toThrow(); expect(SupersetClient.getCSRFToken).toThrow(); - expect(SupersetClient.getUrl).toThrow(); + expect(clientWithGetUrl.getUrl).toThrow(); expect(SupersetClient.isAuthenticated).toThrow(); expect(SupersetClient.reAuthenticate).toThrow(); expect(SupersetClient.configure).not.toThrow(); @@ -100,7 +105,7 @@ describe('SupersetClient', () => { const getUrlSpy = jest.spyOn(SupersetClientClass.prototype, 'getUrl'); SupersetClient.configure({ appRoot: '/app' }); - expect(SupersetClient.getUrl({ endpoint: '/some/path' })).toContain( + expect(clientWithGetUrl.getUrl({ endpoint: '/some/path' })).toContain( '/app/some/path', ); expect(getUrlSpy).toHaveBeenCalledTimes(1); diff --git a/superset-frontend/packages/superset-ui-core/test/query/types/Datasource.test.ts b/superset-frontend/packages/superset-ui-core/test/query/types/Datasource.test.ts index aa77b74349e..2ecc42c2279 100644 --- a/superset-frontend/packages/superset-ui-core/test/query/types/Datasource.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/query/types/Datasource.test.ts @@ -28,10 +28,11 @@ test('DEFAULT_METRICS', () => { }); test('DatasourceType', () => { - expect(Object.keys(DatasourceType).length).toBe(5); + expect(Object.keys(DatasourceType).length).toBe(6); expect(DatasourceType.Table).toBe('table'); expect(DatasourceType.Query).toBe('query'); expect(DatasourceType.Dataset).toBe('dataset'); expect(DatasourceType.SlTable).toBe('sl_table'); expect(DatasourceType.SavedQuery).toBe('saved_query'); + expect(DatasourceType.SemanticView).toBe('semantic_view'); }); diff --git a/superset-frontend/packages/superset-ui-core/test/time-format/TimeFormatter.test.ts b/superset-frontend/packages/superset-ui-core/test/time-format/TimeFormatter.test.ts index 4bfe534ed88..5a942d8e65d 100644 --- a/superset-frontend/packages/superset-ui-core/test/time-format/TimeFormatter.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/time-format/TimeFormatter.test.ts @@ -71,10 +71,16 @@ describe('TimeFormatter', () => { // PivotData.processRecord coerces values with String(), turning numeric // timestamps into strings. const timestamp = PREVIEW_TIME.getTime().toString(); - expect(formatter.format(timestamp)).toEqual('2017'); + expect(formatter.format(timestamp as unknown as number | Date)).toEqual( + '2017', + ); }); test('handles ISO-8601 string without misinterpreting it as a number', () => { - expect(formatter.format('2017-02-14T11:22:33.000Z')).toEqual('2017'); + expect( + formatter.format( + '2017-02-14T11:22:33.000Z' as unknown as number | Date, + ), + ).toEqual('2017'); }); test('otherwise returns formatted value', () => { expect(formatter.format(PREVIEW_TIME)).toEqual('2017'); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts index fad7944f6a5..0817e2896b2 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts @@ -1402,7 +1402,7 @@ test('getAxisType with forced categorical', () => { test('getAxisType treats numeric as category for bar charts', () => { expect( - getAxisType( + (getAxisType as (...args: unknown[]) => AxisType)( false, false, GenericDataType.Numeric, @@ -1410,7 +1410,7 @@ test('getAxisType treats numeric as category for bar charts', () => { ), ).toEqual(AxisType.Category); expect( - getAxisType( + (getAxisType as (...args: unknown[]) => AxisType)( false, false, GenericDataType.Numeric, diff --git a/superset-frontend/src/components/Chart/Chart.tsx b/superset-frontend/src/components/Chart/Chart.tsx index b695415572f..5f058aa6024 100644 --- a/superset-frontend/src/components/Chart/Chart.tsx +++ b/superset-frontend/src/components/Chart/Chart.tsx @@ -359,7 +359,9 @@ class Chart extends PureComponent { width, } = this.props; - const databaseName = datasource?.database?.name as string | undefined; + const databaseName = + datasource?.parent?.name ?? + (datasource?.database?.name as string | undefined); const isLoading = chartStatus === 'loading'; // Suppress spinner during auto-refresh to avoid visual flicker diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx index 6922669e479..9326b63b485 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx @@ -58,6 +58,7 @@ import { Dataset } from '../types'; import TableControls from './DrillDetailTableControls'; import { getDrillPayload } from './utils'; import { ResultsPage } from './types'; +import { datasetLabelLower } from 'src/features/semanticLayers/label'; const PAGE_SIZE = 50; @@ -373,7 +374,7 @@ export default function DrillDetailPane({ tableContent = ; } else if (resultsPage?.total === 0) { // Render empty state if no results are returned for page - const title = t('No rows were returned for this dataset'); + const title = t('No rows were returned for this %s', datasetLabelLower()); tableContent = ; } else { // Render table if at least one page has successfully loaded diff --git a/superset-frontend/src/components/DatabaseSelector/index.tsx b/superset-frontend/src/components/DatabaseSelector/index.tsx index b8f5aeb2377..3fe4f3b7c6a 100644 --- a/superset-frontend/src/components/DatabaseSelector/index.tsx +++ b/superset-frontend/src/components/DatabaseSelector/index.tsx @@ -52,6 +52,10 @@ import type { DatabaseObject, } from './types'; import { StyledFormLabel } from './styles'; +import { + databaseLabel, + databasesLabelLower, +} from 'src/features/semanticLayers/label'; const DatabaseSelectorWrapper = styled.div<{ horizontal?: boolean }>` ${({ theme, horizontal }) => @@ -433,7 +437,11 @@ export function DatabaseSelector({ function renderDatabaseSelect() { if (sqlLabMode) { return renderSelectRow( - t('Select database or type to search databases'), + t( + 'Select %s or type to search %s', + databaseLabel().toLowerCase(), + databasesLabelLower(), + ), null, null, { @@ -450,16 +458,24 @@ export function DatabaseSelector({ return (
{renderSelectRow( - t('Database'), + databaseLabel(), {}, onDatasourceSave: jest.fn(), onChange: () => {}, @@ -91,3 +92,36 @@ test('changes the datasource', async () => { expect(fetchMock.callHistory.calls(/api\/v1\/dataset\/7/)).toHaveLength(1), ); }); + +test('does not show success toast or close modal when datasource request fails', async () => { + const props = { + ...mockedProps, + addDangerToast: jest.fn(), + addSuccessToast: jest.fn(), + onHide: jest.fn(), + }; + (fetchMock.removeRoutes as any)(DATASOURCE_ENDPOINT); + (fetchMock.removeRoutes as any)(DATASOURCES_ENDPOINT); + (fetchMock.removeRoutes as any)(INFO_ENDPOINT); + fetchMock.get(DATASOURCES_ENDPOINT, { result: [mockDatasource['7__table']] }); + fetchMock.get(INFO_ENDPOINT, {}); + fetchMock.get(DATASOURCE_ENDPOINT, 500); + + const { findByTestId, getByRole } = setup(props); + const confirmLink = await findByTestId('datasource-link'); + fireEvent.click(confirmLink); + fireEvent.click(getByRole('button', { name: 'Proceed' })); + + await waitFor(() => { + expect(fetchMock.callHistory.calls(/api\/v1\/dataset\/7/)).toHaveLength(1); + }); + expect(props.addSuccessToast).not.toHaveBeenCalled(); + expect(props.onHide).not.toHaveBeenCalled(); + + (fetchMock.removeRoutes as any)(DATASOURCE_ENDPOINT); + (fetchMock.removeRoutes as any)(DATASOURCES_ENDPOINT); + (fetchMock.removeRoutes as any)(INFO_ENDPOINT); + fetchMock.get(DATASOURCES_ENDPOINT, { result: [mockDatasource['7__table']] }); + fetchMock.get(INFO_ENDPOINT, {}); + fetchMock.get(DATASOURCE_ENDPOINT, DATASOURCE_PAYLOAD); +}); diff --git a/superset-frontend/src/components/Datasource/ChangeDatasourceModal/index.tsx b/superset-frontend/src/components/Datasource/ChangeDatasourceModal/index.tsx index 3b8f3531039..28cfb307f1a 100644 --- a/superset-frontend/src/components/Datasource/ChangeDatasourceModal/index.tsx +++ b/superset-frontend/src/components/Datasource/ChangeDatasourceModal/index.tsx @@ -53,6 +53,7 @@ import { import withToasts from 'src/components/MessageToasts/withToasts'; import { InputRef } from 'antd'; import type { Datasource, ChangeDatasourceModalProps } from '../types'; +import { datasetLabelLower } from 'src/features/semanticLayers/label'; const CONFIRM_WARNING_MESSAGE = t( 'Warning! Changing the dataset may break the chart if the metadata does not exist.', @@ -109,7 +110,11 @@ const ChangeDatasourceModal: FunctionComponent = ({ const { state: { loading, resourceCollection, resourceCount }, fetchData, - } = useListViewResource('dataset', t('dataset'), addDangerToast); + } = useListViewResource( + 'dataset', + datasetLabelLower(), + addDangerToast, + ); const selectDatasource = useCallback((datasource: Datasource) => { setConfirmChange(true); @@ -166,28 +171,27 @@ const ChangeDatasourceModal: FunctionComponent = ({ setPageIndex(0); }; - const handleChangeConfirm = () => { - SupersetClient.get({ - endpoint: `/api/v1/dataset/${confirmedDataset?.id}`, - }) - .then(({ json }) => { - // eslint-disable-next-line no-param-reassign - json.result.type = 'table'; - onDatasourceSave(json.result); - onChange(`${confirmedDataset?.id}__table`); - }) - .catch(response => { - getClientErrorObject(response).then( - ({ error, message }: { error: any; message: string }) => { - const errorMessage = error - ? error.error || error.statusText || error - : message; - addDangerToast(errorMessage); - }, - ); + const handleChangeConfirm = async () => { + try { + const { json } = await SupersetClient.get({ + endpoint: `/api/v1/dataset/${confirmedDataset?.id}`, }); - onHide(); - addSuccessToast(t('Successfully changed dataset!')); + // eslint-disable-next-line no-param-reassign + json.result.type = 'table'; + onDatasourceSave(json.result); + onChange(`${confirmedDataset?.id}__table`); + onHide(); + addSuccessToast(t('Successfully changed %s!', datasetLabelLower())); + } catch (response) { + getClientErrorObject(response).then( + ({ error, message }: { error: any; message: string }) => { + const errorMessage = error + ? error.error || error.statusText || error + : message; + addDangerToast(errorMessage); + }, + ); + } }; const handlerCancelConfirm = () => { @@ -253,7 +257,7 @@ const ChangeDatasourceModal: FunctionComponent = ({ onHide={onHide} responsive name="Swap dataset" - title={t('Swap dataset')} + title={t('Swap %s', datasetLabelLower())} width={confirmChange ? '432px' : ''} height={confirmChange ? 'auto' : '540px'} hideFooter={!confirmChange} diff --git a/superset-frontend/src/components/ErrorMessage/DatasetNotFoundErrorMessage.tsx b/superset-frontend/src/components/ErrorMessage/DatasetNotFoundErrorMessage.tsx index e74da4d0649..4333b2ce2c2 100644 --- a/superset-frontend/src/components/ErrorMessage/DatasetNotFoundErrorMessage.tsx +++ b/superset-frontend/src/components/ErrorMessage/DatasetNotFoundErrorMessage.tsx @@ -20,6 +20,7 @@ import { t } from '@apache-superset/core/translation'; import type { ErrorMessageComponentProps } from './types'; import { ErrorAlert } from './ErrorAlert'; +import { datasetLabelLower } from 'src/features/semanticLayers/label'; export function DatasetNotFoundErrorMessage({ error, @@ -29,7 +30,7 @@ export function DatasetNotFoundErrorMessage({ const { level, message } = error; return ( { + const index = filters.findIndex(f => f.id === id); + if (index >= 0) { + filterRefs[index]?.current?.clearFilter?.(); + } + }, })); return ( diff --git a/superset-frontend/src/components/ListView/ListView.tsx b/superset-frontend/src/components/ListView/ListView.tsx index 59a3309bbfa..9c1f313bd68 100644 --- a/superset-frontend/src/components/ListView/ListView.tsx +++ b/superset-frontend/src/components/ListView/ListView.tsx @@ -19,7 +19,14 @@ import { t } from '@apache-superset/core/translation'; import { Alert } from '@apache-superset/core/components'; import { styled } from '@apache-superset/core/theme'; -import { useCallback, useEffect, useRef, useState, ReactNode } from 'react'; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + ReactNode, +} from 'react'; import cx from 'classnames'; import TableCollection from '@superset-ui/core/components/TableCollection'; import BulkTagModal from 'src/features/tags/BulkTagModal'; @@ -265,6 +272,11 @@ export interface ListViewProps { columnsForWrapText?: string[]; enableBulkTag?: boolean; bulkTagResourceName?: string; + /** Optional ref exposed to callers for programmatic filter control. */ + filtersRef?: React.RefObject<{ + clearFilters: () => void; + clearFilterById: (id: string) => void; + }>; } export function ListView({ @@ -291,6 +303,7 @@ export function ListView({ columnsForWrapText, enableBulkTag = false, bulkTagResourceName, + filtersRef, addSuccessToast, addDangerToast, }: ListViewProps) { @@ -338,7 +351,21 @@ export function ListView({ }); } - const filterControlsRef = useRef<{ clearFilters: () => void }>(null); + const filterControlsRef = useRef<{ + clearFilters: () => void; + clearFilterById: (id: string) => void; + }>(null); + + // Wire the optional external filtersRef to our internal filterControlsRef. + // useLayoutEffect fires synchronously after DOM mutations, guaranteeing the + // ref is populated before the first paint and after every update. + useLayoutEffect(() => { + if (filtersRef) { + ( + filtersRef as React.MutableRefObject + ).current = filterControlsRef.current; + } + }); const handleClearFilterControls = useCallback(() => { if (query.filters) { diff --git a/superset-frontend/src/dashboard/components/AddSliceCard/AddSliceCard.tsx b/superset-frontend/src/dashboard/components/AddSliceCard/AddSliceCard.tsx index 56229cc2bc3..acb5463fdc2 100644 --- a/superset-frontend/src/dashboard/components/AddSliceCard/AddSliceCard.tsx +++ b/superset-frontend/src/dashboard/components/AddSliceCard/AddSliceCard.tsx @@ -36,6 +36,7 @@ import { Tooltip, ImageLoader } from '@superset-ui/core/components'; import { GenericLink, usePluginContext } from 'src/components'; import { assetUrl } from 'src/utils/assetUrl'; import { Theme } from '@emotion/react'; +import { datasetLabel } from 'src/features/semanticLayers/label'; const FALLBACK_THUMBNAIL_URL = assetUrl( '/static/assets/images/chart-card-fallback.svg', @@ -283,7 +284,7 @@ const AddSliceCard: FC<{ > diff --git a/superset-frontend/src/dashboard/components/SliceAdder.tsx b/superset-frontend/src/dashboard/components/SliceAdder.tsx index 8ad34e3c065..936badc9d0b 100644 --- a/superset-frontend/src/dashboard/components/SliceAdder.tsx +++ b/superset-frontend/src/dashboard/components/SliceAdder.tsx @@ -55,6 +55,7 @@ import type { ConnectDragSource } from 'react-dnd'; import AddSliceCard from './AddSliceCard'; import AddSliceDragPreview from './dnd/AddSliceDragPreview'; import { DragDroppable } from './dnd/DragDroppable'; +import { datasetLabelLower } from 'src/features/semanticLayers/label'; export type SliceAdderProps = { theme: Theme; @@ -88,7 +89,7 @@ const KEYS_TO_FILTERS = ['slice_name', 'viz_type', 'datasource_name']; const KEYS_TO_SORT = { slice_name: t('name'), viz_type: t('viz type'), - datasource_name: t('dataset'), + datasource_name: datasetLabelLower(), changed_on: t('recent'), }; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx index b079f133af9..98cc75e53ea 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx @@ -51,6 +51,10 @@ import { addDangerToast } from 'src/components/MessageToasts/actions'; import { cachedSupersetGet } from 'src/utils/cachedSupersetGet'; import { dispatchChartCustomizationHoverAction } from './utils'; import { mergeExtraFormData } from '../../utils'; +import { + datasetLabel as getDatasetLabel, + datasetLabelLower, +} from 'src/features/semanticLayers/label'; interface ColumnApiResponse { column_name?: string; @@ -262,9 +266,9 @@ const GroupByFilterCardContent: FC<{ - {t('Dataset')} + {getDatasetLabel()} - {typeof datasetLabel === 'string' ? datasetLabel : 'Dataset'} + {typeof datasetLabel === 'string' ? datasetLabel : t('Dataset')} @@ -475,7 +479,13 @@ const GroupByFilterCard: FC = ({ } catch (error) { setColumnOptions([]); dispatch( - addDangerToast(t('Failed to load columns for dataset %s', datasetId)), + addDangerToast( + t( + 'Failed to load columns for %s %s', + datasetLabelLower(), + datasetId, + ), + ), ); } finally { setLoading(false); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.tsx index f48e6dc039f..d089e98a675 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.tsx @@ -30,6 +30,11 @@ import { Dataset, DatasetSelectLabel, } from 'src/features/datasets/DatasetSelectLabel'; +import { + datasetLabel, + datasetLabelLower, + datasetsLabelLower, +} from 'src/features/semanticLayers/label'; interface DatasetSelectProps { onChange: (value: { label: string | ReactNode; value: number }) => void; @@ -101,13 +106,13 @@ const DatasetSelect = ({ return ( ); }; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx index 575319f7516..3d28036785e 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx @@ -120,6 +120,7 @@ import { INPUT_WIDTH, } from './constants'; import DependencyList from './DependencyList'; +import { datasetLabel } from 'src/features/semanticLayers/label'; const FORM_ITEM_WIDTH = 260; @@ -325,6 +326,12 @@ const FiltersConfigForm = ( const filters = form.getFieldValue('filters'); const formValues = filters?.[filterId]; const formFilter = formValues || undoFormValues || defaultFormFilter; + const formFilterWithTimeGrains = formFilter as typeof formFilter & { + time_grains?: string[]; + }; + const filterToEditWithTimeGrains = filterToEdit as + | (Filter & { time_grains?: string[] }) + | undefined; const handleModifyFilter = useCallback(() => { if (onModifyFilter) { @@ -587,7 +594,8 @@ const FiltersConfigForm = ( !!filterToEdit?.time_range; const hasTimeGrainPreFilter = !!( - formFilter?.time_grains?.length || filterToEdit?.time_grains?.length + formFilterWithTimeGrains?.time_grains?.length || + filterToEditWithTimeGrains?.time_grains?.length ); const hasEnableSingleValue = @@ -1052,7 +1060,7 @@ const FiltersConfigForm = ( {t('Dataset')}} + label={{datasetLabel()}} initialValue={ datasetDetails ? { @@ -1072,7 +1080,10 @@ const FiltersConfigForm = ( rules={[ { required: !isRemoved, - message: t('Dataset is required'), + message: + datasetLabel() === t('Datasource') + ? t('Datasource is required') + : t('Dataset is required'), }, ]} {...getFiltersConfigModalTestId('datasource-input')} @@ -1098,7 +1109,7 @@ const FiltersConfigForm = ( ) : ( {t('Dataset')}} + label={{datasetLabel()}} > @@ -1322,7 +1333,7 @@ const FiltersConfigForm = ( 'time_grains', ]} initialValue={ - filterToEdit?.time_grains + filterToEditWithTimeGrains?.time_grains } {...getFiltersConfigModalTestId( 'time-grain-allowlist', diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/filterTransformer.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/filterTransformer.ts index b2d059cdcb3..575d6baebe9 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/filterTransformer.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/filterTransformer.ts @@ -113,7 +113,7 @@ function transformFormInput( excluded: [], }; - return { + const result: Filter & { time_grains?: string[] } = { id, type: NativeFilterType.NativeFilter, name: formInputs.name, @@ -127,14 +127,17 @@ function transformFormInput( adhoc_filters: formInputs.adhoc_filters, time_range: formInputs.time_range, granularity_sqla: formInputs.granularity_sqla, - time_grains: formInputs.time_grains?.length - ? formInputs.time_grains - : undefined, sortMetric: formInputs.sortMetric ?? null, requiredFirst: formInputs.requiredFirst ? Object.values(formInputs.requiredFirst).find(rf => rf) : undefined, }; + + if (formInputs.time_grains?.length) { + result.time_grains = formInputs.time_grains; + } + + return result; } function transformSavedFilter(id: string, filter: Filter): Filter { diff --git a/superset-frontend/src/explore/actions/exploreActions.test.ts b/superset-frontend/src/explore/actions/exploreActions.test.ts index 55c1d18daf6..6fb94468dfe 100644 --- a/superset-frontend/src/explore/actions/exploreActions.test.ts +++ b/superset-frontend/src/explore/actions/exploreActions.test.ts @@ -17,6 +17,7 @@ * under the License. */ import type { AnyAction } from 'redux'; +import { SupersetClient } from '@superset-ui/core'; import { defaultState } from 'src/explore/store'; import exploreReducer, { ExploreState, @@ -240,3 +241,107 @@ describe('reducers', () => { ); }); }); + +test('fetchCompatibility ignores stale async responses', async () => { + const dispatch = jest.fn(); + + let resolveFirst: (value: { + json: { + result: { + compatible_metrics: string[]; + compatible_dimensions: string[]; + }; + }; + }) => void; + let resolveSecond: (value: { + json: { + result: { + compatible_metrics: string[]; + compatible_dimensions: string[]; + }; + }; + }) => void; + + const firstPromise = new Promise<{ + json: { + result: { + compatible_metrics: string[]; + compatible_dimensions: string[]; + }; + }; + }>(resolve => { + resolveFirst = resolve; + }); + const secondPromise = new Promise<{ + json: { + result: { + compatible_metrics: string[]; + compatible_dimensions: string[]; + }; + }; + }>(resolve => { + resolveSecond = resolve; + }); + + const postSpy = jest.spyOn(SupersetClient, 'post'); + postSpy + .mockImplementationOnce(() => firstPromise as never) + .mockImplementationOnce(() => secondPromise as never); + + const firstThunk = actions.fetchCompatibility( + 'semantic_view', + 7, + ['m1'], + ['d1'], + )(dispatch as any); + const secondThunk = actions.fetchCompatibility( + 'semantic_view', + 7, + ['m2'], + ['d2'], + )(dispatch as any); + + resolveSecond!({ + json: { + result: { + compatible_metrics: ['m2'], + compatible_dimensions: ['d2'], + }, + }, + }); + await secondThunk; + + resolveFirst!({ + json: { + result: { + compatible_metrics: ['m1'], + compatible_dimensions: ['d1'], + }, + }, + }); + await firstThunk; + + const compatibilityActions = dispatch.mock.calls + .map(call => call[0]) + .filter((action: AnyAction) => action.type === actions.SET_COMPATIBILITY); + const successfulActions = compatibilityActions.filter( + (action: AnyAction) => action.compatibilityLoading === false, + ); + + expect(successfulActions).toContainEqual( + expect.objectContaining({ + compatibleMetrics: ['m2'], + compatibleDimensions: ['d2'], + compatibilityLoading: false, + }), + ); + expect(successfulActions).not.toContainEqual( + expect.objectContaining({ + compatibleMetrics: ['m1'], + compatibleDimensions: ['d1'], + compatibilityLoading: false, + }), + ); + + postSpy.mockRestore(); +}); diff --git a/superset-frontend/src/explore/actions/exploreActions.ts b/superset-frontend/src/explore/actions/exploreActions.ts index 1863acaebbb..f1c2dda2818 100644 --- a/superset-frontend/src/explore/actions/exploreActions.ts +++ b/superset-frontend/src/explore/actions/exploreActions.ts @@ -166,6 +166,90 @@ export function updateExploreChartState( }; } +export const SET_COMPATIBILITY = 'SET_COMPATIBILITY'; +export function setCompatibility(payload: { + compatibleMetrics: string[] | null; + compatibleDimensions: string[] | null; + compatibilityLoading: boolean; +}) { + return { type: SET_COMPATIBILITY, ...payload }; +} + +let compatibilityRequestSeq = 0; + +/** + * Fetch compatible metrics and dimensions for the current selection. + * + * Only fires for semantic views — SQL datasets always have full compatibility + * so we short-circuit to `null` (no filtering) for everything else. + * + * Covers both real-time selection changes (M3) and saved-chart loading (M4): + * call this thunk on mount as well as whenever the metric / dimension + * selection changes in Explore. + */ +export function fetchCompatibility( + datasourceType: string, + datasourceId: number, + selectedMetrics: string[], + selectedDimensions: string[], +) { + return async (dispatch: Dispatch) => { + compatibilityRequestSeq += 1; + const requestSeq = compatibilityRequestSeq; + + if (datasourceType !== 'semantic_view') { + dispatch( + setCompatibility({ + compatibleMetrics: null, + compatibleDimensions: null, + compatibilityLoading: false, + }), + ); + return; + } + + dispatch( + setCompatibility({ + compatibleMetrics: null, + compatibleDimensions: null, + compatibilityLoading: true, + }), + ); + + try { + const { json } = await SupersetClient.post({ + endpoint: `/api/v1/datasource/${datasourceType}/${datasourceId}/compatible`, + jsonPayload: { + selected_metrics: selectedMetrics, + selected_dimensions: selectedDimensions, + }, + }); + if (requestSeq !== compatibilityRequestSeq) { + return; + } + dispatch( + setCompatibility({ + compatibleMetrics: json.result.compatible_metrics, + compatibleDimensions: json.result.compatible_dimensions, + compatibilityLoading: false, + }), + ); + } catch { + // On error fall back to no filtering so the user is never blocked. + if (requestSeq !== compatibilityRequestSeq) { + return; + } + dispatch( + setCompatibility({ + compatibleMetrics: null, + compatibleDimensions: null, + compatibilityLoading: false, + }), + ); + } + }; +} + export const SET_STASH_FORM_DATA = 'SET_STASH_FORM_DATA'; export function setStashFormData( isHidden: boolean, @@ -208,6 +292,7 @@ export const exploreActions = { sliceUpdated, setForceQuery, syncDatasourceMetadata, + fetchCompatibility, }; export type ExploreActions = typeof exploreActions; diff --git a/superset-frontend/src/explore/actions/saveModalActions.ts b/superset-frontend/src/explore/actions/saveModalActions.ts index 7c1df6ec8b7..db8f6b901c6 100644 --- a/superset-frontend/src/explore/actions/saveModalActions.ts +++ b/superset-frontend/src/explore/actions/saveModalActions.ts @@ -151,11 +151,8 @@ export const getSlicePayload = async ( const [id, typeString] = formData.datasource.split('__'); datasourceId = parseInt(id, 10); - const formattedTypeString = - typeString.charAt(0).toUpperCase() + typeString.slice(1); - if (formattedTypeString in DatasourceType) { - datasourceType = - DatasourceType[formattedTypeString as keyof typeof DatasourceType]; + if (Object.values(DatasourceType).includes(typeString as DatasourceType)) { + datasourceType = typeString as DatasourceType; } } diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx index b7c4b4ae48b..ae901bc23ac 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx @@ -19,6 +19,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import { t } from '@apache-superset/core/translation'; import { ensureIsArray } from '@superset-ui/core'; +import { datasetLabelLower } from 'src/features/semanticLayers/label'; import { styled } from '@apache-superset/core/theme'; import { EmptyState, Loading } from '@superset-ui/core/components'; import { GenericDataType } from '@apache-superset/core/common'; @@ -160,7 +161,10 @@ export const SamplesPane = ({ } if (data.length === 0) { - const title = t('No samples were returned for this dataset'); + const title = t( + 'No samples were returned for this %s', + datasetLabelLower(), + ); return ; } diff --git a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/DatasourcePanelDragOption.test.tsx b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/DatasourcePanelDragOption.test.tsx index 12fd816b6db..050671c7b4e 100644 --- a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/DatasourcePanelDragOption.test.tsx +++ b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/DatasourcePanelDragOption.test.tsx @@ -26,7 +26,7 @@ test('should render', async () => { value={{ metric_name: 'test', uuid: '1' }} type={DndItemType.Metric} />, - { useDnd: true }, + { useDnd: true, useRedux: true, initialState: { explore: {} } }, ); expect( @@ -41,7 +41,7 @@ test('should have attribute draggable:true', async () => { value={{ metric_name: 'test', uuid: '1' }} type={DndItemType.Metric} />, - { useDnd: true }, + { useDnd: true, useRedux: true, initialState: { explore: {} } }, ); expect( diff --git a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/index.tsx b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/index.tsx index dad76da609d..d7968dcb3eb 100644 --- a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/index.tsx +++ b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/index.tsx @@ -16,8 +16,9 @@ * specific language governing permissions and limitations * under the License. */ -import { RefObject } from 'react'; +import { RefObject, useMemo } from 'react'; import { useDrag } from 'react-dnd'; +import { useSelector } from 'react-redux'; import { Metric } from '@superset-ui/core'; import { css, styled, useTheme } from '@apache-superset/core/theme'; import { ColumnMeta } from '@superset-ui/chart-controls'; @@ -27,6 +28,7 @@ import { StyledMetricOption, } from 'src/explore/components/optionRenderers'; import { Icons } from '@superset-ui/core/components/Icons'; +import { ExplorePageState } from 'src/explore/types'; import { DatasourcePanelDndItem } from '../types'; @@ -70,11 +72,38 @@ export default function DatasourcePanelDragOption( ) { const { labelRef, showTooltip, type, value } = props; const theme = useTheme(); + + // Read compatibility lists from Redux. + // `null` means no filtering is active (SQL datasets, or no selection yet). + const compatibleMetrics = useSelector< + ExplorePageState, + string[] | null | undefined + >(state => state.explore.compatibleMetrics); + const compatibleDimensions = useSelector< + ExplorePageState, + string[] | null | undefined + >(state => state.explore.compatibleDimensions); + + // An item is compatible when the list is null (no filter) or when its + // name explicitly appears in the list returned by the backend. + const isCompatible = useMemo(() => { + if (type === DndItemType.Metric) { + if (!compatibleMetrics) return true; + return compatibleMetrics.includes((value as Metric).metric_name); + } + if (type === DndItemType.Column) { + if (!compatibleDimensions) return true; + return compatibleDimensions.includes((value as ColumnMeta).column_name); + } + return true; + }, [type, value, compatibleMetrics, compatibleDimensions]); + const [{ isDragging }, drag] = useDrag({ item: { value: props.value, type: props.type, }, + canDrag: isCompatible, collect: monitor => ({ isDragging: monitor.isDragging(), }), @@ -87,7 +116,14 @@ export default function DatasourcePanelDragOption( }; return ( - + {type === DndItemType.Column ? ( ) : ( diff --git a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.test.tsx b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.test.tsx index 621b33a323f..37449902b5e 100644 --- a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.test.tsx +++ b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.test.tsx @@ -89,7 +89,7 @@ const setup = (data: DatasourcePanelItemProps['data'] = mockData) => ))} , - { useDnd: true }, + { useDnd: true, useRedux: true, initialState: { explore: {} } }, ); test('renders each item accordingly', () => { diff --git a/superset-frontend/src/explore/components/DatasourcePanel/index.tsx b/superset-frontend/src/explore/components/DatasourcePanel/index.tsx index 74718ae3751..3394791e618 100644 --- a/superset-frontend/src/explore/components/DatasourcePanel/index.tsx +++ b/superset-frontend/src/explore/components/DatasourcePanel/index.tsx @@ -122,7 +122,7 @@ const sortColumns = (slice: DatasourcePanelColumn[]) => if (col2?.is_dttm && !col1?.is_dttm) { return 1; } - return 0; + return (col1?.column_name ?? '').localeCompare(col2?.column_name ?? ''); }) .sort((a, b) => (b?.is_certified ?? 0) - (a?.is_certified ?? 0)); @@ -191,7 +191,9 @@ export default function DataSourcePanel({ const filteredMetrics = useMemo(() => { if (!searchKeyword) { - return allowedMetrics ?? []; + return [...(allowedMetrics ?? [])].sort((a, b) => + (a?.metric_name ?? '').localeCompare(b?.metric_name ?? ''), + ); } return matchSorter(allowedMetrics, searchKeyword, { keys: [ diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx index 9b38848d8e4..baa5b947314 100644 --- a/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx +++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx @@ -36,6 +36,7 @@ import { JsonObject, MatrixifyFormData, DatasourceType, + ensureIsArray, } from '@superset-ui/core'; import { ControlStateMapping, @@ -412,6 +413,48 @@ function ExploreViewContainer(props: ExploreViewContainerProps) { [originalTitle, theme?.brandAppName, theme?.brandLogoAlt], ); + // M3 + M4: fire compatibility check on mount and whenever the metric / + // dimension selection changes. Only semantic views use the endpoint; + // SQL datasets short-circuit to null inside fetchCompatibility. + const selectedMetrics = useMemo( + () => + ensureIsArray(props.form_data.metrics).filter( + (m): m is string => typeof m === 'string', + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [JSON.stringify(props.form_data.metrics)], + ); + const selectedDimensions = useMemo( + () => + [ + ...ensureIsArray(props.form_data.groupby), + ...ensureIsArray(props.form_data.columns), + ...(typeof props.form_data.x_axis === 'string' + ? [props.form_data.x_axis] + : []), + ].filter((d): d is string => typeof d === 'string'), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + JSON.stringify(props.form_data.groupby), + JSON.stringify(props.form_data.columns), + props.form_data.x_axis, + ], + ); + useEffect(() => { + props.actions.fetchCompatibility( + props.datasource.type, + props.datasource.id as number, + selectedMetrics, + selectedDimensions, + ); + // props.datasource.id covers the saved-chart-loading case (M4) + }, [ + props.datasource.id, + props.datasource.type, + selectedMetrics, + selectedDimensions, + ]); + const addHistory = useCallback( async ({ isReplace = false, diff --git a/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigConstants.test.tsx b/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigConstants.test.tsx index 8d09344a15d..3f8e40367bf 100644 --- a/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigConstants.test.tsx +++ b/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigConstants.test.tsx @@ -19,15 +19,19 @@ import { SHARED_COLUMN_CONFIG_PROPS } from './constants'; -const tokenSeparators = - SHARED_COLUMN_CONFIG_PROPS.d3NumberFormat.tokenSeparators; - test('should allow commas in D3 format inputs', () => { - expect(tokenSeparators).toBeDefined(); - expect(tokenSeparators).not.toContain(','); + const { options } = SHARED_COLUMN_CONFIG_PROPS.d3NumberFormat; + const labels = (options ?? []).map((option: { label: unknown }) => + String(option.label), + ); + expect(labels.some((label: string) => label.includes(','))).toBe(true); }); -test('should have correct default token separators', () => { - const expectedSeparators = ['\r\n', '\n', '\t', ';']; - expect(tokenSeparators).toEqual(expectedSeparators); +test('should use defaults from Select token separators', () => { + expect( + Object.prototype.hasOwnProperty.call( + SHARED_COLUMN_CONFIG_PROPS.d3NumberFormat, + 'tokenSeparators', + ), + ).toBe(false); }); diff --git a/superset-frontend/src/explore/components/controls/ColumnConfigControl/constants.tsx b/superset-frontend/src/explore/components/controls/ColumnConfigControl/constants.tsx index f4bf1e74411..7668cf328c2 100644 --- a/superset-frontend/src/explore/components/controls/ColumnConfigControl/constants.tsx +++ b/superset-frontend/src/explore/components/controls/ColumnConfigControl/constants.tsx @@ -58,8 +58,6 @@ const d3NumberFormat: ControlFormItemSpec<'Select'> = { creatable: true, minWidth: '14em', debounceDelay: 500, - // default value tokenSeparators in superset-frontend/packages/superset-ui-core/src/components/Select/constants.ts - tokenSeparators: ['\r\n', '\n', '\t', ';'], }; const d3TimeFormat: ControlFormItemSpec<'Select'> = { diff --git a/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx b/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx index 76136b8b483..5c22595bd63 100644 --- a/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx +++ b/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx @@ -40,11 +40,13 @@ import { DatasourceModal, ErrorAlert, } from 'src/components'; +import SemanticViewEditModal from 'src/features/semanticViews/SemanticViewEditModal'; import { Menu } from '@superset-ui/core/components/Menu'; import { Icons } from '@superset-ui/core/components/Icons'; import WarningIconWithTooltip from '@superset-ui/core/components/WarningIconWithTooltip'; import { URL_PARAMS } from 'src/constants'; import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils'; +import { datasetLabelLower } from 'src/features/semanticLayers/label'; import { userHasPermission, isUserAdmin, @@ -68,6 +70,7 @@ interface ExtendedDatasource extends Datasource { }>; extra?: string; health_check_message?: string; + cache_timeout?: number | null; database?: { id: number; database_name: string; @@ -375,7 +378,7 @@ class DatasourceControl extends PureComponent< const canAccessSqlLab = userHasPermission(user, 'SQL Lab', 'menu_access'); - const editText = t('Edit dataset'); + const editText = t('Edit %s', datasetLabelLower()); const requestedQuery = { datasourceKey: `${datasource.id}__${datasource.type}`, sql: datasource.sql, @@ -387,7 +390,9 @@ class DatasourceControl extends PureComponent< label: !allowEdit ? ( {editText} @@ -402,7 +407,7 @@ class DatasourceControl extends PureComponent< defaultDatasourceMenuItems.push({ key: CHANGE_DATASET, - label: t('Swap dataset'), + label: t('Swap %s', datasetLabelLower()), }); if (!isMissingDatasource && canAccessSqlLab) { @@ -481,7 +486,7 @@ class DatasourceControl extends PureComponent< queryDatasourceMenuItems.push({ key: SAVE_AS_DATASET, - label: {t('Save as dataset')}, + label: {t('Save as %s', datasetLabelLower())}, }); const queryDatasourceMenu = ( @@ -495,7 +500,7 @@ class DatasourceControl extends PureComponent< const titleText = isMissingDatasource && !datasource.name - ? t('Missing dataset') + ? t('Missing %s', datasetLabelLower()) : getDatasourceTitle(datasource); const tooltip = titleText; @@ -561,14 +566,15 @@ class DatasourceControl extends PureComponent< ) : (

{t( - 'The dataset linked to this chart may have been deleted.', + 'The %s linked to this chart may have been deleted.', + datasetLabelLower(), )}

@@ -578,7 +584,7 @@ class DatasourceControl extends PureComponent< this.handleMenuItemClick({ key: CHANGE_DATASET }) } > - {t('Swap dataset')} + {t('Swap %s', datasetLabelLower())}

@@ -587,14 +593,27 @@ class DatasourceControl extends PureComponent< )}
)} - {showEditDatasourceModal && ( - - )} + {showEditDatasourceModal && + (String(datasource.type) === 'semantic_view' ? ( + this.onDatasourceSave(datasource)} + semanticView={{ + id: datasource.id, + table_name: datasource.name, + description: datasource.description, + cache_timeout: datasource.cache_timeout, + }} + /> + ) : ( + + ))} {showChangeDatasourceModal && ( ( state => state.explore.datasource.type, ); + const compatibleDimensions = useSelector< + ExplorePageState, + string[] | null | undefined + >(state => state.explore.compatibleDimensions); const [initialLabel] = useState(label); const [initialAdhocColumn, initialCalculatedColumn, initialSimpleColumn] = getInitialColumnValues(editedColumn); @@ -167,21 +171,22 @@ const ColumnSelectPopover = ({ const sqlEditorRef = useRef(null); - const [calculatedColumns, simpleColumns] = useMemo( - () => - columns?.reduce( - (acc: [ColumnMeta[], ColumnMeta[]], column: ColumnMeta) => { - if (column.expression) { - acc[0].push(column); - } else { - acc[1].push(column); - } - return acc; - }, - [[], []], - ), - [columns], - ); + const [calculatedColumns, simpleColumns] = useMemo(() => { + const [calc, simple] = (columns ?? []).reduce( + (acc: [ColumnMeta[], ColumnMeta[]], column: ColumnMeta) => { + if (column.expression) { + acc[0].push(column); + } else { + acc[1].push(column); + } + return acc; + }, + [[], []], + ); + const alpha = (a: ColumnMeta, b: ColumnMeta) => + (a.column_name ?? '').localeCompare(b.column_name ?? ''); + return [calc.sort(alpha), simple.sort(alpha)]; + }, [columns]); // Filter metrics that are already selected in the chart const availableMetrics = useMemo(() => { @@ -551,6 +556,11 @@ const ColumnSelectPopover = ({ key: `column-${simpleColumn.column_name}`, column_name: simpleColumn.column_name, verbose_name: simpleColumn.verbose_name ?? '', + disabled: + compatibleDimensions != null && + !compatibleDimensions.includes( + simpleColumn.column_name, + ), })), ...availableMetrics.map(metric => ({ value: metric.metric_name, @@ -565,6 +575,9 @@ const ColumnSelectPopover = ({ key: `metric-${metric.metric_name}`, metric_name: metric.metric_name, verbose_name: metric.verbose_name ?? '', + disabled: + compatibleDimensions != null && + !compatibleDimensions.includes(metric.metric_name), })), ]} optionFilterProps={[ diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndAdhocFilterOption.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndAdhocFilterOption.tsx index 857b098003b..525b2350496 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndAdhocFilterOption.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndAdhocFilterOption.tsx @@ -23,6 +23,7 @@ import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilt import { OptionSortType } from 'src/explore/types'; import { useGetTimeRangeLabel } from 'src/explore/components/controls/FilterControl/utils'; import OptionWrapper from './OptionWrapper'; +import { datasetLabelLower } from 'src/features/semanticLayers/label'; export interface DndAdhocFilterOptionProps { adhocFilter: AdhocFilter; @@ -68,7 +69,10 @@ export default function DndAdhocFilterOption({ isExtra={adhocFilter.isExtra} datasourceWarningMessage={ adhocFilter.datasourceWarning - ? t('This filter might be incompatible with current dataset') + ? t( + 'This filter might be incompatible with current %s', + datasetLabelLower(), + ) : undefined } /> diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect.tsx index 126d699ddff..0c4fbe40d76 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect.tsx @@ -38,6 +38,7 @@ import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetr import MetricDefinitionValue from 'src/explore/components/controls/MetricControl/MetricDefinitionValue'; import ColumnSelectPopoverTrigger from './ColumnSelectPopoverTrigger'; import { DndControlProps } from './types'; +import { datasetLabelLower } from 'src/features/semanticLayers/label'; const AGGREGATED_DECK_GL_CHART_TYPES = [ 'deck_screengrid', @@ -129,6 +130,16 @@ function DndColumnMetricSelect(props: DndColumnMetricSelectProps) { formData, } = props; + // Semantic views do not support arbitrary SQL expressions as dimensions. + // Merge 'sqlExpression' into disabledTabs so the Custom SQL tab is hidden. + const effectiveDisabledTabs = useMemo( + () => + String(datasource?.type) === 'semantic_view' + ? new Set([...(disabledTabs ?? []), 'sqlExpression']) + : disabledTabs, + [datasource?.type, disabledTabs], + ); + const [newColumnPopoverVisible, setNewColumnPopoverVisible] = useState(false); const combinedOptionsMap = useMemo(() => { @@ -303,7 +314,7 @@ function DndColumnMetricSelect(props: DndColumnMetricSelectProps) { }} editedColumn={column} isTemporal={isTemporal} - disabledTabs={disabledTabs} + disabledTabs={effectiveDisabledTabs} > diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx index 6e474e552cf..1d46fd0020c 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx @@ -17,6 +17,7 @@ * under the License. */ import { useCallback, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; import { t } from '@apache-superset/core/translation'; import { AdhocColumn, QueryFormColumn, isAdhocColumn } from '@superset-ui/core'; import { tn } from '@apache-superset/core/translation'; @@ -27,8 +28,10 @@ import OptionWrapper from 'src/explore/components/controls/DndColumnSelectContro import { OptionSelector } from 'src/explore/components/controls/DndColumnSelectControl/utils'; import { DatasourcePanelDndItem } from 'src/explore/components/DatasourcePanel/types'; import { DndItemType } from 'src/explore/components/DndItemType'; +import { ExplorePageState } from 'src/explore/types'; import ColumnSelectPopoverTrigger from './ColumnSelectPopoverTrigger'; import { DndControlProps } from './types'; +import { datasetLabelLower } from 'src/features/semanticLayers/label'; export type DndColumnSelectProps = DndControlProps & { options: ColumnMeta[]; @@ -49,6 +52,19 @@ function DndColumnSelect(props: DndColumnSelectProps) { isTemporal, disabledTabs, } = props; + + // Semantic views do not support arbitrary SQL expressions as dimensions. + const datasourceType = useSelector( + state => state.explore.datasource?.type, + ); + const effectiveDisabledTabs = useMemo( + () => + datasourceType === 'semantic_view' + ? new Set([...(disabledTabs ?? []), 'sqlExpression']) + : disabledTabs, + [datasourceType, disabledTabs], + ); + const [newColumnPopoverVisible, setNewColumnPopoverVisible] = useState(false); const optionSelector = useMemo(() => { @@ -103,7 +119,10 @@ function DndColumnSelect(props: DndColumnSelectProps) { optionSelector.values.map((column, idx) => { const datasourceWarningMessage = isAdhocColumn(column) && column.datasourceWarning - ? t('This column might be incompatible with current dataset') + ? t( + 'This column might be incompatible with current %s', + datasetLabelLower(), + ) : undefined; const withCaret = isAdhocColumn(column) || !column.error_text; @@ -121,7 +140,7 @@ function DndColumnSelect(props: DndColumnSelectProps) { }} editedColumn={column} isTemporal={isTemporal} - disabledTabs={disabledTabs} + disabledTabs={effectiveDisabledTabs} >
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.test.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.test.tsx index db1c341ab23..59610ce4dc0 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.test.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.test.tsx @@ -69,7 +69,7 @@ const baseFormData = { }; const mockStore = configureStore([thunk]); -const store = mockStore({}); +const store = mockStore({ explore: {} }); function setup({ value = undefined, diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.test.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.test.tsx index 420937b9703..9a9adad165d 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.test.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.test.tsx @@ -69,14 +69,20 @@ const adhocMetricB = { }; test('renders with default props', () => { - render(, { useDnd: true }); + render(, { + useDnd: true, + useRedux: true, + }); expect( screen.getByText('Drop a column/metric here or click'), ).toBeInTheDocument(); }); test('renders with default props and multi = true', () => { - render(, { useDnd: true }); + render(, { + useDnd: true, + useRedux: true, + }); expect( screen.getByText('Drop columns/metrics here or click'), ).toBeInTheDocument(); @@ -86,6 +92,7 @@ test('render selected metrics correctly', () => { const metricValues = ['metric_a', 'metric_b', adhocMetricB]; render(, { useDnd: true, + useRedux: true, }); expect(screen.getByText('metric_a')).toBeVisible(); expect(screen.getByText('Metric B')).toBeVisible(); @@ -107,6 +114,7 @@ test('warn selected custom metric when metric gets removed from dataset', async />, { useDnd: true, + useRedux: true, }, ); @@ -159,6 +167,7 @@ test('warn selected custom metric when metric gets removed from dataset for sing />, { useDnd: true, + useRedux: true, }, ); @@ -217,6 +226,7 @@ test('remove selected adhoc metric when column gets removed from dataset', async />, { useDnd: true, + useRedux: true, }, ); @@ -259,6 +269,7 @@ test('update adhoc metric name when column label in dataset changes', () => { />, { useDnd: true, + useRedux: true, }, ); @@ -304,6 +315,7 @@ test('can drag metrics', async () => { const metricValues = ['metric_a', 'metric_b', adhocMetricB]; render(, { useDnd: true, + useRedux: true, }); expect(screen.getByText('metric_a')).toBeVisible(); @@ -341,6 +353,7 @@ test('cannot drop a duplicated item', () => { , { useDnd: true, + useRedux: true, }, ); @@ -374,6 +387,7 @@ test('can drop a saved metric when disallow_adhoc_metrics', () => { , { useDnd: true, + useRedux: true, }, ); @@ -415,6 +429,7 @@ test('cannot drop non-saved metrics when disallow_adhoc_metrics', () => { , { useDnd: true, + useRedux: true, }, ); @@ -463,6 +478,7 @@ test('title changes on custom SQL text change', async () => { />, { useDnd: true, + useRedux: true, }, ); diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx index 07fd183bdbb..988eb302da5 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx @@ -41,6 +41,7 @@ import { DndItemType } from 'src/explore/components/DndItemType'; import DndSelectLabel from 'src/explore/components/controls/DndColumnSelectControl/DndSelectLabel'; import { savedMetricType } from 'src/explore/components/controls/MetricControl/types'; import { AGGREGATES } from 'src/explore/constants'; +import { datasetLabelLower } from 'src/features/semanticLayers/label'; const EMPTY_OBJECT = {}; const DND_ACCEPTED_TYPES = [DndItemType.Column, DndItemType.Metric]; @@ -77,7 +78,10 @@ const coerceMetrics = ( ) { return { metric_name: metric, - error_text: t('This metric might be incompatible with current dataset'), + error_text: t( + 'This metric might be incompatible with current %s', + datasetLabelLower(), + ), uuid: nanoid(), }; } @@ -128,6 +132,26 @@ const DndMetricSelect = (props: any) => { return extra; }, [datasource?.extra]); + // Semantic views do not support arbitrary SQL expressions as metrics. + const disallowAdhocMetrics = + extra.disallow_adhoc_metrics || datasource?.type === 'semantic_view'; + + // AdhocMetricEditPopover reads `datasource.extra.disallow_adhoc_metrics` + // directly, so we need to inject the flag there too — not just in canDrop. + const datasourceForPopover = useMemo(() => { + if (!disallowAdhocMetrics || !datasource) return datasource; + let parsedExtra: Record = {}; + if (datasource.extra) { + try { + parsedExtra = JSON.parse(datasource.extra as string); + } catch {} // eslint-disable-line no-empty + } + return { + ...datasource, + extra: JSON.stringify({ ...parsedExtra, disallow_adhoc_metrics: true }), + }; + }, [disallowAdhocMetrics, datasource]); + const savedMetricSet = useMemo( () => new Set( @@ -184,7 +208,7 @@ const DndMetricSelect = (props: any) => { const canDrop = useCallback( (item: DatasourcePanelDndItem) => { if ( - extra.disallow_adhoc_metrics && + disallowAdhocMetrics && (item.type !== DndItemType.Metric || !savedMetricSet.has(item.value.metric_name)) ) { @@ -293,14 +317,17 @@ const DndMetricSelect = (props: any) => { columns={props.columns} savedMetrics={props.savedMetrics} savedMetricsOptions={getSavedMetricOptionsForMetric(index)} - datasource={props.datasource} + datasource={datasourceForPopover} onMoveLabel={moveLabel} onDropLabel={handleDropLabel} type={`${DndItemType.AdhocMetricOption}_${props.name}_${props.label}`} multi={multi} datasourceWarningMessage={ option instanceof AdhocMetric && option.datasourceWarning - ? t('This metric might be incompatible with current dataset') + ? t( + 'This metric might be incompatible with current %s', + datasetLabelLower(), + ) : undefined } /> @@ -399,7 +426,7 @@ const DndMetricSelect = (props: any) => { columns={props.columns} savedMetricsOptions={newSavedMetricOptions} savedMetric={EMPTY_OBJECT as savedMetricType} - datasource={props.datasource} + datasource={datasourceForPopover} isControlledComponent visible={newMetricPopoverVisible} togglePopover={togglePopover} diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.tsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.tsx index 97cb4bfb2f1..e8158462ea5 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.tsx +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.tsx @@ -415,21 +415,25 @@ export default class AdhocFilterEditPopover extends Component< ), }, - { - key: ExpressionTypes.Sql, - label: t('Custom SQL'), - children: ( - - - - ), - }, + ...(datasource?.type === 'semantic_view' + ? [] + : [ + { + key: ExpressionTypes.Sql, + label: t('Custom SQL'), + children: ( + + + + ), + }, + ]), ]} /> {hasDeckSlices && ( diff --git a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/AdhocMetricEditPopover.test.tsx b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/AdhocMetricEditPopover.test.tsx index a18346ffa0f..cf28e55e619 100644 --- a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/AdhocMetricEditPopover.test.tsx +++ b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/AdhocMetricEditPopover.test.tsx @@ -67,13 +67,19 @@ const createProps = () => ({ test('Should render', () => { const props = createProps(); - render(); + render(, { + useRedux: true, + initialState: { explore: {} }, + }); expect(screen.getByTestId('metrics-edit-popover')).toBeVisible(); }); test('Should render correct elements', () => { const props = createProps(); - render(); + render(, { + useRedux: true, + initialState: { explore: {} }, + }); expect(screen.getByRole('tablist')).toBeVisible(); expect(screen.getByRole('button', { name: 'Resize' })).toBeVisible(); expect(screen.getByRole('button', { name: 'Save' })).toBeVisible(); @@ -82,7 +88,10 @@ test('Should render correct elements', () => { test('Should render correct elements for SQL', () => { const props = createProps(); - render(); + render(, { + useRedux: true, + initialState: { explore: {} }, + }); expect(screen.getByRole('tab', { name: 'Custom SQL' })).toBeVisible(); expect(screen.getByRole('tab', { name: 'Simple' })).toBeVisible(); expect(screen.getByRole('tab', { name: 'Saved' })).toBeVisible(); @@ -94,7 +103,10 @@ test('Should render correct elements for allow ad-hoc metrics', () => { ...createProps(), datasource: { extra: '{"disallow_adhoc_metrics": false}' }, }; - render(); + render(, { + useRedux: true, + initialState: { explore: {} }, + }); expect(screen.getByRole('tab', { name: 'Custom SQL' })).toBeEnabled(); expect(screen.getByRole('tab', { name: 'Simple' })).toBeEnabled(); expect(screen.getByRole('tab', { name: 'Saved' })).toBeEnabled(); @@ -106,7 +118,10 @@ test('Should render correct elements for disallow ad-hoc metrics', () => { ...createProps(), datasource: { extra: '{"disallow_adhoc_metrics": true}' }, }; - render(); + render(, { + useRedux: true, + initialState: { explore: {} }, + }); expect(screen.getByRole('tab', { name: 'Custom SQL' })).toHaveAttribute( 'aria-disabled', 'true', @@ -121,7 +136,10 @@ test('Should render correct elements for disallow ad-hoc metrics', () => { test('Clicking on "Close" should call onClose', () => { const props = createProps(); - render(); + render(, { + useRedux: true, + initialState: { explore: {} }, + }); expect(props.onClose).toHaveBeenCalledTimes(0); userEvent.click(screen.getByRole('button', { name: 'Close' })); expect(props.onClose).toHaveBeenCalledTimes(1); @@ -129,7 +147,10 @@ test('Clicking on "Close" should call onClose', () => { test('Clicking on "Save" should call onChange and onClose', async () => { const props = createProps(); - render(); + render(, { + useRedux: true, + initialState: { explore: {} }, + }); expect(props.onChange).toHaveBeenCalledTimes(0); expect(props.onClose).toHaveBeenCalledTimes(0); userEvent.click( @@ -145,7 +166,10 @@ test('Clicking on "Save" should call onChange and onClose', async () => { test('Clicking on "Save" should not call onChange and onClose', () => { const props = createProps(); - render(); + render(, { + useRedux: true, + initialState: { explore: {} }, + }); expect(props.onChange).toHaveBeenCalledTimes(0); expect(props.onClose).toHaveBeenCalledTimes(0); userEvent.click(screen.getByRole('button', { name: 'Save' })); @@ -155,7 +179,10 @@ test('Clicking on "Save" should not call onChange and onClose', () => { test('Clicking on "Save" should call onChange and onClose for new metric', () => { const props = createProps(); - render(); + render(, { + useRedux: true, + initialState: { explore: {} }, + }); expect(props.onChange).toHaveBeenCalledTimes(0); expect(props.onClose).toHaveBeenCalledTimes(0); userEvent.click(screen.getByRole('button', { name: 'Save' })); @@ -165,7 +192,10 @@ test('Clicking on "Save" should call onChange and onClose for new metric', () => test('Clicking on "Save" should call onChange and onClose for new title', () => { const props = createProps(); - render(); + render(, { + useRedux: true, + initialState: { explore: {} }, + }); expect(props.onChange).toHaveBeenCalledTimes(0); expect(props.onClose).toHaveBeenCalledTimes(0); userEvent.click(screen.getByRole('button', { name: 'Save' })); @@ -178,7 +208,10 @@ test('Should switch to tab:Simple', () => { props.getCurrentTab.mockImplementation(tab => { props.adhocMetric.expressionType = tab; }); - render(); + render(, { + useRedux: true, + initialState: { explore: {} }, + }); expect(screen.getByRole('tabpanel', { name: 'Saved' })).toBeVisible(); expect( @@ -202,7 +235,10 @@ test('Should render "Simple" tab correctly', () => { props.getCurrentTab.mockImplementation(tab => { props.adhocMetric.expressionType = tab; }); - render(); + render(, { + useRedux: true, + initialState: { explore: {} }, + }); const tab = screen.getByRole('tab', { name: 'Simple' }).parentElement!; userEvent.click(tab); @@ -216,7 +252,10 @@ test('Should switch to tab:Custom SQL', () => { props.getCurrentTab.mockImplementation(tab => { props.adhocMetric.expressionType = tab; }); - render(); + render(, { + useRedux: true, + initialState: { explore: {} }, + }); expect(screen.getByRole('tabpanel', { name: 'Saved' })).toBeVisible(); expect( @@ -242,7 +281,10 @@ test('Should render "Custom SQL" tab correctly', async () => { props.getCurrentTab.mockImplementation(tab => { props.adhocMetric.expressionType = tab; }); - render(); + render(, { + useRedux: true, + initialState: { explore: {} }, + }); const tab = screen.getByRole('tab', { name: 'Custom SQL' }).parentElement!; userEvent.click(tab); @@ -286,7 +328,10 @@ test('Should filter saved metrics by metric_name and verbose_name', async () => }, ], }; - render(); + render(, { + useRedux: true, + initialState: { explore: {} }, + }); const combobox = screen.getByRole('combobox', { name: 'Select saved metrics', @@ -362,7 +407,10 @@ test('Should filter columns by column_name and verbose_name in Simple tab', asyn props.getCurrentTab.mockImplementation(tab => { props.adhocMetric.expressionType = tab; }); - render(); + render(, { + useRedux: true, + initialState: { explore: {} }, + }); const tab = screen.getByRole('tab', { name: 'Simple' }).parentElement!; userEvent.click(tab); diff --git a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.tsx b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.tsx index 031248df0a0..22fdeaa93ee 100644 --- a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.tsx +++ b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.tsx @@ -18,6 +18,7 @@ */ /* eslint-disable camelcase */ import { PureComponent, createRef } from 'react'; +import { useSelector } from 'react-redux'; import { isDefined, ensureIsArray, DatasourceType } from '@superset-ui/core'; import { t } from '@apache-superset/core/translation'; import type { editors } from '@apache-superset/core'; @@ -94,6 +95,8 @@ interface AdhocMetricEditPopoverProps { datasource?: DatasourceInfo; isNewMetric?: boolean; isLabelModified?: boolean; + /** Names of metrics the user may select; null means no filtering. */ + compatibleMetrics?: string[] | null; } interface AdhocMetricEditPopoverState { @@ -123,7 +126,7 @@ const StyledSelect = styled(Select)` export const SAVED_TAB_KEY = 'SAVED'; -export default class AdhocMetricEditPopover extends PureComponent< +class AdhocMetricEditPopover extends PureComponent< AdhocMetricEditPopoverProps, AdhocMetricEditPopoverState > { @@ -438,15 +441,24 @@ export default class AdhocMetricEditPopover extends PureComponent< ensureIsArray(savedMetricsOptions).length > 0 ? ( ({ + options={[...ensureIsArray(savedMetricsOptions)] + .sort((a, b) => + (a.metric_name ?? '').localeCompare( + b.metric_name ?? '', + ), + ) + .map(savedMetric => ({ value: savedMetric.metric_name, label: this.renderMetricOption(savedMetric), key: savedMetric.id, metric_name: savedMetric.metric_name, verbose_name: savedMetric.verbose_name ?? '', - }), - )} + disabled: + this.props.compatibleMetrics != null && + !this.props.compatibleMetrics.includes( + savedMetric.metric_name, + ), + }))} optionFilterProps={['metric_name', 'verbose_name']} {...savedSelectProps} /> @@ -596,3 +608,20 @@ export default class AdhocMetricEditPopover extends PureComponent< } // @ts-expect-error - defaultProps for backward compatibility AdhocMetricEditPopover.defaultProps = defaultProps; + +// --------------------------------------------------------------------------- +// Thin functional wrapper that injects compatibility data from Redux. +// AdhocMetricEditPopover is a class component and cannot use hooks directly. +// --------------------------------------------------------------------------- +function AdhocMetricEditPopoverWithRedux(props: AdhocMetricEditPopoverProps) { + const compatibleMetrics = useSelector( + (state: any) => + state.explore?.compatibleMetrics as string[] | null | undefined, + ); + return ( + + ); +} + +export { AdhocMetricEditPopover }; +export default AdhocMetricEditPopoverWithRedux; diff --git a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricOption.test.tsx b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricOption.test.tsx index 72a7c975b93..e947bee3953 100644 --- a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricOption.test.tsx +++ b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricOption.test.tsx @@ -61,7 +61,11 @@ function setup(overrides: Record = {}) { ...overrides, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any - return render(, { useDnd: true }); + return render(, { + useDnd: true, + useRedux: true, + initialState: { explore: {} }, + }); } test('renders an overlay trigger wrapper for the label', () => { diff --git a/superset-frontend/src/explore/components/controls/MetricControl/MetricsControl.test.tsx b/superset-frontend/src/explore/components/controls/MetricControl/MetricsControl.test.tsx index de8a2afb0cd..78db85a6cb4 100644 --- a/superset-frontend/src/explore/components/controls/MetricControl/MetricsControl.test.tsx +++ b/superset-frontend/src/explore/components/controls/MetricControl/MetricsControl.test.tsx @@ -62,7 +62,10 @@ function setup(overrides: Record = {}) { ...defaultProps, ...overrides, }; - const result = render(, { useDnd: true }); + const result = render(, { + useDnd: true, + useRedux: true, + }); return { onChange, ...result }; } @@ -166,7 +169,7 @@ test('does not remove custom SQL metric if savedMetrics changes', async () => { ]} datasource={undefined} />, - { useDnd: true }, + { useDnd: true, useRedux: true }, ); expect(screen.getByText('old label')).toBeInTheDocument(); diff --git a/superset-frontend/src/explore/controls.tsx b/superset-frontend/src/explore/controls.tsx index 91e91de6867..74caebe52f1 100644 --- a/superset-frontend/src/explore/controls.tsx +++ b/superset-frontend/src/explore/controls.tsx @@ -64,6 +64,7 @@ import { validateNonEmpty, } from '@superset-ui/core'; import { t } from '@apache-superset/core/translation'; +import { datasetLabel } from 'src/features/semanticLayers/label'; import { formatSelectOptions } from 'src/explore/exploreUtils'; import { TIME_FILTER_LABELS } from './constants'; import { StyledColumnOption } from './components/optionRenderers'; @@ -214,7 +215,7 @@ export const controls = { datasource: { type: 'DatasourceControl', - label: t('Dataset'), + label: datasetLabel(), default: null, description: null, mapStateToProps: ({ datasource }: ControlState) => ({ diff --git a/superset-frontend/src/explore/reducers/exploreReducer.ts b/superset-frontend/src/explore/reducers/exploreReducer.ts index faffbfac5fa..c7eaf4d699d 100644 --- a/superset-frontend/src/explore/reducers/exploreReducer.ts +++ b/superset-frontend/src/explore/reducers/exploreReducer.ts @@ -70,6 +70,9 @@ export interface ExploreState { metadata?: { owners?: string[] | null; }; + compatibleMetrics?: string[] | null; + compatibleDimensions?: string[] | null; + compatibilityLoading?: boolean; saveAction?: SaveActionType | null; chartStates?: Record; } @@ -178,6 +181,13 @@ interface UpdateExploreChartStateAction { lastModified: number; } +interface SetCompatibilityAction { + type: typeof actions.SET_COMPATIBILITY; + compatibleMetrics: string[] | null; + compatibleDimensions: string[] | null; + compatibilityLoading: boolean; +} + type ExploreAction = | DynamicPluginControlsReadyAction | ToggleFaveStarAction @@ -197,6 +207,7 @@ type ExploreAction = | SliceUpdatedAction | SetForceQueryAction | UpdateExploreChartStateAction + | SetCompatibilityAction | HydrateExplore; // Extended control state for dynamic form controls - uses Record for flexibility @@ -635,6 +646,15 @@ export default function exploreReducer( force: typedAction.force, }; }, + [actions.SET_COMPATIBILITY]() { + const typedAction = action as SetCompatibilityAction; + return { + ...state, + compatibleMetrics: typedAction.compatibleMetrics, + compatibleDimensions: typedAction.compatibleDimensions, + compatibilityLoading: typedAction.compatibilityLoading, + }; + }, [actions.UPDATE_EXPLORE_CHART_STATE]() { const typedAction = action as UpdateExploreChartStateAction; return { diff --git a/superset-frontend/src/explore/types.ts b/superset-frontend/src/explore/types.ts index d1420fbb3fd..72b5e239809 100644 --- a/superset-frontend/src/explore/types.ts +++ b/superset-frontend/src/explore/types.ts @@ -71,6 +71,8 @@ export type OptionSortType = Partial< export type Datasource = Dataset & { database?: DatabaseObject; + /** The parent resource that owns this datasource (database or semantic layer). */ + parent?: { name: string }; datasource?: string; catalog?: string | null; schema?: string; @@ -131,6 +133,9 @@ export interface ExplorePageState { standalone: boolean; force: boolean; common: JsonObject; + compatibleMetrics?: string[] | null; + compatibleDimensions?: string[] | null; + compatibilityLoading?: boolean; }; sliceEntities?: JsonObject; // propagated from Dashboard view } diff --git a/superset-frontend/src/features/home/Menu.tsx b/superset-frontend/src/features/home/Menu.tsx index 1f9e99f0e94..06d606cc6a4 100644 --- a/superset-frontend/src/features/home/Menu.tsx +++ b/superset-frontend/src/features/home/Menu.tsx @@ -35,6 +35,7 @@ import { MenuObjectProps, MenuData, } from 'src/types/bootstrapTypes'; +import { datasetsLabel } from 'src/features/semanticLayers/label'; import RightMenu from './RightMenu'; import { NAVBAR_MENU_POPUP_OFFSET } from './commonMenuData'; @@ -223,7 +224,7 @@ export function Menu({ setActiveTabs(['Charts']); break; case path.startsWith(Paths.Datasets): - setActiveTabs(['Datasets']); + setActiveTabs([datasetsLabel()]); break; case path.startsWith(Paths.SqlLab) || path.startsWith(Paths.SavedQueries): setActiveTabs(['SQL']); @@ -408,6 +409,12 @@ export default function MenuWrapper({ data, ...rest }: MenuProps) { Manage: true, }; + // Remap labels that depend on feature flags so they stay in sync with + // the active-tab key used in the Menu component above. + const labelOverrides: Record string> = { + Datasets: datasetsLabel, + }; + // Cycle through menu.menu to build out cleanedMenu and settings const cleanedMenu: MenuObjectProps[] = []; const settings: MenuObjectProps[] = []; @@ -419,6 +426,10 @@ export default function MenuWrapper({ data, ...rest }: MenuProps) { const children: (MenuObjectProps | string)[] = []; const newItem = { ...item, + // Apply any label override for this item (keyed by FAB internal name). + ...(item.name && labelOverrides[item.name] + ? { label: labelOverrides[item.name]() } + : {}), }; // Filter childs diff --git a/superset-frontend/src/features/home/SubMenu.tsx b/superset-frontend/src/features/home/SubMenu.tsx index 3b9375b050b..9c2e2c451eb 100644 --- a/superset-frontend/src/features/home/SubMenu.tsx +++ b/superset-frontend/src/features/home/SubMenu.tsx @@ -149,6 +149,7 @@ export interface ButtonProps { buttonStyle: 'primary' | 'secondary' | 'dashed' | 'link' | 'tertiary'; loading?: boolean; icon?: ReactNode; + component?: ReactNode; } export interface SubMenuProps { @@ -312,18 +313,22 @@ const SubMenuComponent: FunctionComponent = props => { ), }))} /> - {props.buttons?.map((btn, i) => ( - - ))} + {props.buttons?.map((btn, i) => + btn.component ? ( + {btn.component} + ) : ( + + ), + )}
{props.children} diff --git a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.test.tsx b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.test.tsx new file mode 100644 index 00000000000..f664c30fffc --- /dev/null +++ b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.test.tsx @@ -0,0 +1,130 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { SupersetClient } from '@superset-ui/core'; +import { render, waitFor } from 'spec/helpers/testing-library'; + +import SemanticLayerModal from './SemanticLayerModal'; + +let mockJsonFormsChangeTriggered = false; + +jest.mock('@jsonforms/react', () => ({ + ...jest.requireActual('@jsonforms/react'), + JsonForms: ({ onChange }: { onChange: (value: unknown) => void }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + if (!mockJsonFormsChangeTriggered) { + mockJsonFormsChangeTriggered = true; + onChange({ + data: { warehouse: 'wh1' }, + errors: [], + }); + } + return null; + }, +})); + +jest.mock('@superset-ui/core', () => ({ + ...jest.requireActual('@superset-ui/core'), + SupersetClient: { + ...jest.requireActual('@superset-ui/core').SupersetClient, + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + }, + getClientErrorObject: jest.fn(() => Promise.resolve({ error: '' })), +})); + +const mockedGet = SupersetClient.get as jest.Mock; +const mockedPost = SupersetClient.post as jest.Mock; + +const props = { + show: true, + onHide: jest.fn(), + addDangerToast: jest.fn(), + addSuccessToast: jest.fn(), + semanticLayerUuid: '11111111-1111-1111-1111-111111111111', +}; + +beforeEach(() => { + mockJsonFormsChangeTriggered = false; + jest.useFakeTimers(); + mockedGet.mockReset(); + mockedPost.mockReset(); + + mockedGet + .mockResolvedValueOnce({ + json: { + result: [{ id: 'snowflake', name: 'Snowflake', description: '' }], + }, + }) + .mockResolvedValueOnce({ + json: { + result: { + name: 'Layer 1', + type: 'snowflake', + configuration: { warehouse: 'wh0' }, + }, + }, + }); + + mockedPost.mockResolvedValue({ + json: { + result: { + type: 'object', + properties: { + warehouse: { + type: 'string', + 'x-dynamic': true, + 'x-dependsOn': ['warehouse'], + }, + }, + }, + }, + }); +}); + +afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); +}); + +test('posts configuration schema refresh after debounce', async () => { + render(); + + await waitFor(() => { + expect(mockedPost).toHaveBeenNthCalledWith(1, { + endpoint: '/api/v1/semantic_layer/schema/configuration', + jsonPayload: { + type: 'snowflake', + configuration: { warehouse: 'wh0' }, + }, + }); + }); + + jest.advanceTimersByTime(501); + + await waitFor(() => { + expect(mockedPost).toHaveBeenNthCalledWith(2, { + endpoint: '/api/v1/semantic_layer/schema/configuration', + jsonPayload: { + type: 'snowflake', + configuration: { warehouse: 'wh1' }, + }, + }); + }); +}); diff --git a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx new file mode 100644 index 00000000000..0193f296488 --- /dev/null +++ b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx @@ -0,0 +1,408 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { t } from '@apache-superset/core/translation'; +import { SupersetClient, getClientErrorObject } from '@superset-ui/core'; +import { Input, Select, Button } from '@superset-ui/core/components'; +import { Icons } from '@superset-ui/core/components/Icons'; +import { JsonForms } from '@jsonforms/react'; +import type { JsonSchema, UISchemaElement } from '@jsonforms/core'; +import { cellRegistryEntries } from '@great-expectations/jsonforms-antd-renderers'; +import type { ErrorObject } from 'ajv'; +import { + StandardModal, + ModalFormField, + MODAL_STANDARD_WIDTH, + MODAL_MEDIUM_WIDTH, +} from 'src/components/Modal'; +import { styled } from '@apache-superset/core/theme'; +import { + renderers, + sanitizeSchema, + buildUiSchema, + getDynamicDependencies, + areDependenciesSatisfied, + serializeDependencyValues, + SCHEMA_REFRESH_DEBOUNCE_MS, +} from './jsonFormsHelpers'; + +const ModalContent = styled.div` + padding: ${({ theme }) => theme.sizeUnit * 4}px; +`; + +type Step = 'type' | 'config'; +type ValidationMode = 'ValidateAndHide' | 'ValidateAndShow'; + +interface SemanticLayerType { + id: string; + name: string; + description: string; +} + +interface SemanticLayerModalProps { + show: boolean; + onHide: () => void; + addDangerToast: (msg: string) => void; + addSuccessToast: (msg: string) => void; + semanticLayerUuid?: string; +} + +export default function SemanticLayerModal({ + show, + onHide, + addDangerToast, + addSuccessToast, + semanticLayerUuid, +}: SemanticLayerModalProps) { + const isEditMode = !!semanticLayerUuid; + const [step, setStep] = useState('type'); + const [name, setName] = useState(''); + const [selectedType, setSelectedType] = useState(null); + const [types, setTypes] = useState([]); + const [loading, setLoading] = useState(false); + const [configSchema, setConfigSchema] = useState(null); + const [uiSchema, setUiSchema] = useState( + undefined, + ); + const [formData, setFormData] = useState>({}); + const [saving, setSaving] = useState(false); + const [hasErrors, setHasErrors] = useState(true); + const [refreshingSchema, setRefreshingSchema] = useState(false); + const [validationMode, setValidationMode] = + useState('ValidateAndHide'); + const errorsRef = useRef([]); + const debounceTimerRef = useRef | null>(null); + const lastDepSnapshotRef = useRef(''); + const dynamicDepsRef = useRef>({}); + + const fetchTypes = useCallback(async () => { + setLoading(true); + try { + const { json } = await SupersetClient.get({ + endpoint: '/api/v1/semantic_layer/types', + }); + setTypes(json.result ?? []); + } catch (error) { + const clientError = await getClientErrorObject(error); + addDangerToast( + clientError.error || + t('An error occurred while fetching semantic layer types'), + ); + } finally { + setLoading(false); + } + }, [addDangerToast]); + + const applySchema = useCallback((rawSchema: JsonSchema) => { + const schema = sanitizeSchema(rawSchema); + setConfigSchema(schema); + setUiSchema(buildUiSchema(schema)); + dynamicDepsRef.current = getDynamicDependencies(rawSchema); + }, []); + + const fetchConfigSchema = useCallback( + async (type: string, configuration?: Record) => { + const isInitialFetch = !configuration; + if (isInitialFetch) setLoading(true); + else setRefreshingSchema(true); + try { + const { json } = await SupersetClient.post({ + endpoint: '/api/v1/semantic_layer/schema/configuration', + jsonPayload: { type, configuration }, + }); + applySchema(json.result); + if (json.warning) { + addDangerToast(String(json.warning)); + } + if (isInitialFetch) setStep('config'); + } catch (error) { + const clientError = await getClientErrorObject(error); + if (isInitialFetch) { + addDangerToast( + clientError.error || + t('An error occurred while fetching the configuration schema'), + ); + } else { + addDangerToast( + clientError.error || + t('An error occurred while refreshing the configuration schema'), + ); + } + } finally { + if (isInitialFetch) setLoading(false); + else setRefreshingSchema(false); + } + }, + [addDangerToast, applySchema], + ); + + const fetchExistingLayer = useCallback( + async (uuid: string) => { + setLoading(true); + try { + const { json } = await SupersetClient.get({ + endpoint: `/api/v1/semantic_layer/${uuid}`, + }); + const layer = json.result; + setName(layer.name ?? ''); + setSelectedType(layer.type); + setFormData(layer.configuration ?? {}); + setHasErrors(false); + // In edit mode, fetch the enriched schema using the full saved + // configuration so that dynamic dropdowns (account, project, + // environment) show their human-readable labels immediately rather + // than flashing raw IDs while the background refresh completes. + const { json: schemaJson } = await SupersetClient.post({ + endpoint: '/api/v1/semantic_layer/schema/configuration', + jsonPayload: { type: layer.type, configuration: layer.configuration }, + }); + applySchema(schemaJson.result); + setStep('config'); + } catch (error) { + const clientError = await getClientErrorObject(error); + addDangerToast( + clientError.error || + t('An error occurred while fetching the semantic layer'), + ); + } finally { + setLoading(false); + } + }, + [addDangerToast, applySchema], + ); + + useEffect(() => { + if (show) { + if (isEditMode && semanticLayerUuid) { + fetchTypes(); + fetchExistingLayer(semanticLayerUuid); + } else { + fetchTypes(); + } + } else { + setStep('type'); + setName(''); + setSelectedType(null); + setTypes([]); + setConfigSchema(null); + setUiSchema(undefined); + setFormData({}); + setHasErrors(true); + setRefreshingSchema(false); + setValidationMode('ValidateAndHide'); + errorsRef.current = []; + lastDepSnapshotRef.current = ''; + dynamicDepsRef.current = {}; + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + } + }, [show, fetchTypes, isEditMode, semanticLayerUuid, fetchExistingLayer]); + + const handleStepAdvance = () => { + if (selectedType) { + fetchConfigSchema(selectedType); + } + }; + + const handleBack = () => { + setStep('type'); + setConfigSchema(null); + setUiSchema(undefined); + setFormData({}); + setValidationMode('ValidateAndHide'); + errorsRef.current = []; + lastDepSnapshotRef.current = ''; + dynamicDepsRef.current = {}; + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + }; + + const handleCreate = async () => { + setSaving(true); + try { + if (isEditMode && semanticLayerUuid) { + await SupersetClient.put({ + endpoint: `/api/v1/semantic_layer/${semanticLayerUuid}`, + jsonPayload: { name, configuration: formData }, + }); + addSuccessToast(t('Semantic layer updated')); + } else { + await SupersetClient.post({ + endpoint: '/api/v1/semantic_layer/', + jsonPayload: { name, type: selectedType, configuration: formData }, + }); + addSuccessToast(t('Semantic layer created')); + } + onHide(); + } catch (error) { + const clientError = await getClientErrorObject(error); + addDangerToast( + clientError.error || + (isEditMode + ? t('An error occurred while updating the semantic layer') + : t('An error occurred while creating the semantic layer')), + ); + } finally { + setSaving(false); + } + }; + + const handleSave = () => { + if (step === 'type') { + handleStepAdvance(); + } else { + // Trigger validation UI and submit only from explicit save action. + setValidationMode('ValidateAndShow'); + if (errorsRef.current.length === 0) { + handleCreate(); + } + } + }; + + const maybeRefreshSchema = useCallback( + (data: Record) => { + if (!selectedType) return; + + const dynamicDeps = dynamicDepsRef.current; + if (Object.keys(dynamicDeps).length === 0) return; + + // Check if any dynamic field has all dependencies satisfied + const hasSatisfiedDeps = Object.values(dynamicDeps).some(deps => + areDependenciesSatisfied(deps, data, configSchema ?? undefined), + ); + if (!hasSatisfiedDeps) return; + + // Only re-fetch if dependency values actually changed + const snapshot = serializeDependencyValues(dynamicDeps, data); + if (snapshot === lastDepSnapshotRef.current) return; + lastDepSnapshotRef.current = snapshot; + + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = setTimeout(() => { + fetchConfigSchema(selectedType, data); + }, SCHEMA_REFRESH_DEBOUNCE_MS); + }, + [selectedType, fetchConfigSchema, configSchema], + ); + + const handleFormChange = useCallback( + ({ + data, + errors, + }: { + data: Record; + errors?: ErrorObject[]; + }) => { + setFormData(data); + errorsRef.current = errors ?? []; + setHasErrors(errorsRef.current.length > 0); + maybeRefreshSchema(data); + }, + [maybeRefreshSchema], + ); + + const selectedTypeName = + types.find(type => type.id === selectedType)?.name ?? ''; + + const title = isEditMode + ? t('Edit %s', selectedTypeName || t('Semantic Layer')) + : step === 'type' + ? t('New Semantic Layer') + : t('Configure %s', selectedTypeName); + + return ( + : } + width={step === 'type' ? MODAL_STANDARD_WIDTH : MODAL_MEDIUM_WIDTH} + saveDisabled={ + step === 'type' ? !selectedType : saving || !name.trim() || hasErrors + } + saveText={ + step === 'type' ? undefined : isEditMode ? t('Save') : t('Create') + } + saveLoading={saving} + contentLoading={loading} + > + + {step === 'type' ? ( + + setName(e.target.value)} + placeholder={t('Name of the semantic layer')} + /> + + {configSchema && ( + // Wrap in a form with autocomplete="off" so browsers do not + // autofill credential fields (service token, account, etc.). + // eslint-disable-next-line jsx-a11y/no-redundant-roles +
e.preventDefault()} + > + + + )} + + )} +
+
+ ); +} diff --git a/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.test.ts b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.test.ts new file mode 100644 index 00000000000..9f81bea4772 --- /dev/null +++ b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.test.ts @@ -0,0 +1,150 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import type { JsonSchema } from '@jsonforms/core'; + +import { + areDependenciesSatisfied, + sanitizeSchema, + buildUiSchema, + getDynamicDependencies, + serializeDependencyValues, +} from './jsonFormsHelpers'; + +test('areDependenciesSatisfied returns true for present dependency values', () => { + expect( + areDependenciesSatisfied(['database', 'schema'], { + database: 'examples', + schema: 'public', + }), + ).toBe(true); +}); + +test('areDependenciesSatisfied treats empty object dependencies as unsatisfied', () => { + expect( + areDependenciesSatisfied(['auth'], { + auth: {}, + }), + ).toBe(false); +}); + +test('areDependenciesSatisfied uses schema defaults for untouched fields', () => { + const schema: JsonSchema = { + type: 'object', + properties: { + database: { + type: 'string', + default: 'analytics', + }, + }, + }; + + expect(areDependenciesSatisfied(['database'], {}, schema)).toBe(true); +}); + +test('sanitizeSchema removes empty enums and preserves other properties', () => { + const schema: JsonSchema = { + type: 'object', + properties: { + environment: { + type: 'string', + enum: [], + }, + warehouse: { + type: 'string', + enum: ['xsmall', 'small'], + }, + }, + }; + + const sanitized = sanitizeSchema(schema); + const sanitizedProperties = + (sanitized.properties as Record) ?? {}; + + expect(sanitizedProperties.environment?.enum).toBeUndefined(); + expect(sanitizedProperties.warehouse?.enum).toEqual(['xsmall', 'small']); +}); + +test('buildUiSchema respects x-propertyOrder and includes placeholders/tooltips', () => { + const schema = { + type: 'object', + properties: { + database: { + type: 'string', + description: 'Target database', + examples: ['examples'], + }, + schema: { + type: 'string', + }, + }, + 'x-propertyOrder': ['schema', 'database'], + } as JsonSchema; + + const uiSchema = buildUiSchema(schema) as { + type: string; + elements: Array>; + }; + + expect(uiSchema.type).toBe('VerticalLayout'); + expect(uiSchema.elements[0].scope).toBe('#/properties/schema'); + expect(uiSchema.elements[1].scope).toBe('#/properties/database'); + expect(uiSchema.elements[1].options).toEqual({ + placeholderText: 'examples', + tooltip: 'Target database', + }); +}); + +test('getDynamicDependencies extracts x-dynamic dependency mapping', () => { + const schema = { + type: 'object', + properties: { + schema: { + type: 'string', + 'x-dynamic': true, + 'x-dependsOn': ['database'], + }, + database: { + type: 'string', + }, + warehouse: { + type: 'string', + 'x-dynamic': true, + }, + }, + } as JsonSchema; + + expect(getDynamicDependencies(schema)).toEqual({ schema: ['database'] }); +}); + +test('serializeDependencyValues is stable and sorted by key', () => { + const dynamicDeps = { + schema: ['database'], + role: ['warehouse', 'database'], + }; + + const data = { + warehouse: 'compute_wh', + database: 'analytics', + ignored: 'x', + }; + + expect(serializeDependencyValues(dynamicDeps, data)).toBe( + JSON.stringify({ database: 'analytics', warehouse: 'compute_wh' }), + ); +}); diff --git a/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx new file mode 100644 index 00000000000..85c7b891dbc --- /dev/null +++ b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx @@ -0,0 +1,386 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useEffect } from 'react'; +import { t } from '@apache-superset/core/translation'; +import { Spin, Select, Form } from 'antd'; +import { withJsonFormsControlProps } from '@jsonforms/react'; +import type { + JsonSchema, + UISchemaElement, + ControlProps, +} from '@jsonforms/core'; +import { + rankWith, + and, + isStringControl, + formatIs, + schemaMatches, +} from '@jsonforms/core'; +import { + rendererRegistryEntries, + TextControl, +} from '@great-expectations/jsonforms-antd-renderers'; + +export const SCHEMA_REFRESH_DEBOUNCE_MS = 500; + +/** + * Custom renderer that renders `Input.Password` for fields with + * `format: "password"` in the JSON Schema (e.g. Pydantic `SecretStr`). + */ +function PasswordControl(props: ControlProps) { + const uischema = { + ...props.uischema, + options: { + ...props.uischema.options, + type: 'password', + inputProps: { + ...((props.uischema.options?.inputProps as Record) ?? + {}), + // Prevent browsers from autofilling stored login passwords into + // service-token fields. 'new-password' is respected even when + // 'off' is ignored (Chrome ≥ 34). + autoComplete: 'new-password', + }, + }, + }; + return TextControl({ ...props, uischema }); +} +const PasswordRenderer = withJsonFormsControlProps(PasswordControl); +const passwordEntry = { + tester: rankWith(3, and(isStringControl, formatIs('password'))), + renderer: PasswordRenderer, +}; + +/** + * Renderer for `const` properties (e.g. Pydantic discriminator fields). + * Renders nothing visually but ensures the const value is set in form data, + * so discriminated unions resolve correctly on the backend. + */ +function ConstControl({ data, handleChange, path, schema }: ControlProps) { + const constValue = (schema as Record).const; + useEffect(() => { + if (constValue !== undefined && data !== constValue) { + handleChange(path, constValue); + } + }, [constValue, data, handleChange, path]); + return null; +} +const ConstRenderer = withJsonFormsControlProps(ConstControl); +const constEntry = { + tester: rankWith( + 10, + schemaMatches( + s => + s !== undefined && + 'const' in s && + !(s as Record).readOnly, + ), + ), + renderer: ConstRenderer, +}; + +/** + * Renderer for read-only fields (e.g. a fixed database that the admin locked). + * Renders a disabled input showing the current value. Also ensures the default + * value is injected into form data (like ConstControl does for hidden fields). + */ +function ReadOnlyControl({ + data, + handleChange, + path, + schema, + ...rest +}: ControlProps) { + const defaultValue = + (schema as Record).const ?? + (schema as Record).default; + useEffect(() => { + if (defaultValue !== undefined && data !== defaultValue) { + handleChange(path, defaultValue); + } + }, [defaultValue, data, handleChange, path]); + + return TextControl({ + ...rest, + data, + handleChange, + path, + schema, + enabled: false, + }); +} +const ReadOnlyRenderer = withJsonFormsControlProps(ReadOnlyControl); +const readOnlyEntry = { + tester: rankWith( + 11, + schemaMatches( + s => s !== undefined && (s as Record).readOnly === true, + ), + ), + renderer: ReadOnlyRenderer, +}; + +/** + * Checks whether all dependency values are filled (non-empty). + * Handles nested objects (like auth) by checking they have at least one key. + * + * Fields that have a `default` in the schema are considered satisfied even + * when the user has not explicitly touched them yet — JsonForms does not + * write default values into `data` until a field is interacted with, so + * without this fallback a field like `admin_host` (which ships with a + * sensible default) would permanently block the refresh. + */ +export function areDependenciesSatisfied( + dependencies: string[], + data: Record, + schema?: JsonSchema, +): boolean { + return dependencies.every(dep => { + const value = data[dep]; + if (value !== null && value !== undefined && value !== '') { + if (typeof value === 'object' && Object.keys(value).length === 0) + return false; + return true; + } + // Fall back to the schema default when the field hasn't been touched yet. + const defaultValue = schema?.properties?.[dep]?.default; + return ( + defaultValue !== null && defaultValue !== undefined && defaultValue !== '' + ); + }); +} + +/** + * Renderer for fields marked `x-dynamic` in the JSON Schema. + * Shows a loading spinner inside the input while the schema is being + * refreshed with dynamic values from the backend. + */ +function DynamicFieldControl(props: ControlProps) { + const { refreshingSchema, formData: cfgData } = props.config ?? {}; + const deps = (props.schema as Record)?.['x-dependsOn']; + const refreshing = + refreshingSchema && + Array.isArray(deps) && + areDependenciesSatisfied( + deps as string[], + (cfgData as Record) ?? {}, + props.rootSchema, + ); + + if (!refreshing) { + return TextControl(props); + } + + const uischema = { + ...props.uischema, + options: { + ...props.uischema.options, + placeholderText: t('Loading...'), + inputProps: { suffix: }, + }, + }; + return TextControl({ ...props, uischema, enabled: false }); +} +const DynamicFieldRenderer = withJsonFormsControlProps(DynamicFieldControl); +const dynamicFieldEntry = { + tester: rankWith( + 3, + and( + isStringControl, + schemaMatches( + s => (s as Record)?.['x-dynamic'] === true, + ), + ), + ), + renderer: DynamicFieldRenderer, +}; + +/** + * Renderer for fields that carry an ``x-enumNames`` array alongside their + * ``enum`` values. Renders as an Antd Select showing human-readable labels + * (from ``x-enumNames``) while storing the underlying enum values in form + * data. Used for MetricFlow's integer-ID fields (account, project, + * environment) where the backend provides both IDs and display names. + */ +function EnumNamesControl(props: ControlProps) { + const { refreshingSchema } = props.config ?? {}; + const schema = props.schema as Record; + const enumValues = (schema.enum as unknown[]) ?? []; + const enumNames = + (schema['x-enumNames'] as string[]) ?? enumValues.map(String); + + const options = enumValues.map((value, index) => ({ + value, + label: enumNames[index] ?? String(value), + })); + + const tooltip = (props.uischema?.options as Record) + ?.tooltip as string | undefined; + + return ( + + handleLayerChange(value as string)} + options={layers.map(l => ({ + value: l.uuid, + label: l.name, + }))} + getPopupContainer={() => document.body} + /> + + + {/* Loading runtime schema */} + {loadingRuntime && ( + + + + )} + + {/* Source location (runtime config fields) */} + {hasRuntimeFields && !loadingRuntime && ( + <> + {t('Source location')} + + + + + )} + + {/* Semantic Views — always visible once a layer is selected */} + {selectedLayerUuid && !loadingRuntime && ( + +