From 3868821dc81882c9b70fe710a2313006308a7548 Mon Sep 17 00:00:00 2001 From: Mehmet Salih Yavuz Date: Fri, 20 Feb 2026 00:26:16 +0300 Subject: [PATCH 1/3] fix(webpack): skip building service worker in dev (#38106) --- superset-frontend/webpack.config.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/superset-frontend/webpack.config.js b/superset-frontend/webpack.config.js index da8c3689b410..8530db279f72 100644 --- a/superset-frontend/webpack.config.js +++ b/superset-frontend/webpack.config.js @@ -317,7 +317,12 @@ const config = { menu: addPreamble('src/views/menu.tsx'), spa: addPreamble('src/views/index.tsx'), embedded: addPreamble('src/embedded/index.tsx'), - 'service-worker': path.join(APP_DIR, 'src/service-worker.ts'), + // Skip service-worker build in dev mode to avoid overwriting the placeholder + ...(isDevMode + ? {} + : { + 'service-worker': path.join(APP_DIR, 'src/service-worker.ts'), + }), }, cache: { type: 'filesystem', From 5278deaf63536fd07f3c1007d2d393f9f076c692 Mon Sep 17 00:00:00 2001 From: Richard Fogaca Nienkotter <63572350+richardfogaca@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:25:44 -0300 Subject: [PATCH 2/3] fix(metrics): normalize legacy currency strings (#37455) --- .../CurrencyControl/CurrencyControl.test.tsx | 29 +++- .../CurrencyControl/CurrencyControl.tsx | 39 ++++-- superset/connectors/sqla/models.py | 3 +- superset/datasets/schemas.py | 22 ++- superset/models/sql_types/__init__.py | 7 + superset/models/sql_types/base.py | 106 ++++++++++++++ tests/integration_tests/datasets/api_tests.py | 2 +- tests/unit_tests/datasets/schema_tests.py | 56 ++++++++ tests/unit_tests/models/sql_types/__init__.py | 16 +++ .../models/sql_types/currency_type_test.py | 129 ++++++++++++++++++ 10 files changed, 393 insertions(+), 16 deletions(-) create mode 100644 superset/models/sql_types/base.py create mode 100644 tests/unit_tests/models/sql_types/__init__.py create mode 100644 tests/unit_tests/models/sql_types/currency_type_test.py diff --git a/superset-frontend/src/explore/components/controls/CurrencyControl/CurrencyControl.test.tsx b/superset-frontend/src/explore/components/controls/CurrencyControl/CurrencyControl.test.tsx index 39a1a64ee29e..a40f5750446c 100644 --- a/superset-frontend/src/explore/components/controls/CurrencyControl/CurrencyControl.test.tsx +++ b/superset-frontend/src/explore/components/controls/CurrencyControl/CurrencyControl.test.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { render } from 'spec/helpers/testing-library'; +import { render, selectOption } from 'spec/helpers/testing-library'; import { CurrencyControl } from './CurrencyControl'; test('CurrencyControl renders position and symbol selects', () => { @@ -36,3 +36,30 @@ test('CurrencyControl renders position and symbol selects', () => { ).toBeInTheDocument(); expect(container.querySelectorAll('.ant-select')).toHaveLength(2); }); + +test('CurrencyControl handles string currency value', async () => { + const onChange = jest.fn(); + const { container } = render( + , + { + useRedux: true, + initialState: { + common: { currencies: ['USD', 'EUR'] }, + explore: { datasource: {} }, + }, + }, + ); + + expect( + container.querySelector('[data-test="currency-control-container"]'), + ).toBeInTheDocument(); + + await selectOption('Suffix', 'Currency prefix or suffix'); + expect(onChange).toHaveBeenLastCalledWith({ + symbol: 'USD', + symbolPosition: 'suffix', + }); +}); diff --git a/superset-frontend/src/explore/components/controls/CurrencyControl/CurrencyControl.tsx b/superset-frontend/src/explore/components/controls/CurrencyControl/CurrencyControl.tsx index 8ec689044405..3da754330fde 100644 --- a/superset-frontend/src/explore/components/controls/CurrencyControl/CurrencyControl.tsx +++ b/superset-frontend/src/explore/components/controls/CurrencyControl/CurrencyControl.tsx @@ -29,7 +29,7 @@ import ControlHeader from '../../ControlHeader'; export interface CurrencyControlProps { onChange: (currency: Partial) => void; - value?: Partial; + value?: Partial | string | null; symbolSelectOverrideProps?: Partial; currencySelectOverrideProps?: Partial; symbolSelectAdditionalStyles?: CSSObject; @@ -59,9 +59,12 @@ export const CURRENCY_SYMBOL_POSITION_OPTIONS = [ { value: 'suffix', label: t('Suffix') }, ]; +const isCurrencyObject = (value: unknown): value is Partial => + !!value && typeof value === 'object' && !Array.isArray(value); + export const CurrencyControl = ({ onChange, - value: currency = {}, + value: rawCurrency = {}, symbolSelectOverrideProps = {}, currencySelectOverrideProps = {}, symbolSelectAdditionalStyles, @@ -69,6 +72,24 @@ export const CurrencyControl = ({ ...props }: CurrencyControlProps) => { const theme = useTheme(); + const normalizedCurrency = useMemo>(() => { + if (isCurrencyObject(rawCurrency)) { + return rawCurrency; + } + + if (typeof rawCurrency === 'string') { + try { + const parsed = JSON.parse(rawCurrency) as unknown; + if (isCurrencyObject(parsed)) { + return parsed; + } + } catch { + return {}; + } + } + + return {}; + }, [rawCurrency]); const currencies = useSelector( state => state.common?.currencies, ); @@ -155,10 +176,12 @@ export const CurrencyControl = ({ options={CURRENCY_SYMBOL_POSITION_OPTIONS} placeholder={t('Prefix or suffix')} onChange={(symbolPosition: string) => { - onChange({ ...currency, symbolPosition }); + onChange({ ...normalizedCurrency, symbolPosition }); }} - onClear={() => onChange({ ...currency, symbolPosition: undefined })} - value={currency?.symbolPosition} + onClear={() => + onChange({ ...normalizedCurrency, symbolPosition: undefined }) + } + value={normalizedCurrency?.symbolPosition} allowClear {...symbolSelectOverrideProps} /> @@ -167,10 +190,10 @@ export const CurrencyControl = ({ options={currenciesOptions} placeholder={t('Currency')} onChange={(symbol: string) => { - onChange({ ...currency, symbol }); + onChange({ ...normalizedCurrency, symbol }); }} - onClear={() => onChange({ ...currency, symbol: undefined })} - value={currency?.symbol} + onClear={() => onChange({ ...normalizedCurrency, symbol: undefined })} + value={normalizedCurrency?.symbol} allowClear allowNewOptions sortComparator={currencySortComparator} diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index be74a199672d..ddd31e78fa7a 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -103,6 +103,7 @@ SQLA_QUERY_KEYS, ) from superset.models.slice import Slice +from superset.models.sql_types.base import CurrencyType from superset.sql.parse import Table from superset.superset_typing import ( AdhocColumn, @@ -1093,7 +1094,7 @@ class SqlMetric(AuditMixinNullable, ImportExportMixin, CertificationMixin, Model metric_type = Column(String(32)) description = Column(utils.MediumText()) d3format = Column(String(128)) - currency = Column(JSON, nullable=True) + currency = Column(CurrencyType, nullable=True) warning_text = Column(Text) table_id = Column(Integer, ForeignKey("tables.id", ondelete="CASCADE")) expression = Column(utils.MediumText(), nullable=False) diff --git a/superset/datasets/schemas.py b/superset/datasets/schemas.py index 82f1e7faa1ce..1a30a6bd799a 100644 --- a/superset/datasets/schemas.py +++ b/superset/datasets/schemas.py @@ -32,6 +32,7 @@ from superset import security_manager from superset.connectors.sqla.models import SqlaTable from superset.exceptions import SupersetMarshmallowValidationError +from superset.models.sql_types import parse_currency_string from superset.utils import json get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}} @@ -97,6 +98,20 @@ class DatasetMetricCurrencyPutSchema(Schema): symbolPosition = fields.String(validate=Length(1, 128)) # noqa: N815 +class CurrencyField(fields.Nested): + """ + Nested field that tolerates legacy string payloads for currency. + """ + + def _deserialize( + self, value: Any, attr: str | None, data: dict[str, Any], **kwargs: Any + ) -> Any: + if isinstance(value, str): + value = parse_currency_string(value) + + return super()._deserialize(value, attr, data, **kwargs) + + class DatasetMetricsPutSchema(Schema): id = fields.Integer() expression = fields.String(required=True) @@ -105,7 +120,7 @@ class DatasetMetricsPutSchema(Schema): metric_name = fields.String(required=True, validate=Length(1, 255)) metric_type = fields.String(allow_none=True, validate=Length(1, 32)) d3format = fields.String(allow_none=True, validate=Length(1, 128)) - currency = fields.Nested(DatasetMetricCurrencyPutSchema, allow_none=True) + currency = CurrencyField(DatasetMetricCurrencyPutSchema, allow_none=True) verbose_name = fields.String(allow_none=True, metadata={Length: (1, 1024)}) warning_text = fields.String(allow_none=True) uuid = fields.UUID(allow_none=True) @@ -276,9 +291,6 @@ def fix_fields(self, data: dict[str, Any], **kwargs: Any) -> dict[str, Any]: if isinstance(data.get("extra"), str): data["extra"] = json.loads(data["extra"]) - if isinstance(data.get("currency"), str): - data["currency"] = json.loads(data["currency"]) - return data metric_name = fields.String(required=True) @@ -287,7 +299,7 @@ def fix_fields(self, data: dict[str, Any], **kwargs: Any) -> dict[str, Any]: expression = fields.String(required=True) description = fields.String(allow_none=True) d3format = fields.String(allow_none=True) - currency = fields.Nested(ImportMetricCurrencySchema, allow_none=True) + currency = CurrencyField(ImportMetricCurrencySchema, allow_none=True) extra = fields.Dict(allow_none=True) warning_text = fields.String(allow_none=True) diff --git a/superset/models/sql_types/__init__.py b/superset/models/sql_types/__init__.py index 13a83393a912..05407b9f442f 100644 --- a/superset/models/sql_types/__init__.py +++ b/superset/models/sql_types/__init__.py @@ -14,3 +14,10 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. + +from superset.models.sql_types.base import CurrencyType, parse_currency_string + +__all__ = [ + "CurrencyType", + "parse_currency_string", +] diff --git a/superset/models/sql_types/base.py b/superset/models/sql_types/base.py new file mode 100644 index 000000000000..355d9df5115c --- /dev/null +++ b/superset/models/sql_types/base.py @@ -0,0 +1,106 @@ +# 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. +"""Base SQL types for Superset models.""" + +from __future__ import annotations + +# pylint: disable=abstract-method +import ast +from typing import Any + +from sqlalchemy.engine.interfaces import Dialect +from sqlalchemy.types import JSON, TypeDecorator + +from superset.utils import json + + +def _try_parse_currency(value: str) -> Any | None: + try: + return json.loads(value) + except (json.JSONDecodeError, TypeError): + pass + + try: + return ast.literal_eval(value) + except (ValueError, SyntaxError): + return None + + +def parse_currency_string(value: str) -> dict[str, Any]: + if not value: + return {} + + current: Any = value + for _ in range(2): + if isinstance(current, dict): + return current + if not isinstance(current, str): + return {} + + parsed = _try_parse_currency(current) + if parsed is None: + return {} + if isinstance(parsed, str): + current = parsed + continue + if isinstance(parsed, dict): + return parsed + return {} + + return {} + + +class CurrencyType(TypeDecorator): + """ + Custom SQLAlchemy type for metric currency that ensures string values + are parsed to dicts when read from the database. + + This handles legacy data that was stored as stringified JSON before + the currency column was migrated from VARCHAR to JSON type. Some data + may have been double-encoded or stored with Python dict formatting. + + Example problematic values this handles: + - '{"symbol": "USD", "symbolPosition": "prefix"}' (JSON string) + - "{'symbol': 'EUR', 'symbolPosition': 'suffix'}" (Python dict string) + """ + + impl = JSON + cache_ok = True + + def process_result_value( + self, value: Any, dialect: Dialect + ) -> dict[str, Any] | None: + """ + Process value when reading from database. + + Ensures the returned value is always a dict (or None), even if + the stored value is a string representation of a dict. + + Args: + value: The value from the database (could be None, dict, or string) + dialect: The SQLAlchemy dialect being used + + Returns: + A dict representing the currency configuration, or None + """ + if value is None: + return None + + if isinstance(value, str): + return parse_currency_string(value) + + return value diff --git a/tests/integration_tests/datasets/api_tests.py b/tests/integration_tests/datasets/api_tests.py index 6677f19f8ad1..c0692fe97419 100644 --- a/tests/integration_tests/datasets/api_tests.py +++ b/tests/integration_tests/datasets/api_tests.py @@ -1510,7 +1510,7 @@ def test_update_dataset_update_metric_invalid_currency(self): { "metric_name": "test", "expression": "COUNT(*)", - "currency": '{"symbol": "USD", "symbolPosition": "suffix"}', + "currency": {"symbol": "", "symbolPosition": ""}, }, ] } diff --git a/tests/unit_tests/datasets/schema_tests.py b/tests/unit_tests/datasets/schema_tests.py index 280a2440c2df..40d63f3af8bb 100644 --- a/tests/unit_tests/datasets/schema_tests.py +++ b/tests/unit_tests/datasets/schema_tests.py @@ -75,3 +75,59 @@ def test_dataset_put_schema_currency_code_column_optional() -> None: "currency_code_column" not in result or result.get("currency_code_column") is None ) + + +def test_dataset_metrics_put_schema_parses_currency_string() -> None: + """Test that DatasetMetricsPutSchema parses string currency payloads.""" + from superset.datasets.schemas import DatasetMetricsPutSchema + + schema = DatasetMetricsPutSchema() + data = { + "expression": "SUM(amount)", + "metric_name": "sum_amount", + "currency": '{"symbol": "EUR", "symbolPosition": "suffix"}', + } + result = schema.load(data) + assert result["currency"] == {"symbol": "EUR", "symbolPosition": "suffix"} + + +def test_dataset_metrics_put_schema_parses_python_dict_string() -> None: + """Test that DatasetMetricsPutSchema parses Python dict currency strings.""" + from superset.datasets.schemas import DatasetMetricsPutSchema + + schema = DatasetMetricsPutSchema() + data = { + "expression": "SUM(amount)", + "metric_name": "sum_amount", + "currency": "{'symbol': 'GBP', 'symbolPosition': 'prefix'}", + } + result = schema.load(data) + assert result["currency"] == {"symbol": "GBP", "symbolPosition": "prefix"} + + +def test_dataset_metrics_put_schema_handles_malformed_currency() -> None: + """Test that DatasetMetricsPutSchema normalizes malformed currency strings.""" + from superset.datasets.schemas import DatasetMetricsPutSchema + + schema = DatasetMetricsPutSchema() + data = { + "expression": "SUM(amount)", + "metric_name": "sum_amount", + "currency": "not valid json", + } + result = schema.load(data) + assert result["currency"] == {} + + +def test_import_v1_metric_schema_parses_currency_string() -> None: + """Test that ImportV1MetricSchema parses string currency payloads.""" + from superset.datasets.schemas import ImportV1MetricSchema + + schema = ImportV1MetricSchema() + data = { + "metric_name": "sum_amount", + "expression": "SUM(amount)", + "currency": '{"symbol": "CAD", "symbolPosition": "suffix"}', + } + result = schema.load(data) + assert result["currency"] == {"symbol": "CAD", "symbolPosition": "suffix"} diff --git a/tests/unit_tests/models/sql_types/__init__.py b/tests/unit_tests/models/sql_types/__init__.py new file mode 100644 index 000000000000..13a83393a912 --- /dev/null +++ b/tests/unit_tests/models/sql_types/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/tests/unit_tests/models/sql_types/currency_type_test.py b/tests/unit_tests/models/sql_types/currency_type_test.py new file mode 100644 index 000000000000..58f94d3b850b --- /dev/null +++ b/tests/unit_tests/models/sql_types/currency_type_test.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. +"""Tests for CurrencyType - a custom SQLAlchemy type for metric currency.""" + +from unittest.mock import MagicMock + +import pytest + +from superset.models.sql_types.base import CurrencyType + + +@pytest.fixture +def currency_type() -> CurrencyType: + """Create a CurrencyType instance for testing.""" + return CurrencyType() + + +@pytest.fixture +def mock_dialect() -> MagicMock: + """Create a mock SQLAlchemy dialect.""" + return MagicMock() + + +def test_process_result_value_with_none( + currency_type: CurrencyType, mock_dialect: MagicMock +) -> None: + """Test that None values are returned as-is.""" + result = currency_type.process_result_value(None, mock_dialect) + assert result is None + + +def test_process_result_value_with_dict( + currency_type: CurrencyType, mock_dialect: MagicMock +) -> None: + """Test that dict values are returned as-is.""" + value = {"symbol": "USD", "symbolPosition": "prefix"} + result = currency_type.process_result_value(value, mock_dialect) + assert result == {"symbol": "USD", "symbolPosition": "prefix"} + + +def test_process_result_value_with_empty_dict( + currency_type: CurrencyType, mock_dialect: MagicMock +) -> None: + """Test that empty dict values are returned as-is.""" + result = currency_type.process_result_value({}, mock_dialect) + assert result == {} + + +def test_process_result_value_with_json_string( + currency_type: CurrencyType, mock_dialect: MagicMock +) -> None: + """Test that JSON string values are parsed to dict.""" + value = '{"symbol": "EUR", "symbolPosition": "suffix"}' + result = currency_type.process_result_value(value, mock_dialect) + assert result == {"symbol": "EUR", "symbolPosition": "suffix"} + + +def test_process_result_value_with_python_dict_string( + currency_type: CurrencyType, mock_dialect: MagicMock +) -> None: + """Test that Python dict string (single quotes) values are parsed to dict.""" + value = "{'symbol': 'GBP', 'symbolPosition': 'prefix'}" + result = currency_type.process_result_value(value, mock_dialect) + assert result == {"symbol": "GBP", "symbolPosition": "prefix"} + + +def test_process_result_value_with_double_encoded_json_string( + currency_type: CurrencyType, mock_dialect: MagicMock +) -> None: + """Test that double-encoded JSON strings are parsed to dict.""" + value = '"{\\"symbol\\": \\"EUR\\", \\"symbolPosition\\": \\"suffix\\"}"' + result = currency_type.process_result_value(value, mock_dialect) + assert result == {"symbol": "EUR", "symbolPosition": "suffix"} + + +def test_process_result_value_with_malformed_string( + currency_type: CurrencyType, mock_dialect: MagicMock +) -> None: + """Test that malformed string values return empty dict.""" + value = "not valid json at all" + result = currency_type.process_result_value(value, mock_dialect) + assert result == {} + + +def test_process_result_value_with_empty_string( + currency_type: CurrencyType, mock_dialect: MagicMock +) -> None: + """Test that empty string values return empty dict.""" + result = currency_type.process_result_value("", mock_dialect) + assert result == {} + + +def test_process_result_value_with_partial_json_string( + currency_type: CurrencyType, mock_dialect: MagicMock +) -> None: + """Test that partial JSON string (only symbol) is parsed correctly.""" + value = '{"symbol": "JPY"}' + result = currency_type.process_result_value(value, mock_dialect) + assert result == {"symbol": "JPY"} + + +def test_cache_ok_is_true(currency_type: CurrencyType) -> None: + """Test that cache_ok is True for SQLAlchemy compatibility.""" + assert currency_type.cache_ok is True + + +def test_impl_is_json(currency_type: CurrencyType) -> None: + """Test that the underlying implementation is JSON type.""" + from sqlalchemy.types import JSON + + impl = currency_type.impl + if isinstance(impl, type): + assert issubclass(impl, JSON) + else: + assert isinstance(impl, JSON) From f4acce5727fe32d504472a5456dbca049b41dae9 Mon Sep 17 00:00:00 2001 From: Vanessa Giannoni <38120642+VanessaGiannoni@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:46:39 -0300 Subject: [PATCH 3/3] fix(table): preserve time grain aggregation when temporal column casing changes (#37893) --- .../plugin-chart-table/src/controlPanel.tsx | 4 +- .../test/controlPanel.test.ts | 77 +++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 superset-frontend/plugins/plugin-chart-table/test/controlPanel.test.ts diff --git a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx index d7417ee78b4d..7de658c7186c 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx @@ -274,7 +274,7 @@ const config: ControlPanelConfig = { visibility: ({ controls }) => { const dttmLookup = Object.fromEntries( ensureIsArray(controls?.groupby?.options).map(option => [ - option.column_name, + (option.column_name || '').toLowerCase(), option.is_dttm, ]), ); @@ -285,7 +285,7 @@ const config: ControlPanelConfig = { return true; } if (isPhysicalColumn(selection)) { - return !!dttmLookup[selection]; + return !!dttmLookup[(selection || '').toLowerCase()]; } return false; }) diff --git a/superset-frontend/plugins/plugin-chart-table/test/controlPanel.test.ts b/superset-frontend/plugins/plugin-chart-table/test/controlPanel.test.ts new file mode 100644 index 000000000000..e09034fe6500 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-table/test/controlPanel.test.ts @@ -0,0 +1,77 @@ +/* eslint-disable camelcase */ +/** + * 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 + */ + +import { + ControlPanelConfig, + ControlPanelsContainerProps, + ControlState, + CustomControlItem, +} from '@superset-ui/chart-controls'; +import config from '../src/controlPanel'; + +type VisibilityFn = ( + props: ControlPanelsContainerProps, + control?: ControlState, +) => boolean; + +function isControlWithVisibility( + controlItem: unknown, +): controlItem is CustomControlItem & { + config: Required & { visibility: VisibilityFn }; +} { + return ( + typeof controlItem === 'object' && + controlItem !== null && + 'name' in controlItem && + 'config' in controlItem && + typeof (controlItem as CustomControlItem).config?.visibility === 'function' + ); +} + +function getVisibility( + panel: ControlPanelConfig, + controlName: string, +): VisibilityFn { + const item = (panel.controlPanelSections || []) + .flatMap(section => section?.controlSetRows || []) + .flat() + .find(c => isControlWithVisibility(c) && c.name === controlName); + + if (!isControlWithVisibility(item)) { + throw new Error(`Control "${controlName}" with visibility not found`); + } + return item.config.visibility; +} + +function mkProps( + groupbyValue: string[], + options = [ + { column_name: 'ORDERDATE', is_dttm: true }, + { column_name: 'some_other_col', is_dttm: false }, + ], +): ControlPanelsContainerProps { + return { + controls: { + groupby: { value: groupbyValue, options }, + }, + } as unknown as ControlPanelsContainerProps; +} + +test('time_grain_sqla visibility should be case-insensitive', () => { + const vis = getVisibility(config, 'time_grain_sqla'); + const controlState = {} as ControlState; + + expect(vis(mkProps(['orderdate']), controlState)).toBe(true); + expect(vis(mkProps(['ORDERDATE']), controlState)).toBe(true); + expect(vis(mkProps(['some_other_col']), controlState)).toBe(false); +});