Compare commits

..

1 Commits

Author SHA1 Message Date
Amin Ghadersohi
c4243d6b5c fix(mcp): improve execute_sql response-too-large error to suggest limit parameter
When execute_sql responses exceed the token limit, the error message now
suggests both adding a SQL LIMIT clause and using the tool's limit
parameter. Previously only the SQL LIMIT was mentioned, and the general
"add a limit" suggestion was skipped for execute_sql entirely.
2026-04-01 05:05:27 -04:00
196 changed files with 3910 additions and 10379 deletions

View File

@@ -76,7 +76,7 @@ jobs:
fetch-depth: 1
- name: Run Claude PR Action
uses: anthropics/claude-code-action@6e2bd52842c65e914eba5c8badd17560bd26b5de # beta
uses: anthropics/claude-code-action@88c168b39e7e64da0286d812b6e9fbebb6708185 # beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
timeout_minutes: "60"

View File

@@ -58,7 +58,7 @@ jobs:
- name: Login to Amazon ECR
if: steps.describe-services.outputs.active == 'true'
id: login-ecr
uses: aws-actions/amazon-ecr-login@f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78 # v2
uses: aws-actions/amazon-ecr-login@183a1442edf41672e66566b7fc560e297a290896 # v2
- name: Delete ECR image tag
if: steps.describe-services.outputs.active == 'true'

View File

@@ -199,7 +199,7 @@ jobs:
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78 # v2
uses: aws-actions/amazon-ecr-login@183a1442edf41672e66566b7fc560e297a290896 # v2
- name: Load, tag and push image to ECR
id: push-image
@@ -235,7 +235,7 @@ jobs:
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78 # v2
uses: aws-actions/amazon-ecr-login@183a1442edf41672e66566b7fc560e297a290896 # v2
- name: Check target image exists in ECR
id: check-image

View File

@@ -70,7 +70,7 @@ jobs:
yarn install --check-cache
- name: Download database diagnostics (if triggered by integration tests)
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success'
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
continue-on-error: true
with:
workflow: superset-python-integrationtest.yml
@@ -79,7 +79,7 @@ jobs:
path: docs/src/data/
- name: Try to download latest diagnostics (for push/dispatch triggers)
if: github.event_name != 'workflow_run'
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
continue-on-error: true
with:
workflow: superset-python-integrationtest.yml

View File

@@ -111,7 +111,7 @@ jobs:
run: |
yarn install --check-cache
- name: Download database diagnostics from integration tests
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
with:
workflow: superset-python-integrationtest.yml
run_id: ${{ github.event.workflow_run.id }}

View File

@@ -23,7 +23,7 @@ jobs:
fetch-depth: 0
- name: Set up Helm
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4
with:
version: v3.16.4

View File

@@ -42,7 +42,7 @@ jobs:
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Install Helm
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4
with:
version: v3.5.4

View File

@@ -115,10 +115,6 @@ services:
DATABASE_HOST: db-light
DATABASE_DB: superset_light
POSTGRES_DB: superset_light
EXAMPLES_HOST: db-light
EXAMPLES_DB: superset_light
EXAMPLES_USER: superset
EXAMPLES_PASSWORD: superset
SUPERSET_CONFIG_PATH: /app/docker/pythonpath_dev/superset_config_docker_light.py
GITHUB_HEAD_REF: ${GITHUB_HEAD_REF:-}
GITHUB_SHA: ${GITHUB_SHA:-}
@@ -141,10 +137,6 @@ services:
DATABASE_HOST: db-light
DATABASE_DB: superset_light
POSTGRES_DB: superset_light
EXAMPLES_HOST: db-light
EXAMPLES_DB: superset_light
EXAMPLES_USER: superset
EXAMPLES_PASSWORD: superset
SUPERSET_CONFIG_PATH: /app/docker/pythonpath_dev/superset_config_docker_light.py
healthcheck:
disable: true
@@ -165,7 +157,6 @@ services:
BUILD_SUPERSET_FRONTEND_IN_DOCKER: true
NPM_RUN_PRUNE: false
SCARF_ANALYTICS: "${SCARF_ANALYTICS:-}"
DISABLE_TS_CHECKER: "${DISABLE_TS_CHECKER:-true}"
# configuring the dev-server to use the host.docker.internal to connect to the backend
superset: "http://superset-light:8088"
# Webpack dev server must bind to 0.0.0.0 to be accessible from outside the container

View File

@@ -80,7 +80,7 @@ case "${1}" in
;;
app)
echo "Starting web app (using development server)..."
flask run -p $PORT --reload --debugger --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*:*/superset-frontend/*"
flask run -p $PORT --reload --debugger --without-threads --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*"
;;
app-gunicorn)
echo "Starting web app..."

View File

@@ -70,7 +70,7 @@
"@swc/core": "^1.15.21",
"antd": "^6.3.5",
"baseline-browser-mapping": "^2.10.13",
"caniuse-lite": "^1.0.30001786",
"caniuse-lite": "^1.0.30001782",
"docusaurus-plugin-openapi-docs": "^4.6.0",
"docusaurus-theme-openapi-docs": "^4.6.0",
"js-yaml": "^4.1.1",

View File

@@ -6067,10 +6067,10 @@ caniuse-api@^3.0.0:
lodash.memoize "^4.1.2"
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001786:
version "1.0.30001786"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz#586120fc73f3c7ee82152f76acd0c37e04acefbb"
integrity sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001782:
version "1.0.30001782"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz#f2b8617f998bc134701c54ce9748af44f646e062"
integrity sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==
ccount@^2.0.0:
version "2.0.1"

View File

@@ -86,7 +86,7 @@ cron-descriptor==1.4.5
# via apache-superset (pyproject.toml)
croniter==6.0.0
# via apache-superset (pyproject.toml)
cryptography==46.0.6
cryptography==46.0.5
# via
# apache-superset (pyproject.toml)
# paramiko
@@ -209,7 +209,7 @@ mako==1.3.10
# via
# apache-superset (pyproject.toml)
# alembic
markdown==3.8.1
markdown==3.8
# via apache-superset (pyproject.toml)
markdown-it-py==3.0.0
# via rich
@@ -279,7 +279,7 @@ parsedatetime==2.6
# via apache-superset (pyproject.toml)
pgsanity==0.2.9
# via apache-superset (pyproject.toml)
pillow==12.1.1
pillow==11.3.0
# via apache-superset (pyproject.toml)
platformdirs==4.3.8
# via requests-cache
@@ -293,7 +293,7 @@ prompt-toolkit==3.0.51
# via click-repl
pyarrow==16.1.0
# via apache-superset (pyproject.toml)
pyasn1==0.6.3
pyasn1==0.6.2
# via
# pyasn1-modules
# rsa
@@ -309,9 +309,9 @@ pydantic-core==2.33.2
# via pydantic
pygeohash==3.2.2
# via apache-superset (pyproject.toml)
pygments==2.20.0
pygments==2.19.1
# via rich
pyjwt==2.12.0
pyjwt==2.10.1
# via
# apache-superset (pyproject.toml)
# flask-appbuilder

View File

@@ -178,7 +178,7 @@ croniter==6.0.0
# via
# -c requirements/base-constraint.txt
# apache-superset
cryptography==46.0.6
cryptography==46.0.5
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -508,7 +508,7 @@ mako==1.3.10
# -c requirements/base-constraint.txt
# alembic
# apache-superset
markdown==3.8.1
markdown==3.8
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -655,7 +655,7 @@ pgsanity==0.2.9
# via
# -c requirements/base-constraint.txt
# apache-superset
pillow==12.1.1
pillow==11.3.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -716,7 +716,7 @@ pyarrow==16.1.0
# apache-superset
# db-dtypes
# pandas-gbq
pyasn1==0.6.3
pyasn1==0.6.2
# via
# -c requirements/base-constraint.txt
# pyasn1-modules
@@ -756,7 +756,7 @@ pygeohash==3.2.2
# via
# -c requirements/base-constraint.txt
# apache-superset
pygments==2.20.0
pygments==2.19.1
# via
# -c requirements/base-constraint.txt
# rich
@@ -764,7 +764,7 @@ pyhive==0.7.0
# via apache-superset
pyinstrument==4.4.0
# via apache-superset
pyjwt==2.12.0
pyjwt==2.10.1
# via
# -c requirements/base-constraint.txt
# apache-superset

View File

@@ -0,0 +1,171 @@
/**
* 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 { CHART_LIST } from 'cypress/utils/urls';
import { setGridMode, toggleBulkSelect } from 'cypress/utils';
import {
setFilter,
interceptBulkDelete,
interceptUpdate,
interceptDelete,
interceptFiltering,
interceptFavoriteStatus,
} from '../explore/utils';
function orderAlphabetical() {
setFilter('Sort', 'Alphabetical');
}
function openProperties() {
cy.get('[aria-label="more"]').eq(0).click();
cy.getBySel('chart-list-edit-option').click();
}
function openMenu() {
cy.get('[aria-label="more"]').eq(0).click();
}
function confirmDelete() {
cy.getBySel('delete-modal-input').type('DELETE');
cy.getBySel('modal-confirm-button').click();
}
function visitChartList() {
interceptFiltering();
interceptFavoriteStatus();
cy.visit(CHART_LIST);
cy.wait('@filtering');
cy.wait('@favoriteStatus');
}
describe('Charts list', () => {
describe('common actions', () => {
beforeEach(() => {
visitChartList();
});
it('should bulk delete correctly', () => {
cy.createSampleCharts([0, 1, 2, 3]);
interceptBulkDelete();
toggleBulkSelect();
// bulk deletes in card-view
setGridMode('card');
orderAlphabetical();
cy.getBySel('skeleton-card').should('not.exist');
cy.getBySel('styled-card').contains('1 - Sample chart').click();
cy.getBySel('styled-card').contains('2 - Sample chart').click();
cy.getBySel('bulk-select-action').contains('Delete').click();
confirmDelete();
cy.wait('@bulkDelete');
cy.getBySel('styled-card')
.eq(1)
.should('not.contain', '1 - Sample chart');
cy.getBySel('styled-card')
.eq(2)
.should('not.contain', '2 - Sample chart');
// bulk deletes in list-view
setGridMode('list');
cy.get('.loading').should('not.exist');
cy.getBySel('table-row').contains('3 - Sample chart').should('exist');
cy.getBySel('table-row').contains('4 - Sample chart').should('exist');
cy.get('[data-test="table-row"] input[type="checkbox"]').eq(0).click();
cy.get('[data-test="table-row"] input[type="checkbox"]').eq(1).click();
cy.getBySel('bulk-select-action').eq(0).contains('Delete').click();
confirmDelete();
cy.wait('@bulkDelete');
cy.get('.loading').should('exist');
cy.get('.loading').should('not.exist');
cy.getBySel('table-row').eq(0).should('not.contain', '3 - Sample chart');
cy.getBySel('table-row').eq(1).should('not.contain', '4 - Sample chart');
});
it('should delete correctly in card mode', () => {
cy.createSampleCharts([0, 1]);
interceptDelete();
// deletes in card-view
setGridMode('card');
orderAlphabetical();
cy.getBySel('styled-card').contains('1 - Sample chart');
openMenu();
cy.getBySel('chart-list-delete-option').click();
confirmDelete();
cy.wait('@delete');
cy.getBySel('styled-card')
.contains('1 - Sample chart')
.should('not.exist');
});
it('should delete correctly in list mode', () => {
cy.createSampleCharts([2, 3]);
interceptDelete();
cy.getBySel('sort-header').contains('Name').click();
// Modal closes immediately without this
cy.wait(2000);
cy.getBySel('table-row').eq(0).contains('3 - Sample chart');
cy.getBySel('delete').eq(0).click();
confirmDelete();
cy.wait('@delete');
cy.get('.loading').should('exist');
cy.get('.loading').should('not.exist');
cy.getBySel('table-row').eq(0).should('not.contain', '3 - Sample chart');
});
it('should edit correctly', () => {
cy.createSampleCharts([0]);
interceptUpdate();
// edits in card-view
setGridMode('card');
orderAlphabetical();
cy.getBySel('skeleton-card').should('not.exist');
cy.getBySel('styled-card').eq(0).contains('1 - Sample chart');
// change title
openProperties();
cy.getBySel('properties-modal-name-input').type(' | EDITED');
cy.get('button:contains("Save")').click();
cy.wait('@update');
cy.getBySel('styled-card').eq(0).contains('1 - Sample chart | EDITED');
// edits in list-view
setGridMode('list');
// Wait for list view to fully render after mode change
cy.get('.loading').should('not.exist');
cy.getBySel('table-row').should('be.visible');
// Target the specific row by chart title to avoid flakiness from row ordering
cy.getBySel('table-row')
.contains('1 - Sample chart | EDITED')
.parents('[data-test="table-row"]')
.find('[data-test="edit-alt"]')
.click();
cy.getBySel('properties-modal-name-input').clear();
cy.getBySel('properties-modal-name-input').type('1 - Sample chart');
cy.get('button:contains("Save")').click();
cy.wait('@update');
cy.getBySel('table-row').contains('1 - Sample chart').should('exist');
});
});
});

View File

@@ -0,0 +1,42 @@
/**
* 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 { DATASET_LIST_PATH } from 'cypress/utils/urls';
describe('Dataset list', () => {
before(() => {
cy.visit(DATASET_LIST_PATH);
});
xit('should open Explore on dataset name click', () => {
cy.intercept('**/api/v1/explore/**').as('explore');
cy.get('[data-test="listview-table"] [data-test="internal-link"]')
.contains('birth_names')
.click();
cy.wait('@explore');
cy.get('[data-test="datasource-control"] .title-select').contains(
'birth_names',
);
cy.get('.metric-option-label').first().contains('COUNT(*)');
cy.get('.column-option-label').first().contains('ds');
cy.get('[data-test="fast-viz-switcher"] > div:not([role="button"]')
.contains('Table')
.should('be.visible');
});
});

View File

@@ -23,6 +23,18 @@ export function interceptFiltering() {
cy.intercept('GET', `**/api/v1/chart/?q=*`).as('filtering');
}
export function interceptBulkDelete() {
cy.intercept('DELETE', `**/api/v1/chart/?q=*`).as('bulkDelete');
}
export function interceptDelete() {
cy.intercept('DELETE', `**/api/v1/chart/*`).as('delete');
}
export function interceptFavoriteStatus() {
cy.intercept('GET', '**/api/v1/chart/favorite_status/*').as('favoriteStatus');
}
export function interceptUpdate() {
cy.intercept('PUT', `**/api/v1/chart/*`).as('update');
}
@@ -31,13 +43,32 @@ export const interceptV1ChartData = (alias = 'v1Data') => {
cy.intercept('**/api/v1/chart/data*').as(alias);
};
function interceptExploreGet() {
export function interceptExploreJson(alias = 'getJson') {
cy.intercept('POST', `**/superset/explore_json/**`).as(alias);
}
export const interceptFormDataKey = () => {
cy.intercept('POST', '**/api/v1/explore/form_data').as('formDataKey');
};
export function interceptExploreGet() {
cy.intercept({
method: 'GET',
url: /.*\/api\/v1\/explore\/\?(form_data_key|dashboard_page_id|slice_id)=.*/,
}).as('getExplore');
}
export function setFilter(filter: string, option: string) {
interceptFiltering();
cy.get(`[aria-label^="${filter}"]`).first().click();
cy.get(`.ant-select-item-option[title="${option}"]`).first().click({
force: true,
});
cy.wait('@filtering');
}
export function saveChartToDashboard(chartName: string, dashboardName: string) {
interceptDashboardGet();
interceptUpdate();

View File

@@ -25,6 +25,28 @@ export interface ChartSpec {
viz: string;
}
const viewTypeIcons = {
card: 'appstore',
list: 'unordered-list',
};
export function setGridMode(type: 'card' | 'list') {
const icon = viewTypeIcons[type];
cy.get(`[aria-label="${icon}"]`).click();
}
export function toggleBulkSelect() {
cy.getBySel('bulk-select').click();
}
export function clearAllInputs() {
cy.get('body').then($body => {
if ($body.find('.ant-select-clear').length) {
cy.get('.ant-select-clear').click({ multiple: true, force: true });
}
});
}
const toSlicelike = ($chart: JQuery<HTMLElement>): Slice => {
const chartId = $chart.attr('data-test-chart-id');
const vizType = $chart.attr('data-test-viz-type');

View File

@@ -25,3 +25,8 @@ export const SUPPORTED_CHARTS_DASHBOARD =
'/superset/dashboard/supported_charts_dash/';
export const TABBED_DASHBOARD = '/superset/dashboard/tabbed_dash/';
export const DATABASE_LIST = '/databaseview/list';
export const DATASET_LIST_PATH = 'tablemodelview/list';
export const ALERT_LIST = '/alert/list/';
export const REPORT_LIST = '/report/list/';
export const LOGIN = '/login/';
export const REGISTER = '/register/';

View File

@@ -5809,9 +5809,9 @@
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
@@ -13072,9 +13072,9 @@
}
},
"lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
},
"lodash.clonedeep": {
"version": "4.5.0",

File diff suppressed because it is too large Load Diff

View File

@@ -169,7 +169,7 @@
"fast-glob": "^3.3.2",
"fs-extra": "^11.3.4",
"fuse.js": "^7.1.0",
"geolib": "^3.3.14",
"geolib": "^3.3.4",
"geostyler": "^18.3.1",
"geostyler-data": "^1.1.0",
"geostyler-openlayers-parser": "^5.4.1",
@@ -182,9 +182,9 @@
"js-levenshtein": "^1.1.6",
"json-bigint": "^1.0.0",
"json-stringify-pretty-compact": "^2.0.0",
"lodash": "^4.18.1",
"lodash": "^4.17.23",
"mapbox-gl": "^3.20.0",
"markdown-to-jsx": "^9.7.13",
"markdown-to-jsx": "^9.7.6",
"match-sorter": "^8.2.0",
"memoize-one": "^5.2.1",
"mousetrap": "^1.6.5",
@@ -223,7 +223,7 @@
"redux-undo": "^1.0.0-beta9-9-7",
"rison": "^0.1.1",
"scroll-into-view-if-needed": "^3.1.0",
"simple-zstd": "^1.4.2",
"simple-zstd": "^2.1.0",
"stream-browserify": "^3.0.0",
"tinycolor2": "^1.4.2",
"urijs": "^1.19.8",
@@ -244,7 +244,7 @@
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
"@babel/plugin-transform-modules-commonjs": "^7.28.6",
"@babel/plugin-transform-runtime": "^7.29.0",
"@babel/preset-env": "^7.29.2",
"@babel/preset-env": "^7.29.0",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@babel/register": "^7.23.7",
@@ -256,7 +256,7 @@
"@emotion/jest": "^11.14.2",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.59.1",
"@playwright/test": "^1.58.2",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-actions": "^8.6.17",
"@storybook/addon-controls": "^8.6.17",
@@ -270,8 +270,8 @@
"@storybook/test": "^8.6.15",
"@storybook/test-runner": "^0.17.0",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.24",
"@swc/plugin-emotion": "^14.8.0",
"@swc/core": "^1.15.18",
"@swc/plugin-emotion": "^14.6.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^8.20.1",
"@testing-library/jest-dom": "^6.9.1",
@@ -284,7 +284,7 @@
"@types/js-levenshtein": "^1.1.3",
"@types/json-bigint": "^1.0.4",
"@types/mousetrap": "^1.6.15",
"@types/node": "^25.5.0",
"@types/node": "^25.3.3",
"@types/react": "^17.0.83",
"@types/react-dom": "^17.0.26",
"@types/react-loadable": "^5.5.11",
@@ -301,11 +301,11 @@
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"babel-jest": "^30.0.2",
"babel-loader": "^10.1.1",
"babel-loader": "^10.1.0",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",
"baseline-browser-mapping": "^2.10.13",
"baseline-browser-mapping": "^2.10.10",
"cheerio": "1.2.0",
"concurrently": "^9.2.1",
"copy-webpack-plugin": "^14.0.0",
@@ -327,7 +327,7 @@
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.9.2",
"eslint-plugin-storybook": "^0.8.0",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-testing-library": "^7.16.1",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^12.6.0",
"fork-ts-checker-webpack-plugin": "^9.1.0",
@@ -337,13 +337,13 @@
"imports-loader": "^5.0.0",
"jest": "^30.3.0",
"jest-environment-jsdom": "^29.7.0",
"jest-html-reporter": "^4.4.0",
"jest-html-reporter": "^4.3.0",
"jest-websocket-mock": "^2.5.0",
"js-yaml-loader": "^1.2.2",
"jsdom": "^29.0.2",
"jsdom": "^28.1.0",
"lerna": "^9.0.4",
"lightningcss": "^1.32.0",
"mini-css-extract-plugin": "^2.10.2",
"mini-css-extract-plugin": "^2.10.1",
"open-cli": "^9.0.0",
"oxlint": "^1.56.0",
"po2json": "^0.4.5",
@@ -361,7 +361,7 @@
"swc-loader": "^0.2.7",
"terser-webpack-plugin": "^5.4.0",
"thread-loader": "^4.0.4",
"ts-jest": "^29.4.9",
"ts-jest": "^29.4.6",
"tscw-config": "^1.1.2",
"tsx": "^4.21.0",
"typescript": "5.4.5",

View File

@@ -29,8 +29,8 @@
},
"dependencies": {
"chalk": "^5.6.2",
"lodash-es": "^4.18.1",
"yeoman-generator": "^8.1.2",
"lodash-es": "^4.17.23",
"yeoman-generator": "^7.5.1",
"yosay": "^3.0.0"
},
"devDependencies": {

View File

@@ -75,7 +75,7 @@
"devDependencies": {
"@babel/cli": "^7.28.6",
"@babel/core": "^7.29.0",
"@babel/preset-env": "^7.29.2",
"@babel/preset-env": "^7.29.0",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"typescript": "^5.0.0",
@@ -102,7 +102,7 @@
"react-dom": "^17.0.2",
"react-loadable": "^5.5.0",
"tinycolor2": "*",
"lodash": "^4.18.1",
"lodash": "^4.17.21",
"antd": "^5.26.0",
"jed": "^1.1.1"
},

View File

@@ -26,11 +26,11 @@
"dependencies": {
"@apache-superset/core": "*",
"@types/react": "*",
"lodash": "^4.18.1",
"lodash": "^4.17.23",
"tinycolor2": "*"
},
"peerDependencies": {
"@ant-design/icons": "^5.6.1",
"@ant-design/icons": "^5.2.6",
"@emotion/react": "^11.4.1",
"@superset-ui/core": "*",
"@testing-library/dom": "^8.20.1",

View File

@@ -21,11 +21,7 @@ import { styled, css } from '@apache-superset/core/theme';
export const ControlSubSectionHeader = styled.div`
${({ theme }) => css`
font-weight: ${theme.fontWeightStrong};
margin-top: ${theme.sizeUnit * 3}px;
margin-bottom: ${theme.sizeUnit}px;
font-size: ${theme.fontSizeSM}px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: ${theme.colorTextSecondary};
`}
`;

View File

@@ -24,40 +24,39 @@
"lib"
],
"dependencies": {
"@ant-design/icons": "^6.1.1",
"@apache-superset/core": "*",
"@ant-design/icons": "^5.2.6",
"@babel/runtime": "^7.29.2",
"@types/json-bigint": "^1.0.4",
"@visx/responsive": "^3.12.0",
"ace-builds": "^1.43.6",
"ag-grid-community": "35.0.1",
"ag-grid-react": "35.0.1",
"brace": "^0.11.1",
"classnames": "^2.5.1",
"core-js": "^3.49.0",
"csstype": "^3.2.3",
"core-js": "^3.49.0",
"d3-format": "^3.1.2",
"dayjs": "^1.11.20",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.20",
"dompurify": "^3.3.3",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
"jed": "^1.1.1",
"lodash": "^4.18.1",
"lodash": "^4.17.23",
"math-expression-evaluator": "^2.0.7",
"pretty-ms": "^9.3.0",
"re-resizable": "^6.11.2",
"react-ace": "^14.0.1",
"react-draggable": "^4.5.0",
"react-error-boundary": "6.0.0",
"react-js-cron": "^5.2.0",
"react-markdown": "^8.0.7",
"react-draggable": "^4.5.0",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.0",
"react-syntax-highlighter": "^16.1.1",
"react-ultimate-pagination": "^1.3.2",
"react-error-boundary": "6.0.0",
"react-markdown": "^8.0.7",
"regenerator-runtime": "^0.14.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
@@ -65,6 +64,7 @@
"reselect": "^5.1.1",
"rison": "^0.1.1",
"seedrandom": "^3.0.5",
"@visx/responsive": "^3.12.0",
"xss": "^1.0.15"
},
"devDependencies": {
@@ -74,12 +74,12 @@
"@types/d3-scale": "^2.1.1",
"@types/d3-time": "^3.0.4",
"@types/d3-time-format": "^4.0.3",
"@types/react-table": "^7.7.20",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/jquery": "^3.5.33",
"@types/lodash": "^4.17.24",
"@types/node": "^25.5.0",
"@types/node": "^25.3.3",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",
"@types/rison": "0.1.0",
"@types/seedrandom": "^3.0.8",
"fetch-mock": "^12.6.0",
@@ -88,6 +88,7 @@
"timezone-mock": "1.4.0"
},
"peerDependencies": {
"antd": "^5.26.0",
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.14.1",
@@ -100,7 +101,6 @@
"@types/react-loadable": "*",
"@types/react-window": "^1.8.8",
"@types/tinycolor2": "*",
"antd": "^5.26.0",
"nanoid": "^5.0.9",
"react": "^17.0.2",
"react-dom": "^17.0.2",

View File

@@ -283,16 +283,6 @@ export function AsyncAceEditor(
color: ${token.colorText} !important;
}
/* Fix cursor misalignment by ensuring consistent font-family */
.ace_editor .ace_content {
font-family: ${editorFontFamily} !important;
}
/* Ensure the text layer uses the same font-family */
.ace_editor .ace_text-layer {
font-family: ${editorFontFamily} !important;
}
/* Adjust gutter colors */
.ace_editor .ace_gutter {
background-color: ${token.colorBgElevated} !important;
@@ -319,11 +309,6 @@ export function AsyncAceEditor(
opacity: 0.5;
}
/* Style bracket matching to blend with theme */
.ace_editor .ace_bracket {
border-color: ${token.colorPrimaryBorderHover} !important;
}
/* Adjust cursor color */
.ace_editor .ace_cursor {
color: ${token.colorPrimaryText} !important;

View File

@@ -115,7 +115,6 @@ import {
PlusSquareOutlined,
PlusOutlined,
ProfileOutlined,
PushpinFilled,
PushpinOutlined,
QuestionCircleOutlined,
ReloadOutlined,
@@ -271,7 +270,6 @@ const AntdIcons = {
PlusSquareOutlined,
PlusOutlined,
ProfileOutlined,
PushpinFilled,
PushpinOutlined,
ReloadOutlined,
QuestionCircleOutlined,

View File

@@ -18,9 +18,6 @@
*/
// Specific modal implementations
export { ChartPropertiesModal } from './ChartPropertiesModal';
export { ConfirmDialog } from './ConfirmDialog';
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
export { DuplicateDatasetModal } from './DuplicateDatasetModal';
export { EditDatasetModal } from './EditDatasetModal';
export { ImportDatasetModal } from './ImportDatasetModal';

View File

@@ -47,7 +47,7 @@ export class ChartListPage {
}
/**
* Navigate to the chart list page in table view.
* Navigate to the chart list page.
* Forces table view via URL parameter to avoid card view default
* (ListviewsDefaultCardView feature flag may enable card view).
*/
@@ -55,13 +55,6 @@ export class ChartListPage {
await this.page.goto(`${URL.CHART_LIST}?viewMode=table`);
}
/**
* Navigate to the chart list page in card view.
*/
async gotoCardView(): Promise<void> {
await this.page.goto(`${URL.CHART_LIST}?viewMode=card`);
}
/**
* Wait for the table to load
* @param options - Optional wait options
@@ -70,16 +63,6 @@ export class ChartListPage {
await this.table.waitForVisible(options);
}
/**
* Wait for card view to finish loading.
*/
async waitForCardLoad(options?: { timeout?: number }): Promise<void> {
await this.page
.locator('[data-test="styled-card"]')
.first()
.waitFor({ state: 'visible', ...options });
}
/**
* Gets a chart row locator by name.
* Returns a Locator that tests can use with expect().toBeVisible(), etc.
@@ -146,24 +129,4 @@ export class ChartListPage {
async clickBulkAction(actionName: string): Promise<void> {
await this.bulkSelect.clickAction(actionName);
}
// --- Card view methods ---
/**
* Gets a chart card locator by name (card view).
*/
getChartCard(chartName: string): Locator {
return this.page
.locator('[data-test="styled-card"]')
.filter({ hasText: chartName });
}
/**
* Clicks the edit option in a chart card's dropdown menu (card view).
*/
async clickCardEditAction(chartName: string): Promise<void> {
const card = this.getChartCard(chartName);
await card.locator('[aria-label="more"]').click();
await this.page.locator('[data-test="chart-list-edit-option"]').click();
}
}

View File

@@ -17,20 +17,21 @@
* under the License.
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import { ChartListPage } from '../../pages/ChartListPage';
import {
ChartPropertiesModal,
DeleteConfirmationModal,
} from '../../components/modals';
import { Toast } from '../../components/core';
import { apiGetChart, ENDPOINTS } from '../../helpers/api/chart';
test as testWithAssets,
expect,
} from '../../../helpers/fixtures/testAssets';
import { ChartListPage } from '../../../pages/ChartListPage';
import { ChartPropertiesModal } from '../../../components/modals/ChartPropertiesModal';
import { DeleteConfirmationModal } from '../../../components/modals/DeleteConfirmationModal';
import { Toast } from '../../../components/core/Toast';
import { apiGetChart, ENDPOINTS } from '../../../helpers/api/chart';
import { createTestChart } from './chart-test-helpers';
import { waitForGet, waitForPut } from '../../helpers/api/intercepts';
import { waitForGet, waitForPut } from '../../../helpers/api/intercepts';
import {
expectStatusOneOf,
expectValidExportZip,
} from '../../helpers/api/assertions';
} from '../../../helpers/api/assertions';
/**
* Extend testWithAssets with chartListPage navigation (beforeEach equivalent).
@@ -260,60 +261,6 @@ test('should bulk delete multiple charts', async ({
}
});
test('should edit chart name from card view', async ({ page, testAssets }) => {
// Create throwaway chart for editing
const { id: chartId, name: chartName } = await createTestChart(
page,
testAssets,
test.info(),
{ prefix: 'test_card_edit' },
);
// Navigate to card view (not table view)
const cardListPage = new ChartListPage(page);
await cardListPage.gotoCardView();
await cardListPage.waitForCardLoad();
// Verify chart card is visible
await expect(cardListPage.getChartCard(chartName)).toBeVisible();
// Open card dropdown and click edit
await cardListPage.clickCardEditAction(chartName);
// Wait for properties modal to be ready
const propertiesModal = new ChartPropertiesModal(page);
await propertiesModal.waitForReady();
// Edit the chart name
const newName = `card_renamed_${Date.now()}_${test.info().parallelIndex}`;
await propertiesModal.fillName(newName);
// Set up response intercept for save
const saveResponsePromise = waitForPut(page, `${ENDPOINTS.CHART}${chartId}`);
// Click Save button
await propertiesModal.clickSave();
// Wait for save to complete and verify success
expectStatusOneOf(await saveResponsePromise, [200, 201]);
// Modal should close
await propertiesModal.waitForHidden();
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify the renamed card appears in card view and old name is gone
await expect(cardListPage.getChartCard(newName)).toBeVisible();
await expect(cardListPage.getChartCard(chartName)).not.toBeVisible();
// Backend verification: API returns updated name
const response = await apiGetChart(page, chartId);
const chart = (await response.json()).result;
expect(chart.slice_name).toBe(newName);
});
test('should bulk export multiple charts', async ({
page,
chartListPage,

View File

@@ -18,9 +18,9 @@
*/
import type { Page, TestInfo } from '@playwright/test';
import type { TestAssets } from '../../helpers/fixtures';
import { apiPostChart } from '../../helpers/api/chart';
import { getDatasetByName } from '../../helpers/api/dataset';
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
import { apiPostChart } from '../../../helpers/api/chart';
import { getDatasetByName } from '../../../helpers/api/dataset';
interface TestChartResult {
id: number;

View File

@@ -17,27 +17,28 @@
* under the License.
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import { DashboardListPage } from '../../pages/DashboardListPage';
import {
DeleteConfirmationModal,
ImportDatasetModal,
} from '../../components/modals';
import { Toast } from '../../components/core';
test as testWithAssets,
expect,
} from '../../../helpers/fixtures/testAssets';
import { DashboardListPage } from '../../../pages/DashboardListPage';
import { DeleteConfirmationModal } from '../../../components/modals/DeleteConfirmationModal';
import { ImportDatasetModal } from '../../../components/modals/ImportDatasetModal';
import { Toast } from '../../../components/core/Toast';
import {
apiGetDashboard,
apiDeleteDashboard,
apiExportDashboards,
getDashboardByName,
ENDPOINTS,
} from '../../helpers/api/dashboard';
} from '../../../helpers/api/dashboard';
import { createTestDashboard } from './dashboard-test-helpers';
import { waitForGet, waitForPost } from '../../helpers/api/intercepts';
import { waitForGet, waitForPost } from '../../../helpers/api/intercepts';
import {
expectStatusOneOf,
expectValidExportZip,
} from '../../helpers/api/assertions';
import { TIMEOUT } from '../../utils/constants';
} from '../../../helpers/api/assertions';
import { TIMEOUT } from '../../../utils/constants';
/**
* Extend testWithAssets with dashboardListPage navigation (beforeEach equivalent).

View File

@@ -18,8 +18,8 @@
*/
import type { Page, TestInfo } from '@playwright/test';
import type { TestAssets } from '../../helpers/fixtures';
import { apiPostDashboard } from '../../helpers/api/dashboard';
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
import { apiPostDashboard } from '../../../helpers/api/dashboard';
interface TestDashboardResult {
id: number;

View File

@@ -18,9 +18,9 @@
*/
import { test, expect } from '@playwright/test';
import { DashboardPage } from '../../pages/DashboardPage';
import { Toast } from '../../components/core';
import { TIMEOUT } from '../../utils/constants';
import { DashboardPage } from '../../../pages/DashboardPage';
import { Toast } from '../../../components/core';
import { TIMEOUT } from '../../../utils/constants';
/**
* Dashboard Export E2E tests.

View File

@@ -18,16 +18,16 @@
*/
import { test, expect } from '@playwright/test';
import { AuthPage } from '../../pages/AuthPage';
import { DashboardPage } from '../../pages/DashboardPage';
import { apiPostTheme, apiDeleteTheme } from '../../helpers/api/theme';
import { AuthPage } from '../../../pages/AuthPage';
import { DashboardPage } from '../../../pages/DashboardPage';
import { apiPostTheme, apiDeleteTheme } from '../../../helpers/api/theme';
import {
apiPostDashboard,
apiPutDashboard,
apiDeleteDashboard,
} from '../../helpers/api/dashboard';
import { apiGet } from '../../helpers/api/requests';
import { TIMEOUT } from '../../utils/constants';
} from '../../../helpers/api/dashboard';
import { apiGet } from '../../../helpers/api/requests';
import { TIMEOUT } from '../../../utils/constants';
/**
* Dashboard Theme E2E tests.

View File

@@ -17,17 +17,17 @@
* under the License.
*/
import { testWithAssets as test, expect } from '../../helpers/fixtures';
import type { TestAssets } from '../../helpers/fixtures';
import { test, expect } from '../../../helpers/fixtures/testAssets';
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
import type { Page, TestInfo } from '@playwright/test';
import { ExplorePage } from '../../pages/ExplorePage';
import { CreateDatasetPage } from '../../pages/CreateDatasetPage';
import { DatasetListPage } from '../../pages/DatasetListPage';
import { ChartCreationPage } from '../../pages/ChartCreationPage';
import { ENDPOINTS } from '../../helpers/api/dataset';
import { waitForPost } from '../../helpers/api/intercepts';
import { expectStatusOneOf } from '../../helpers/api/assertions';
import { apiPostDatabase } from '../../helpers/api/database';
import { ExplorePage } from '../../../pages/ExplorePage';
import { CreateDatasetPage } from '../../../pages/CreateDatasetPage';
import { DatasetListPage } from '../../../pages/DatasetListPage';
import { ChartCreationPage } from '../../../pages/ChartCreationPage';
import { ENDPOINTS } from '../../../helpers/api/dataset';
import { waitForPost } from '../../../helpers/api/intercepts';
import { expectStatusOneOf } from '../../../helpers/api/assertions';
import { apiPostDatabase } from '../../../helpers/api/database';
interface GsheetsSetupResult {
sheetName: string;

View File

@@ -17,36 +17,37 @@
* under the License.
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import path from 'path';
import { DatasetListPage } from '../../pages/DatasetListPage';
import { ExplorePage } from '../../pages/ExplorePage';
import {
ConfirmDialog,
DeleteConfirmationModal,
DuplicateDatasetModal,
EditDatasetModal,
ImportDatasetModal,
} from '../../components/modals';
import { Toast } from '../../components/core';
test as testWithAssets,
expect,
} from '../../../helpers/fixtures/testAssets';
import path from 'path';
import { DatasetListPage } from '../../../pages/DatasetListPage';
import { ExplorePage } from '../../../pages/ExplorePage';
import { ConfirmDialog } from '../../../components/modals/ConfirmDialog';
import { DeleteConfirmationModal } from '../../../components/modals/DeleteConfirmationModal';
import { ImportDatasetModal } from '../../../components/modals/ImportDatasetModal';
import { DuplicateDatasetModal } from '../../../components/modals/DuplicateDatasetModal';
import { EditDatasetModal } from '../../../components/modals/EditDatasetModal';
import { Toast } from '../../../components/core/Toast';
import {
apiDeleteDataset,
apiGetDataset,
apiPostVirtualDataset,
getDatasetByName,
ENDPOINTS,
} from '../../helpers/api/dataset';
} from '../../../helpers/api/dataset';
import { createTestDataset } from './dataset-test-helpers';
import {
waitForGet,
waitForPost,
waitForPut,
} from '../../helpers/api/intercepts';
} from '../../../helpers/api/intercepts';
import {
expectStatusOneOf,
expectValidExportZip,
} from '../../helpers/api/assertions';
import { TIMEOUT } from '../../utils/constants';
} from '../../../helpers/api/assertions';
import { TIMEOUT } from '../../../utils/constants';
/**
* Extend testWithAssets with datasetListPage navigation (beforeEach equivalent).
@@ -457,12 +458,11 @@ test.describe('import dataset', () => {
testAssets,
}) => {
// Dataset name from fixture (test_netflix_1768502050965)
// Note: Fixture contains a Google Sheets dataset backed by shillelagh[gsheetsapi],
// which is a base dependency — import failure fails the test hard (no skip).
// Note: Fixture contains a Google Sheets dataset - test will skip if gsheets connector unavailable
const importedDatasetName = 'test_netflix_1768502050965';
const fixturePath = path.resolve(
__dirname,
'../../fixtures/dataset_export.zip',
'../../../fixtures/dataset_export.zip',
);
// Cleanup: Delete any existing dataset with the same name from previous runs
@@ -518,12 +518,25 @@ test.describe('import dataset', () => {
importResponse = await importResponsePromise;
}
// Fail hard if dataset import fails.
// The fixture contains a gsheets dataset; shillelagh[gsheetsapi] is a base
// dependency (pyproject.toml), so the engine is always available in CI.
// Check final import response for gsheets connector errors
if (!importResponse.ok()) {
const errorBody = await importResponse.json().catch(() => ({}));
throw new Error(`Import failed: ${JSON.stringify(errorBody)}`);
const errorText = JSON.stringify(errorBody);
// Skip test if gsheets connector not installed
if (
errorText.includes('gsheets') ||
errorText.includes('No such DB engine') ||
errorText.includes('Could not load database driver')
) {
await test.info().attach('skip-reason', {
body: `Import failed due to missing gsheets connector: ${errorText}`,
contentType: 'text/plain',
});
test.skip();
return;
}
// Re-throw other errors
throw new Error(`Import failed: ${errorText}`);
}
// Modal should close on success

View File

@@ -18,8 +18,8 @@
*/
import type { Page, TestInfo } from '@playwright/test';
import type { TestAssets } from '../../helpers/fixtures';
import { createTestVirtualDataset } from '../../helpers/api/dataset';
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
import { createTestVirtualDataset } from '../../../helpers/api/dataset';
interface TestDatasetResult {
id: number;

View File

@@ -21,7 +21,6 @@
import d3 from 'd3';
import { extent as d3Extent } from 'd3-array';
import {
ValueFormatter,
getNumberFormatter,
getSequentialSchemeRegistry,
CategoricalColorNamespace,
@@ -61,8 +60,7 @@ interface CountryMapProps {
height: number;
country: string;
linearColorScheme: string;
numberFormat?: string; // left for backward compatibility
formatter: ValueFormatter;
numberFormat: string;
colorScheme: string;
sliceId: number;
}
@@ -76,12 +74,13 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
height,
country,
linearColorScheme,
formatter,
numberFormat,
colorScheme,
sliceId,
} = props;
const container = element;
const format = getNumberFormatter(numberFormat);
const rawExtents = d3Extent(data, v => v.metric);
const extents: [number, number] =
rawExtents[0] != null && rawExtents[1] != null
@@ -183,7 +182,7 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
.style('top', `${position[1] + 30}px`)
.style('left', `${position[0]}px`)
.html(
`<div><strong>${getNameOfRegion(d)}</strong><br>${result.length > 0 ? formatter(result[0].metric) : ''}</div>`,
`<div><strong>${getNameOfRegion(d)}</strong><br>${result.length > 0 ? format(result[0].metric) : ''}</div>`,
);
};

View File

@@ -69,7 +69,6 @@ const config: ControlPanelConfig = {
},
},
],
['currency_format'],
['linear_color_scheme'],
],
},

View File

@@ -16,48 +16,26 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ChartProps, getValueFormatter } from '@superset-ui/core';
import { ChartProps } from '@superset-ui/core';
export default function transformProps(chartProps: ChartProps) {
const { width, height, formData, queriesData, datasource } = chartProps;
const { width, height, formData, queriesData } = chartProps;
const {
linearColorScheme,
numberFormat,
currencyFormat,
selectCountry,
colorScheme,
sliceId,
metric,
} = formData;
const {
currencyFormats = {},
columnFormats = {},
currencyCodeColumn,
} = datasource;
const { data, detected_currency: detectedCurrency } = queriesData[0];
const formatter = getValueFormatter(
metric,
currencyFormats,
columnFormats,
numberFormat,
currencyFormat,
undefined, // key - not needed for single-metric charts
data,
currencyCodeColumn,
detectedCurrency,
);
return {
width,
height,
data: queriesData[0].data,
country: selectCountry ? String(selectCountry).toLowerCase() : null,
linearColorScheme,
numberFormat, // left for backward compatibility
numberFormat,
colorScheme,
sliceId,
formatter,
};
}

View File

@@ -93,7 +93,6 @@ describe('CountryMap (legacy d3)', () => {
linearColorScheme="bnbColors"
colorScheme=""
numberFormat=".2f"
formatter={jest.fn().mockReturnValue('100')}
/>,
);
@@ -116,7 +115,6 @@ describe('CountryMap (legacy d3)', () => {
country="canada"
linearColorScheme="bnbColors"
colorScheme=""
formatter={jest.fn().mockReturnValue('100')}
/>,
);
@@ -146,7 +144,6 @@ describe('CountryMap (legacy d3)', () => {
country="canada"
linearColorScheme="bnbColors"
colorScheme=""
formatter={jest.fn().mockReturnValue('100')}
/>,
);

View File

@@ -24,13 +24,13 @@
"lib"
],
"dependencies": {
"@deck.gl/aggregation-layers": "~9.2.11",
"@deck.gl/aggregation-layers": "~9.2.5",
"@deck.gl/core": "~9.2.5",
"@deck.gl/extensions": "~9.2.9",
"@deck.gl/extensions": "~9.2.5",
"@deck.gl/geo-layers": "~9.2.5",
"@deck.gl/layers": "~9.2.5",
"@deck.gl/mesh-layers": "~9.2.5",
"@deck.gl/react": "~9.2.11",
"@deck.gl/react": "~9.2.5",
"@luma.gl/constants": "~9.2.5",
"@luma.gl/core": "~9.2.5",
"@luma.gl/engine": "~9.2.6",
@@ -46,7 +46,7 @@
"d3-color": "^3.1.0",
"d3-scale": "^4.0.2",
"handlebars": "^4.7.9",
"lodash": "^4.18.1",
"lodash": "^4.17.23",
"mousetrap": "^1.6.5",
"ngeohash": "^0.6.3",
"prop-types": "^15.8.1",

View File

@@ -32,7 +32,7 @@
"d3": "^3.5.17",
"d3-tip": "^0.9.1",
"fast-safe-stringify": "^2.1.1",
"lodash": "^4.18.1",
"lodash": "^4.17.23",
"nvd3-fork": "^2.0.5",
"dompurify": "^3.3.3",
"prop-types": "^15.8.1",

View File

@@ -28,15 +28,15 @@
"@types/react-table": "^7.7.20",
"classnames": "^2.5.1",
"d3-array": "^3.2.4",
"lodash": "^4.18.1",
"lodash": "^4.17.23",
"memoize-one": "^5.2.1",
"react-table": "^7.8.0",
"regenerator-runtime": "^0.14.1",
"xss": "^1.0.15"
},
"peerDependencies": {
"@ant-design/icons": "^5.6.1",
"@apache-superset/core": "*",
"@ant-design/icons": "^5.2.6",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@testing-library/dom": "^8.20.1",

View File

@@ -31,14 +31,14 @@
"dependencies": {
"@types/geojson": "^7946.0.16",
"geojson": "^0.5.0",
"lodash": "^4.18.1"
"lodash": "^4.17.23"
},
"peerDependencies": {
"@ant-design/icons": "^5.6.1",
"@apache-superset/core": "*",
"@ant-design/icons": "^5.2.6",
"@reduxjs/toolkit": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@apache-superset/core": "*",
"@types/react-redux": "*",
"geostyler": "^18.3.1",
"geostyler-data": "^1.0.0",

View File

@@ -28,7 +28,7 @@
"@types/react-redux": "^7.1.34",
"acorn": "^8.16.0",
"d3-array": "^3.2.4",
"lodash": "^4.18.1",
"lodash": "^4.17.23",
"zod": "^4.3.6"
},
"peerDependencies": {

View File

@@ -659,10 +659,7 @@ export default function transformProps(
for (const s of series) {
if (s.id) {
const columnsArr = labelMap[s.id];
const dimensionValue = columnsArr?.[idxSelectedDimension];
if (dimensionValue !== undefined) {
(s as any).stack = dimensionValue;
}
(s as any).stack = columnsArr[idxSelectedDimension];
}
}
}
@@ -685,24 +682,9 @@ export default function transformProps(
// For horizontal bar charts, set max/min from calculated data bounds
if (shouldCalculateDataBounds) {
// For stacked charts, clamp against the per-row stacked total to avoid
// clipping bars. Also keep dataMax so that mixed-sign stacks (where
// positive and negative values cancel in the algebraic row sum) cannot
// produce an axis max smaller than the largest individual positive segment.
const stackedTotalMax = Math.max(
...sortedTotalValues.filter(
(v): v is number => typeof v === 'number' && !Number.isNaN(v),
),
);
const effectiveDataMax = stack
? Math.max(dataMax ?? Number.NEGATIVE_INFINITY, stackedTotalMax)
: dataMax;
if (
effectiveDataMax !== undefined &&
Number.isFinite(effectiveDataMax) &&
yAxisMax === undefined
) {
yAxisMax = effectiveDataMax;
// Set max to actual data max to avoid gaps and ensure labels are visible
if (dataMax !== undefined && yAxisMax === undefined) {
yAxisMax = dataMax;
}
// Set min to actual data min for diverging bars
if (dataMin !== undefined && yAxisMin === undefined && dataMin < 0) {

View File

@@ -24,7 +24,6 @@ import {
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { supersetTheme } from '@apache-superset/core/theme';
import { StackControlsValue } from '../../../src/constants';
import type {
GridComponentOption,
LegendComponentOption,
@@ -728,97 +727,6 @@ describe('Bar Chart X-axis Time Formatting', () => {
});
});
describe('Horizontal stacked bar chart axis bounds', () => {
// Dataset where each series max = 4 but stacked total max = 8
const stackedData: ChartDataResponseResult[] = [
createTestQueryData(
[
{ team: 'Team A', High: 2, Low: 2, Medium: 4 },
{ team: 'Team B', High: null, Low: null, Medium: 3 },
{ team: 'Team C', High: null, Low: null, Medium: 1 },
],
{
colnames: ['team', 'High', 'Low', 'Medium'],
coltypes: [
GenericDataType.String,
GenericDataType.Numeric,
GenericDataType.Numeric,
GenericDataType.Numeric,
],
},
),
];
const horizontalStackedFormData: EchartsTimeseriesFormData = {
...(baseFormData as EchartsTimeseriesFormData),
x_axis: 'team',
metric: ['High', 'Low', 'Medium'],
groupby: [],
orientation: OrientationType.Horizontal,
seriesType: EchartsTimeseriesSeriesType.Bar,
stack: StackControlsValue.Stack,
truncateYAxis: true,
};
test('xAxis.max uses stacked total, not individual series max', () => {
// Individual series max = 4 (Medium), stacked total for Team A = 8
// Without the fix, xAxis.max would be 4, clipping bars and duplicating labels
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsTimeseriesFormData,
EchartsTimeseriesChartProps
>({
defaultFormData: horizontalStackedFormData,
defaultVizType: 'echarts_timeseries_bar',
defaultQueriesData: stackedData,
});
const { echartOptions } = transformProps(chartProps);
const xAxis = echartOptions.xAxis as any;
// xAxis.max must be >= stacked total (8), not capped at individual series max (4)
expect(xAxis.max).toBeGreaterThanOrEqual(8);
});
test('xAxis.max is not set to individual series max when stacking', () => {
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsTimeseriesFormData,
EchartsTimeseriesChartProps
>({
defaultFormData: horizontalStackedFormData,
defaultVizType: 'echarts_timeseries_bar',
defaultQueriesData: stackedData,
});
const { echartOptions } = transformProps(chartProps);
const xAxis = echartOptions.xAxis as any;
// 4 is the individual series max — the axis should not be clipped there
expect(xAxis.max).not.toBe(4);
});
test('non-stacked horizontal bar chart still uses individual series max', () => {
const nonStackedFormData: EchartsTimeseriesFormData = {
...horizontalStackedFormData,
stack: null,
};
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsTimeseriesFormData,
EchartsTimeseriesChartProps
>({
defaultFormData: nonStackedFormData,
defaultVizType: 'echarts_timeseries_bar',
defaultQueriesData: stackedData,
});
const { echartOptions } = transformProps(chartProps);
const xAxis = echartOptions.xAxis as any;
// Without stacking, xAxis.max should be based on individual series values
expect(xAxis.max).toBe(4);
});
});
describe('Legend layout regressions', () => {
const getBottomLegendLayout = (
chartWidth: number,

View File

@@ -37,7 +37,7 @@
"@superset-ui/core": "*",
"ace-builds": "^1.4.14",
"handlebars": "^4.7.8",
"lodash": "^4.18.1",
"lodash": "^4.17.11",
"dayjs": "^1.11.19",
"react": "^17.0.2",
"react-ace": "^10.1.0",

View File

@@ -30,12 +30,10 @@ import { debounceFunc } from '../../consts';
interface StyleCustomControlProps {
value: string;
htmlSanitization: boolean;
}
const StyleControl = (props: CustomControlConfig<StyleCustomControlProps>) => {
const theme = useTheme();
const htmlSanitization = props.htmlSanitization ?? true;
const defaultValue = props?.value
? undefined
@@ -50,16 +48,10 @@ const StyleControl = (props: CustomControlConfig<StyleCustomControlProps>) => {
<ControlHeader>
<div>
{props.label}
{htmlSanitization && (
<InfoTooltip
iconStyle={{ marginLeft: theme.sizeUnit }}
tooltip={t(
'CSS styles may be removed by server-side HTML sanitization. ' +
'If styles are not applying, ask your Superset administrator ' +
'to adjust the HTML sanitization configuration.',
)}
/>
)}
<InfoTooltip
iconStyle={{ marginLeft: theme.sizeUnit }}
tooltip={t('You need to configure HTML sanitization to use CSS')}
/>
</div>
</ControlHeader>
<CodeEditor
@@ -87,9 +79,8 @@ export const styleControlSetItem: ControlSetItem = {
valueKey: null,
validators: [],
mapStateToProps: ({ controls, common }) => ({
mapStateToProps: ({ controls }) => ({
value: controls?.handlebars_template?.value,
htmlSanitization: common?.conf?.HTML_SANITIZATION ?? true,
}),
},
};

View File

@@ -27,11 +27,12 @@
"access": "public"
},
"peerDependencies": {
"@ant-design/icons": "^5.6.1",
"react-icons": "5.4.0",
"@apache-superset/core": "*",
"@ant-design/icons": "^5.2.6",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"lodash": "^4.18.1",
"lodash": "^4.17.11",
"prop-types": "*",
"react": "^17.0.2",
"react-dom": "^17.0.2"

View File

@@ -22,11 +22,9 @@ import { safeHtmlSpan } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { supersetTheme } from '@apache-superset/core/theme';
import PropTypes from 'prop-types';
import {
CaretUpOutlined,
CaretDownOutlined,
ColumnHeightOutlined,
} from '@ant-design/icons';
import { FaSort } from 'react-icons/fa';
import { FaSortDown as FaSortDesc } from 'react-icons/fa';
import { FaSortUp as FaSortAsc } from 'react-icons/fa';
import {
ColorFormatters,
getTextColorForBackground,
@@ -857,7 +855,7 @@ export class TableRenderer extends Component<
if (activeSortColumn !== key) {
return (
<ColumnHeightOutlined
<FaSort
onClick={() =>
this.sortData(key, visibleColKeys, pivotData, maxRowIndex)
}
@@ -865,8 +863,7 @@ export class TableRenderer extends Component<
);
}
const SortIcon =
sortingOrder[key] === 'asc' ? CaretUpOutlined : CaretDownOutlined;
const SortIcon = sortingOrder[key] === 'asc' ? FaSortAsc : FaSortDesc;
return (
<SortIcon
onClick={() =>
@@ -876,9 +873,7 @@ export class TableRenderer extends Component<
);
};
const headerCellFormattedValue =
dateFormatters?.[attrName]?.(
convertToNumberIfNumeric(colKey[attrIdx]),
) ?? colKey[attrIdx];
dateFormatters?.[attrName]?.(convertToNumberIfNumeric(colKey[attrIdx])) ?? colKey[attrIdx];
const { backgroundColor, color } = getCellColor(
[attrName],
headerCellFormattedValue,

View File

@@ -24,19 +24,20 @@
"lib"
],
"dependencies": {
"react-icons": "5.4.0",
"@types/d3-array": "^3.2.2",
"@types/react-table": "^7.7.20",
"classnames": "^2.5.1",
"d3-array": "^3.2.4",
"lodash": "^4.18.1",
"lodash": "^4.17.23",
"memoize-one": "^5.2.1",
"react-table": "^7.8.0",
"regenerator-runtime": "^0.14.1",
"xss": "^1.0.15"
},
"peerDependencies": {
"@ant-design/icons": "^5.6.1",
"@apache-superset/core": "*",
"@ant-design/icons": "^5.2.6",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@testing-library/dom": "^8.20.1",

View File

@@ -35,11 +35,9 @@ import {
Row,
} from 'react-table';
import { extent as d3Extent, max as d3Max } from 'd3-array';
import {
CaretUpOutlined,
CaretDownOutlined,
ColumnHeightOutlined,
} from '@ant-design/icons';
import { FaSort } from 'react-icons/fa';
import { FaSortDown as FaSortDesc } from 'react-icons/fa';
import { FaSortUp as FaSortAsc } from 'react-icons/fa';
import cx from 'classnames';
import {
DataRecord,
@@ -223,9 +221,9 @@ function cellBackground({
function SortIcon<D extends object>({ column }: { column: ColumnInstance<D> }) {
const { isSorted, isSortedDesc } = column;
let sortIcon = <ColumnHeightOutlined />;
let sortIcon = <FaSort />;
if (isSorted) {
sortIcon = isSortedDesc ? <CaretDownOutlined /> : <CaretUpOutlined />;
sortIcon = isSortedDesc ? <FaSortDesc /> : <FaSortAsc />;
}
return sortIcon;
}

View File

@@ -34,9 +34,9 @@
"d3-scale": "^4.0.2"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@apache-superset/core": "*",
"@types/lodash": "*",
"@types/react": "*",
"react": "^17.0.2"

View File

@@ -74,16 +74,13 @@ interface ColumnElementProps {
keys?: { type: ColumnKeyTypeType }[];
type: string;
};
actions?: ReactNode;
}
const ColumnType = styled.div`
const NowrapDiv = styled.div`
white-space: nowrap;
color: ${({ theme }) => theme.colorTextDescription};
font-size: ${({ theme }) => theme.fontSizeSM}px;
`;
const ColumnElement = ({ column, actions }: ColumnElementProps) => {
const ColumnElement = ({ column }: ColumnElementProps) => {
let columnName: ReactNode = column.name;
let icons;
if (column.keys && column.keys.length > 0) {
@@ -113,9 +110,10 @@ const ColumnElement = ({ column, actions }: ColumnElementProps) => {
<div data-test="col-name">
{columnName}
{icons}
{actions}
</div>
<ColumnType>{column.type}</ColumnType>
<NowrapDiv className="text-muted">
<small> {column.type}</small>
</NowrapDiv>
</Flex>
);
};

View File

@@ -257,8 +257,6 @@ test('returns column keywords among selected tables', async () => {
},
);
// Both columns should be present since all cached table metadata
// for this database is included in autocomplete
await waitFor(() =>
expect(result.current).toContainEqual(
expect.objectContaining({
@@ -270,14 +268,31 @@ test('returns column keywords among selected tables', async () => {
),
);
expect(result.current).toContainEqual(
expect(result.current).not.toContainEqual(
expect.objectContaining({
name: unexpectedColumn,
value: unexpectedColumn,
score: COLUMN_AUTOCOMPLETE_SCORE,
meta: 'column',
}),
);
act(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
storeWithSqlLab.dispatch(
addTable(
{ id: expectQueryEditorId } as any,
unexpectedTable,
expectCatalog,
expectSchema,
) as any,
);
});
await waitFor(() =>
expect(result.current).toContainEqual(
expect.objectContaining({
name: unexpectedColumn,
}),
),
);
});
test('returns long keywords with detail', async () => {

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { useEffect, useMemo, useRef } from 'react';
import { useDispatch, useStore } from 'react-redux';
import { useSelector, useDispatch, shallowEqual, useStore } from 'react-redux';
import { t } from '@apache-superset/core/translation';
import { getExtensionsRegistry } from '@superset-ui/core';
@@ -30,10 +30,15 @@ import {
COLUMN_AUTOCOMPLETE_SCORE,
SQL_FUNCTIONS_AUTOCOMPLETE_SCORE,
} from 'src/SqlLab/constants';
import { schemaEndpoints } from 'src/hooks/apiResources';
import {
schemaEndpoints,
tableEndpoints,
skipToken,
} from 'src/hooks/apiResources';
import { api } from 'src/hooks/apiResources/queryApi';
import { useDatabaseFunctionsQuery } from 'src/hooks/apiResources/databaseFunctions';
import useEffectEvent from 'src/hooks/useEffectEvent';
import { SqlLabRootState } from 'src/SqlLab/types';
type Params = {
queryEditorId: string | number;
@@ -46,6 +51,7 @@ type Params = {
const EMPTY_LIST = [] as typeof sqlKeywords;
const { useQueryState: useSchemasQueryState } = schemaEndpoints.schemas;
const { useQueryState: useTablesQueryState } = tableEndpoints.tables;
const getHelperText = (value: string) =>
value.length > 30 && {
@@ -81,6 +87,16 @@ export function useKeywords(
},
{ skip: skipFetch || !dbId },
);
const { currentData: tableData } = useTablesQueryState(
{
dbId,
catalog,
schema,
forceRefresh: false,
},
{ skip: skipFetch || !dbId || !schema },
);
const { currentData: functionNames, isError } = useDatabaseFunctionsQuery(
{ dbId },
{ skip: skipFetch || !dbId },
@@ -94,64 +110,41 @@ export function useKeywords(
}
}, [dispatch, isError]);
const tablesForColumnMetadata = useSelector<SqlLabRootState, string[]>(
({ sqlLab }) =>
skip
? []
: (sqlLab?.tables ?? [])
.filter(table => table.queryEditorId === queryEditorId)
.map(table => table.name),
shallowEqual,
);
const store = useStore();
const apiState = store.getState()[api.reducerPath];
// Normalize catalog for comparison (null/undefined both mean "no catalog")
const normalizedCatalog = catalog ?? null;
// Collect all table names from all cached table-list queries for this database/catalog.
// This includes tables from any schema the user has expanded in the tree.
const allCachedTables = useMemo(() => {
if (skipFetch || !dbId || !apiState) return [];
const tables: { value: string; label: string; schema: string }[] = [];
const seen = new Set<string>();
const queries = apiState.queries ?? {};
for (const entry of Object.values(queries) as any[]) {
const arg = entry?.originalArgs;
if (
arg?.dbId === dbId &&
(arg?.catalog ?? null) === normalizedCatalog &&
entry?.status === 'fulfilled' &&
entry?.data?.options
) {
for (const table of entry.data.options) {
const key = `${arg.schema}.${table.value}`;
if (!seen.has(key)) {
seen.add(key);
tables.push({
value: table.value,
label: table.label ?? table.value,
schema: arg.schema,
});
}
}
}
}
return tables;
}, [dbId, normalizedCatalog, apiState, skipFetch]);
// Collect column names from all cached table-metadata queries for this database/catalog.
// This includes columns from any table the user has expanded in the tree.
const allColumns = useMemo(() => {
if (skipFetch || !dbId || !apiState) return [];
const columns = new Set<string>();
const queries = apiState.queries ?? {};
for (const entry of Object.values(queries) as any[]) {
const arg = entry?.originalArgs;
if (
entry?.status === 'fulfilled' &&
entry?.data?.columns &&
arg?.dbId === dbId &&
(arg?.catalog ?? null) === normalizedCatalog
) {
for (const col of entry.data.columns) {
columns.add(col.name);
}
}
}
tablesForColumnMetadata.forEach(table => {
tableEndpoints.tableMetadata
.select(
dbId && schema
? {
dbId,
catalog,
schema,
table,
}
: skipToken,
)({
[api.reducerPath]: apiState,
})
.data?.columns?.forEach(({ name }) => {
columns.add(name);
});
});
return [...columns];
}, [dbId, normalizedCatalog, apiState, skipFetch]);
}, [dbId, catalog, schema, apiState, tablesForColumnMetadata]);
const insertMatch = useEffectEvent((editor: Editor, data: any) => {
if (data.meta === 'table') {
@@ -160,7 +153,7 @@ export function useKeywords(
{ id: String(queryEditorId), dbId: dbId as number, tabViewId },
data.value,
catalog ?? null,
data.schema ?? schema ?? '',
schema ?? '',
false, // Don't auto-expand/switch tabs when adding via autocomplete
),
);
@@ -194,10 +187,9 @@ export function useKeywords(
const tableKeywords = useMemo(
() =>
allCachedTables.map(({ value, label, schema: tableSchema }) => ({
(tableData?.options ?? []).map(({ value, label }) => ({
name: label,
value,
schema: tableSchema,
score: TABLE_AUTOCOMPLETE_SCORE,
meta: 'table',
completer: {
@@ -205,7 +197,7 @@ export function useKeywords(
},
...getHelperText(value),
})),
[allCachedTables, insertMatch],
[tableData?.options, insertMatch],
);
const columnKeywords = useMemo(

View File

@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { css, styled, useTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import { ModalTrigger } from '@superset-ui/core/components';
import CodeSyntaxHighlighter from '@superset-ui/core/components/CodeSyntaxHighlighter';
@@ -41,12 +40,6 @@ interface TriggerNodeProps {
maxWidth: number;
}
const Title = styled.h4`
font-size: ${({ theme }) => theme.fontSizeLG}px;
margin: ${({ theme }) => theme.sizeUnit * 2}px 0;
font-weight: ${({ theme }) => theme.fontWeightStrong};
`;
const shrinkSql = (sql: string, maxLines: number, maxWidth: number) => {
const ssql = sql || '';
let lines = ssql.split('\n');
@@ -70,32 +63,14 @@ function TriggerNode({ shrink, sql, maxLines, maxWidth }: TriggerNodeProps) {
}
function HighlightSqlModal({ rawSql, sql }: HighlightedSqlModalTypes) {
const theme = useTheme();
const codeBlockStyle = {
border: 1,
borderColor: theme.colorBorder,
borderStyle: 'solid',
backgroundColor: theme.colorBgLayout,
fontSize: theme.fontSize * 0.9,
padding: theme.sizeUnit * 2,
};
return (
<div
css={css`
margin: -${theme.sizeUnit * 6}px;
`}
>
<Title>{t('Source SQL')}</Title>
<CodeSyntaxHighlighter language="sql" customStyle={codeBlockStyle}>
{sql}
</CodeSyntaxHighlighter>
<div>
<h4>{t('Source SQL')}</h4>
<CodeSyntaxHighlighter language="sql">{sql}</CodeSyntaxHighlighter>
{rawSql && rawSql !== sql && (
<div>
<Title>{t('Executed SQL')}</Title>
<CodeSyntaxHighlighter language="sql" customStyle={codeBlockStyle}>
{rawSql}
</CodeSyntaxHighlighter>
<h4>{t('Executed SQL')}</h4>
<CodeSyntaxHighlighter language="sql">{rawSql}</CodeSyntaxHighlighter>
</div>
)}
</div>

View File

@@ -89,7 +89,7 @@ const QueryLimitSelect = ({
>
<Button
size="small"
color="default"
color="primary"
variant="text"
showMarginRight={false}
>

View File

@@ -31,7 +31,7 @@ const SaveDatasetActionButton = ({
}: SaveDatasetActionButtonProps) => (
<>
<Button
color="default"
color="primary"
variant="text"
onClick={() => setShowSave(true)}
icon={<Icons.SaveOutlined />}
@@ -40,7 +40,7 @@ const SaveDatasetActionButton = ({
/>
{onSaveAsExplore && (
<Button
color="default"
color="primary"
variant="text"
onClick={() => onSaveAsExplore?.()}
icon={<Icons.TableOutlined />}

View File

@@ -233,7 +233,7 @@ const SaveQuery = ({
{t('Cancel')}
</Button>
<Button
buttonStyle={isSaved ? 'secondary' : 'primary'}
buttonStyle={isSaved ? undefined : 'primary'}
onClick={onSaveWrapper}
cta
>

View File

@@ -71,7 +71,7 @@ const ShareSqlLabQuery = ({
const tooltip = t('Copy query link to your clipboard');
return (
<Button
color="default"
color="primary"
variant="text"
tooltip={tooltip}
css={css`

View File

@@ -201,7 +201,7 @@ test('display no compatible schema found when schema api throws errors', async (
).toBeGreaterThanOrEqual(1),
);
const select = screen.getByRole('combobox', {
name: 'Select schema',
name: 'Select schema or type to search schemas',
});
userEvent.click(select);
expect(

View File

@@ -134,9 +134,9 @@ test('filters schemas when searching', async () => {
expect(screen.getByText('public')).toBeInTheDocument();
});
// All schemas are visible (no longer filtered to selected schema)
expect(screen.getByText('test_schema')).toBeInTheDocument();
expect(screen.getByText('information_schema')).toBeInTheDocument();
// Verify selected schemas are initially visible
expect(screen.queryByText('test_schema')).not.toBeInTheDocument();
expect(screen.queryByText('information_schema')).not.toBeInTheDocument();
const searchInput = screen.getByPlaceholderText(
'Enter a part of the object name',

View File

@@ -16,32 +16,21 @@
* specific language governing permissions and limitations
* under the License.
*/
import { css, styled, useTheme } from '@apache-superset/core/theme';
import { css, styled } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import type { NodeRendererProps } from 'react-arborist';
import { Icons, Typography } from '@superset-ui/core/components';
import { Icons, Tooltip, Typography } from '@superset-ui/core/components';
import RefreshLabel from '@superset-ui/core/components/RefreshLabel';
import ColumnElement from 'src/SqlLab/components/ColumnElement';
import { ActionButton } from '@superset-ui/core/components/ActionButton';
import copyTextToClipboard from 'src/utils/copy';
import type { TreeNodeData } from './types';
import IconButton from 'src/dashboard/components/IconButton';
import type { TreeNodeData, FetchLazyTablesParams } from './types';
const StyledColumnNode = styled.div`
& > .ant-flex {
flex: 1;
margin-right: ${({ theme }) => theme.sizeUnit * 4}px;
margin-right: ${({ theme }) => theme.sizeUnit * 1.5}px;
cursor: default;
}
.col-copy-action {
opacity: 0;
flex-shrink: 0;
margin-left: ${({ theme }) => theme.sizeUnit}px;
}
&:hover .col-copy-action {
opacity: 1;
}
`;
const getOpacity = (disableCheckbox: boolean | undefined) =>
@@ -78,19 +67,12 @@ export interface TreeNodeRendererProps extends NodeRendererProps<TreeNodeData> {
loadingNodes: Record<string, boolean>;
searchTerm: string;
catalog: string | null | undefined;
pinnedTableKeys: Set<string>;
selectStarMap: Record<string, string>;
handleRefreshTables: (params: {
dbId: number;
catalog: string | null | undefined;
schema: string;
}) => void;
fetchLazyTables: (params: FetchLazyTablesParams) => void;
handlePinTable: (
tableName: string,
schemaName: string,
catalogName: string | null,
) => void;
handleUnpinTable: (tableName: string, schemaName: string) => void;
}
const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
@@ -100,13 +82,9 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
loadingNodes,
searchTerm,
catalog,
pinnedTableKeys,
selectStarMap,
handleRefreshTables,
fetchLazyTables,
handlePinTable,
handleUnpinTable,
}) => {
const theme = useTheme();
const { data } = node;
const parts = data.id.split(':');
const [identifier, _dbId, schema, tableName] = parts;
@@ -131,9 +109,8 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
if (identifier === 'table') {
const TableTypeIcon =
data.tableType === 'view'
? Icons.FunctionOutlined
: Icons.TableOutlined;
data.tableType === 'view' ? Icons.EyeOutlined : Icons.TableOutlined;
// Show loading icon with table type icon when loading
if (isLoading) {
return (
<>
@@ -142,7 +119,15 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
</>
);
}
return <TableTypeIcon iconSize="l" />;
const ExpandIcon = isManuallyOpen
? Icons.MinusSquareOutlined
: Icons.PlusSquareOutlined;
return (
<>
<ExpandIcon iconSize="l" />
<TableTypeIcon iconSize="l" />
</>
);
}
return null;
@@ -177,24 +162,7 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
data-selected={node.isSelected}
onClick={() => node.select()}
>
<ColumnElement
column={data.columnData}
actions={
<span
className="col-copy-action"
onClick={e => e.stopPropagation()}
>
<ActionButton
label={`copy-col-${data.name}`}
tooltip={t('Copy column name')}
icon={<Icons.CopyOutlined iconSize="m" />}
onClick={() =>
copyTextToClipboard(() => Promise.resolve(data.name))
}
/>
</span>
}
/>
<ColumnElement column={data.columnData} />
</StyledColumnNode>
);
}
@@ -237,94 +205,38 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
<RefreshLabel
onClick={e => {
e.stopPropagation();
handleRefreshTables({
dbId: Number(_dbId),
fetchLazyTables({
dbId: _dbId,
catalog,
schema,
forceRefresh: true,
});
}}
tooltipContent={t('Force refresh table list')}
/>
</div>
)}
{identifier === 'table' &&
(() => {
const nodeDbId = Number(_dbId);
const tableKey = `${nodeDbId}:${schema}:${tableName}`;
const isPinned = pinnedTableKeys.has(tableKey);
const selectStar = selectStarMap[tableKey];
return (
<div
className="side-action-container"
role="menu"
onClick={e => e.stopPropagation()}
>
{isPinned && (
<div className="action-static">
<ActionButton
label={`pinned-${schema}-${tableName}`}
icon={
<Icons.PushpinFilled
iconSize="m"
css={css`
color: ${theme.colorTextDescription};
`}
/>
}
onClick={() => handleUnpinTable(tableName, schema)}
/>
</div>
)}
<div className="action-hover">
{selectStar && (
<ActionButton
label={`copy-select-${schema}-${tableName}`}
tooltip={t('Copy SELECT statement to the clipboard')}
icon={<Icons.CopyOutlined iconSize="m" />}
onClick={() =>
copyTextToClipboard(() => Promise.resolve(selectStar))
}
/>
)}
<ActionButton
label={
isPinned
? `unpin-${schema}-${tableName}`
: `pin-${schema}-${tableName}`
}
tooltip={
isPinned
? t('Unpin from the result panel')
: t('Pin to the result panel')
}
icon={
isPinned ? (
<Icons.PushpinFilled iconSize="m" />
) : (
<Icons.PushpinOutlined iconSize="m" />
)
}
onClick={() =>
isPinned
? handleUnpinTable(tableName, schema)
: handlePinTable(tableName, schema, catalog ?? null)
}
/>
</div>
<ActionButton
label={`toggle-${schema}-${tableName}`}
icon={
isManuallyOpen ? (
<Icons.UpOutlined iconSize="m" />
) : (
<Icons.DownOutlined iconSize="m" />
)
}
onClick={() => node.toggle()}
/>
</div>
);
})()}
{identifier === 'table' && (
<div
className="side-action-container"
role="menu"
css={css`
position: inherit;
`}
>
<IconButton
icon={
<Tooltip title={t('Pin to the result panel')}>
<Icons.PushpinOutlined iconSize="xl" />
</Tooltip>
}
onClick={e => {
e.stopPropagation();
handlePinTable(tableName, schema, catalog ?? null);
}}
/>
</div>
)}
</div>
);
};

View File

@@ -40,7 +40,7 @@ import {
} from '@superset-ui/core/components';
import type { SqlLabRootState } from 'src/SqlLab/types';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
import { addTable, removeTables } from 'src/SqlLab/actions/sqlLab';
import { addTable } from 'src/SqlLab/actions/sqlLab';
import PanelToolbar from 'src/components/PanelToolbar';
import { ViewLocations } from 'src/SqlLab/contributions';
import TreeNodeRenderer from './TreeNodeRenderer';
@@ -64,24 +64,16 @@ const StyledTreeContainer = styled.div`
&:hover {
background-color: ${({ theme }) => theme.colorBgTextHover};
.action-static {
display: none;
}
.action-hover {
display: flex;
.side-action-container {
opacity: 1;
}
}
&[data-selected='true'] {
background-color: ${({ theme }) => theme.colorBgTextActive};
.action-static {
display: none;
}
.action-hover {
display: flex;
.side-action-container {
opacity: 1;
}
}
}
@@ -106,21 +98,12 @@ const StyledTreeContainer = styled.div`
}
.side-action-container {
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: auto;
}
.action-static {
display: flex;
align-items: center;
}
.action-hover {
display: none;
align-items: center;
gap: ${({ theme }) => theme.sizeUnit * 0.5}px;
opacity: 0;
position: absolute;
right: ${({ theme }) => theme.sizeUnit * 1.5}px;
top: 50%;
transform: translateY(-50%);
z-index: ${({ theme }) => theme.zIndexPopupBase};
}
`;
@@ -136,20 +119,19 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
);
const queryEditor = useQueryEditor(queryEditorId, [
'dbId',
'schema',
'catalog',
'tabViewId',
]);
const { dbId, catalog } = queryEditor;
const editorId = queryEditor.tabViewId ?? queryEditor.id;
const { dbId, catalog, schema: selectedSchema } = queryEditor;
const pinnedTables = useMemo(
() =>
Object.fromEntries(
tables.map(({ queryEditorId, dbId, schema, name, persistData }) => [
editorId === queryEditorId ? `${dbId}:${schema}:${name}` : '',
queryEditor.id === queryEditorId ? `${dbId}:${schema}:${name}` : '',
persistData,
]),
),
[tables, editorId],
[tables, queryEditor.id],
);
// Tree data hook - manages schema/table/column data fetching and tree structure
@@ -158,47 +140,21 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
isFetching,
refetch,
loadingNodes,
selectStarMap,
handleToggle,
handleRefreshTables,
fetchLazyTables,
errorPayload,
} = useTreeData({
dbId,
catalog,
selectedSchema,
pinnedTables,
});
const pinnedTableKeys = useMemo(
() =>
new Set(
tables
.filter(({ queryEditorId: qeId }) => editorId === qeId)
.map(({ dbId, schema, name }) => `${dbId}:${schema}:${name}`),
),
[tables, editorId],
);
const handlePinTable = useCallback(
(tableName: string, schemaName: string, catalogName: string | null) =>
dispatch(addTable(queryEditor, tableName, catalogName, schemaName)),
[dispatch, queryEditor],
);
const handleUnpinTable = useCallback(
(tableName: string, schemaName: string) => {
const table = tables.find(
t =>
t.queryEditorId === editorId &&
t.dbId === dbId &&
t.schema === schemaName &&
t.name === tableName,
);
if (table) {
dispatch(removeTables([table]));
}
},
[dispatch, tables, editorId, dbId],
);
const [searchTerm, setSearchTerm] = useState('');
const handleSearchChange = useCallback(
({ target }: ChangeEvent<HTMLInputElement>) => setSearchTerm(target.value),
@@ -282,20 +238,14 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
loadingNodes={loadingNodes}
searchTerm={searchTerm}
catalog={catalog}
pinnedTableKeys={pinnedTableKeys}
selectStarMap={selectStarMap}
handleRefreshTables={handleRefreshTables}
fetchLazyTables={fetchLazyTables}
handlePinTable={handlePinTable}
handleUnpinTable={handleUnpinTable}
/>
),
[
catalog,
pinnedTableKeys,
selectStarMap,
handleRefreshTables,
fetchLazyTables,
handlePinTable,
handleUnpinTable,
loadingNodes,
manuallyOpenedNodes,
searchTerm,

View File

@@ -93,6 +93,7 @@ function treeDataReducer(
interface UseTreeDataParams {
dbId: number | undefined;
catalog: string | null | undefined;
selectedSchema: string | undefined;
pinnedTables: Record<string, TableMetaData | undefined>;
}
@@ -101,13 +102,8 @@ interface UseTreeDataResult {
isFetching: boolean;
refetch: () => void;
loadingNodes: Record<string, boolean>;
selectStarMap: Record<string, string>;
handleToggle: (id: string, isOpen: boolean) => Promise<void>;
handleRefreshTables: (params: {
dbId: number;
catalog: string | null | undefined;
schema: string;
}) => void;
fetchLazyTables: ReturnType<typeof useLazyTablesQuery>[0];
errorPayload: SupersetError | null;
}
@@ -120,6 +116,7 @@ const createEmptyNode = (parentId: string): TreeNodeData => ({
const useTreeData = ({
dbId,
catalog,
selectedSchema,
pinnedTables,
}: UseTreeDataParams): UseTreeDataResult => {
// Schema data from API
@@ -250,48 +247,14 @@ const useTreeData = ({
],
);
// Force-refresh the table list for a schema and update the tree
const handleRefreshTables = useCallback(
({
dbId: refreshDbId,
catalog: refreshCatalog,
schema,
}: {
dbId: number;
catalog: string | null | undefined;
schema: string;
}) => {
const schemaKey = `${refreshDbId}:${schema}`;
const nodeId = `schema:${refreshDbId}:${schema}`;
dispatch({ type: 'SET_LOADING_NODE', nodeId, loading: true });
fetchLazyTables({
dbId: refreshDbId,
catalog: refreshCatalog,
schema,
forceRefresh: true,
})
.unwrap()
.then(data => {
dispatch({ type: 'SET_TABLE_DATA', key: schemaKey, data });
})
.catch(error => {
dispatch({
type: 'SET_ERROR',
errorPayload: error?.errors?.[0] ?? null,
});
})
.finally(() => {
dispatch({ type: 'SET_LOADING_NODE', nodeId, loading: false });
});
},
[fetchLazyTables],
);
// Build tree data
const treeData = useMemo((): TreeNodeData[] => {
const data = schemaData?.map(schema => {
// Filter schemas if a schema is selected, otherwise show all
const filteredSchemaData = selectedSchema
? schemaData?.filter(schema => schema.value === selectedSchema)
: schemaData;
const data = filteredSchemaData?.map(schema => {
const schemaKey = `${dbId}:${schema.value}`;
const schemaId = `schema:${dbId}:${schema.value}`;
const tablesData = tableData?.[schemaKey];
@@ -353,31 +316,22 @@ const useTreeData = ({
});
return data ?? [];
}, [dbId, schemaData, tableData, tableSchemaData, pinnedTables]);
// Map of tableKey -> selectStar SQL from table metadata
const selectStarMap = useMemo(() => {
const map: Record<string, string> = {};
const addEntry = (key: string, meta: TableMetaData | undefined) => {
if (meta?.selectStar) {
map[key] = meta.selectStar;
}
};
Object.entries(tableSchemaData).forEach(([key, meta]) =>
addEntry(key, meta),
);
Object.entries(pinnedTables).forEach(([key, meta]) => addEntry(key, meta));
return map;
}, [tableSchemaData, pinnedTables]);
}, [
dbId,
schemaData,
tableData,
tableSchemaData,
pinnedTables,
selectedSchema,
]);
return {
treeData,
isFetching,
refetch,
loadingNodes,
selectStarMap,
handleToggle,
handleRefreshTables,
fetchLazyTables,
errorPayload,
};
};

View File

@@ -29,12 +29,9 @@ import {
} from 'spec/helpers/testing-library';
import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
import mockState from 'spec/fixtures/mockState';
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
import DrillByModal, { DrillByModalProps } from './DrillByModal';
setupAGGridModules();
// Mock the isEmbedded function
jest.mock('src/dashboard/util/isEmbedded', () => ({
isEmbedded: jest.fn(() => false),
@@ -409,9 +406,16 @@ describe('Table view with pagination', () => {
await waitFor(() => {
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
});
// Check that pagination is rendered (there's also a breadcrumb list)
const lists = screen.getAllByRole('list');
const paginationList = lists.find(list =>
list.className?.includes('pagination'),
);
expect(paginationList).toBeInTheDocument();
});
test('should render data in table view', async () => {
test('should handle pagination in table view', async () => {
await renderModal({
column: { column_name: 'state', verbose_name: null },
drillByConfig: {
@@ -428,9 +432,19 @@ describe('Table view with pagination', () => {
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
});
// Check that data is rendered in the grid
// Check that first page data is shown
expect(screen.getByText('State0')).toBeInTheDocument();
// Check pagination controls exist
const nextPageButton = screen.getByTitle('Next Page');
expect(nextPageButton).toBeInTheDocument();
// Click next page
userEvent.click(nextPageButton);
// Verify page changed (State0 should not be visible on page 2)
await waitFor(() => {
expect(screen.getByText('State0')).toBeInTheDocument();
expect(screen.queryByText('State0')).not.toBeInTheDocument();
});
});
@@ -528,12 +542,11 @@ describe('Table view with pagination', () => {
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
});
// ag-grid shows its own empty overlay when there are no rows
const tableContainer = screen.getByTestId('drill-by-results-table');
expect(tableContainer).toBeInTheDocument();
// Should show empty state
expect(screen.getByText('No data')).toBeInTheDocument();
});
test('should render grid in table view', async () => {
test('should handle sorting in table view', async () => {
await renderModal({
column: { column_name: 'state', verbose_name: null },
drillByConfig: {
@@ -550,7 +563,16 @@ describe('Table view with pagination', () => {
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
});
// Find sortable column header
const sortableHeaders = screen.getAllByTestId('sort-header');
expect(sortableHeaders.length).toBeGreaterThan(0);
// Click to sort
userEvent.click(sortableHeaders[0]);
// Table should still be rendered without crashes
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
});
});
});

View File

@@ -25,12 +25,25 @@ import {
within,
waitFor,
} from 'spec/helpers/testing-library';
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
import { useResultsTableView } from './useResultsTableView';
beforeAll(() => {
setupAGGridModules();
});
const capturedProps: any[] = [];
jest.mock(
'src/explore/components/DataTablesPane/components/SingleQueryResultPane',
() => {
const actual = jest.requireActual(
'src/explore/components/DataTablesPane/components/SingleQueryResultPane',
);
return {
...actual,
SingleQueryResultPane: (props: any) => {
capturedProps.push(props);
return actual.SingleQueryResultPane(props);
},
};
},
);
const MOCK_CHART_DATA_RESULT = [
{
@@ -79,9 +92,9 @@ test('Displays results table for 1 query', () => {
);
render(result.current, { useRedux: true });
expect(screen.queryByRole('tablist')).not.toBeInTheDocument();
expect(screen.getByText('name')).toBeInTheDocument();
expect(screen.getByText('sum__num')).toBeInTheDocument();
expect(screen.getByText('Michael')).toBeInTheDocument();
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getAllByTestId('sort-header')).toHaveLength(2);
expect(screen.getAllByTestId('table-row')).toHaveLength(4);
});
test('Displays results for 2 queries', async () => {
@@ -89,18 +102,60 @@ test('Displays results for 2 queries', async () => {
useResultsTableView(MOCK_CHART_DATA_RESULT, '1__table', true),
);
render(result.current, { useRedux: true });
const getActiveTabElement = () =>
document.querySelector('.ant-tabs-tabpane-active') as HTMLElement;
const tablistElement = screen.getByRole('tablist');
expect(tablistElement).toBeInTheDocument();
expect(within(tablistElement).getByText('Results 1')).toBeInTheDocument();
expect(within(tablistElement).getByText('Results 2')).toBeInTheDocument();
expect(screen.getByText('Michael')).toBeInTheDocument();
expect(within(getActiveTabElement()).getByRole('table')).toBeInTheDocument();
expect(
within(getActiveTabElement()).getAllByTestId('sort-header'),
).toHaveLength(2);
expect(
within(getActiveTabElement()).getAllByTestId('table-row'),
).toHaveLength(4);
userEvent.click(screen.getByText('Results 2'));
await waitFor(() => {
expect(screen.getByText('gender')).toBeInTheDocument();
expect(
within(getActiveTabElement()).getAllByTestId('sort-header'),
).toHaveLength(3);
});
expect(
within(getActiveTabElement()).getAllByTestId('table-row'),
).toHaveLength(2);
});
test('passes isPaginationSticky={false} to SingleQueryResultPane for single query', () => {
capturedProps.length = 0;
const { result } = renderHook(() =>
useResultsTableView(MOCK_CHART_DATA_RESULT.slice(0, 1), '1__table', true),
);
render(result.current, { useRedux: true });
expect(capturedProps.length).toBeGreaterThan(0);
capturedProps.forEach(props => {
expect(props).toMatchObject({
isPaginationSticky: false,
});
});
});
test('passes isPaginationSticky={false} to SingleQueryResultPane for multiple queries', () => {
capturedProps.length = 0;
const { result } = renderHook(() =>
useResultsTableView(MOCK_CHART_DATA_RESULT, '1__table', true),
);
render(result.current, { useRedux: true });
expect(capturedProps.length).toBeGreaterThanOrEqual(2);
capturedProps.forEach(props => {
expect(props).toMatchObject({
isPaginationSticky: false,
});
});
expect(screen.getByText('boy')).toBeInTheDocument();
});

View File

@@ -22,12 +22,13 @@ import { t } from '@apache-superset/core/translation';
import { SingleQueryResultPane } from 'src/explore/components/DataTablesPane/components/SingleQueryResultPane';
import Tabs from '@superset-ui/core/components/Tabs';
const ResultContainer = styled.div`
${() => css`
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
const DATA_SIZE = 15;
const PaginationContainer = styled.div`
${({ theme }) => css`
& .pagination-container {
bottom: ${-theme.sizeUnit * 4}px;
}
`}
`;
@@ -41,17 +42,19 @@ export const useResultsTableView = (
}
if (chartDataResult.length === 1) {
return (
<ResultContainer data-test="drill-by-results-table">
<PaginationContainer data-test="drill-by-results-table">
<SingleQueryResultPane
colnames={chartDataResult[0].colnames}
coltypes={chartDataResult[0].coltypes}
rowcount={chartDataResult[0].sql_rowcount}
data={chartDataResult[0].data}
dataSize={DATA_SIZE}
datasourceId={datasourceId}
isVisible
canDownload={canDownload}
isPaginationSticky={false}
/>
</ResultContainer>
</PaginationContainer>
);
}
return (
@@ -61,17 +64,19 @@ export const useResultsTableView = (
key: `result-tab-${index}`,
label: t('Results %s', index + 1),
children: (
<ResultContainer>
<PaginationContainer>
<SingleQueryResultPane
colnames={res.colnames}
coltypes={res.coltypes}
data={res.data}
rowcount={res.sql_rowcount}
dataSize={DATA_SIZE}
datasourceId={datasourceId}
isVisible
canDownload={canDownload}
isPaginationSticky={false}
/>
</ResultContainer>
</PaginationContainer>
),
}))}
/>

View File

@@ -214,7 +214,7 @@ test('Refresh should work', async () => {
expect(fetchMock.callHistory.calls(schemaApiRoute).length).toBe(0);
const select = screen.getByRole('combobox', {
name: 'Select schema: public',
name: 'Select schema or type to search schemas: public',
});
await userEvent.click(select);
@@ -331,7 +331,7 @@ test('Should schema select display options', async () => {
const props = createProps();
render(<DatabaseSelector {...props} />, { useRedux: true, store });
const select = screen.getByRole('combobox', {
name: 'Select schema: public',
name: 'Select schema or type to search schemas: public',
});
expect(select).toBeInTheDocument();
await userEvent.click(select);
@@ -379,7 +379,7 @@ test('Sends the correct schema when changing the schema', async () => {
rerender(<DatabaseSelector {...props} />);
expect(props.onSchemaChange).toHaveBeenCalledTimes(0);
const select = screen.getByRole('combobox', {
name: 'Select schema: public',
name: 'Select schema or type to search schemas: public',
});
expect(select).toBeInTheDocument();
await userEvent.click(select);

View File

@@ -515,12 +515,17 @@ export function DatabaseSelector({
function renderSchemaSelect() {
if (sqlLabMode) {
return renderSelectRow(t('Select schema'), null, null, {
displayValue: currentSchema?.label,
disabled: !currentDb || readOnly,
loading: loadingSchemas,
icon: <Icons.RightOutlined />,
});
return renderSelectRow(
t('Select schema or type to search schemas'),
null,
null,
{
displayValue: currentSchema?.label,
disabled: !currentDb || readOnly,
loading: loadingSchemas,
icon: <Icons.RightOutlined />,
},
);
}
const refreshIcon = !readOnly && (
<RefreshLabel
@@ -534,13 +539,13 @@ export function DatabaseSelector({
{renderSelectRow(
t('Schema'),
<Select
ariaLabel={t('Select schema')}
ariaLabel={t('Select schema or type to search schemas')}
disabled={!currentDb || readOnly}
labelInValue
loading={loadingSchemas}
name="select-schema"
notFoundContent={t('No compatible schema found')}
placeholder={t('Select schema')}
placeholder={t('Select schema or type to search schemas')}
onChange={item => changeSchema(item as SchemaOption)}
options={schemaOptions}
showSearch

View File

@@ -45,19 +45,11 @@ const DatasourceEditor = AsyncEsmComponent(
() => import('../components/DatasourceEditor'),
);
const MODAL_HEIGHT_VH = 90;
const TOP_MARGIN_VH = (100 - MODAL_HEIGHT_VH) / 2;
const StyledDatasourceModal = styled(Modal)`
top: ${TOP_MARGIN_VH}vh;
padding-bottom: 0;
&& .ant-modal-content {
max-height: none;
margin-top: 0;
margin-bottom: 0;
min-height: 500px;
min-width: 500px;
}
&& .ant-modal-body {
@@ -375,9 +367,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
}
responsive
resizable
resizableConfig={{
defaultSize: { width: 'auto', height: `${MODAL_HEIGHT_VH}vh` },
}}
resizableConfig={{ defaultSize: { width: 'auto', height: '900px' } }}
draggable
>
<DatasourceEditor

View File

@@ -33,6 +33,7 @@ export const FoldersToolbar = styled.div`
top: -${theme.margin}px; // offsets tabs component bottom margin
z-index: 10;
background: ${theme.colorBgContainer};
padding-top: ${theme.paddingMD}px;
display: flex;
flex-direction: column;
gap: ${theme.paddingLG}px;

View File

@@ -370,7 +370,6 @@ const StyledTableTabs = styled(Tabs)`
flex: 1;
min-height: 0;
overflow: auto;
padding-top: ${({ theme }) => theme.paddingMD}px;
}
.ant-tabs-content {
@@ -2526,20 +2525,18 @@ class DatasourceEditor extends PureComponent<
key: TABS_KEYS.SETTINGS,
label: t('Settings'),
children: (
<div style={{ overflowX: 'hidden' }}>
<Row gutter={16}>
<Col xs={24} md={12}>
<FormContainer>
{this.renderSettingsFieldset()}
</FormContainer>
</Col>
<Col xs={24} md={12}>
<FormContainer>
{this.renderAdvancedFieldset()}
</FormContainer>
</Col>
</Row>
</div>
<Row gutter={16}>
<Col xs={24} md={12}>
<FormContainer>
{this.renderSettingsFieldset()}
</FormContainer>
</Col>
<Col xs={24} md={12}>
<FormContainer>
{this.renderAdvancedFieldset()}
</FormContainer>
</Col>
</Row>
),
},
]}

View File

@@ -99,7 +99,6 @@ export function DatabaseErrorMessage({
<ErrorAlert
errorType={t('%s Error', extra?.engine_name || t('DB engine'))}
message={alertMessage}
messagePre
description={alertDescription}
type={level}
descriptionDetails={body}

View File

@@ -35,7 +35,6 @@ export const ErrorAlert: React.FC<ErrorAlertProps> = ({
description,
descriptionDetails,
descriptionDetailsCollapsed = true,
messagePre = false,
descriptionPre = true,
compact = false,
children,
@@ -70,20 +69,13 @@ export const ErrorAlert: React.FC<ErrorAlertProps> = ({
);
};
const preStyle = {
whiteSpace: 'pre-wrap' as const,
whiteSpace: 'pre-wrap',
fontFamily: theme.fontFamilyCode,
margin: `${theme.sizeUnit}px 0`,
};
const renderDescription = () => (
<div>
{message &&
(messagePre ? (
<Typography.Paragraph style={preStyle}>
{message}
</Typography.Paragraph>
) : (
<div>{message}</div>
))}
{message && <div>{message}</div>}
{description && (
<Typography.Paragraph
style={descriptionPre ? preStyle : {}}

View File

@@ -38,7 +38,6 @@ export interface ErrorAlertProps {
description?: React.ReactNode; // Text shown under the first line, not collapsible
descriptionDetails?: React.ReactNode | string; // Text shown under the first line, collapsible
descriptionDetailsCollapsed?: boolean; // Hides the collapsible section unless "Show more" is clicked, default true
messagePre?: boolean; // Uses pre-style on the message, default false
descriptionPre?: boolean; // Uses pre-style to break lines, default true
compact?: boolean; // Shows the error icon with tooltip and modal, default false
children?: React.ReactNode; // Additional content to show in the modal

View File

@@ -62,7 +62,7 @@ const PanelToolbar = ({
buttonSize="small"
aria-label={command?.title}
variant="text"
color="default"
color="primary"
/>
);
})
@@ -140,7 +140,7 @@ const PanelToolbar = ({
>
<Button
showMarginRight={false}
color="default"
color="primary"
variant="text"
css={css`
padding: 8px;

View File

@@ -93,7 +93,7 @@ test('renders with default props', async () => {
name: 'Select database or type to search databases',
});
const schemaSelect = screen.getByRole('combobox', {
name: 'Select schema: test_schema',
name: 'Select schema or type to search schemas: test_schema',
});
const tableSelect = screen.getByRole('combobox', {
name: 'Select table or type to search tables',

View File

@@ -181,7 +181,7 @@ const DetailsPanelPopover = ({
return (
<Popover
color={theme.colorBgElevated}
color={`${theme.colorBgElevated}cc`}
content={content}
open={popoverVisible}
onOpenChange={handleVisibility}

View File

@@ -288,15 +288,8 @@ const VerticalFilterBar: FC<VerticalBarProps> = ({
<Bar className={cx({ open: filtersOpen })} width={width}>
<Header toggleFiltersBar={toggleFiltersBar} />
{!isInitialized ? (
<div
css={{
height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Loading position="inline-centered" size="s" muted />
<div css={{ height }}>
<Loading size="s" muted />
</div>
) : (
<div css={tabPaneStyle} onScroll={onScroll}>

View File

@@ -654,8 +654,7 @@ test('reorders filters via keyboard (Space, ArrowDown, Space)', async () => {
}
}, 30000);
// eslint-disable-next-line jest/no-disabled-tests -- flaky timeout, see https://github.com/apache/superset/pull/39181
test.skip('updates sidebar title when filter name changes', async () => {
test('updates sidebar title when filter name changes', async () => {
const nativeFilterConfig = [
buildNativeFilter('NATIVE_FILTER-1', 'state', []),
buildNativeFilter('NATIVE_FILTER-2', 'country', []),

View File

@@ -648,7 +648,6 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
</span>
);
let isInSubSection = false;
const PanelChildren = (
<>
<StashFormDataContainer
@@ -666,19 +665,8 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
.filter(Boolean)}
/>
{isVisible && (
<div style={{ paddingLeft: theme.sizeUnit * 2 }}>
<>
{section.controlSetRows.map((controlSets, i) => {
// Detect sub-section header rows (React elements with no name prop)
const isSubSectionHeaderRow = controlSets.some(
item =>
isValidElement(item) &&
!(item as React.ReactElement<Record<string, unknown>>).props
?.name,
);
if (isSubSectionHeaderRow) {
isInSubSection = true;
}
const renderedControls = controlSets
.map(controlItem => {
if (!controlItem) {
@@ -727,23 +715,14 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
if (renderedControls.length === 0) {
return null;
}
// Indent controls within sub-sections for visual hierarchy
const paddingLeft =
isInSubSection && !isSubSectionHeaderRow
? theme.sizeUnit * 3
: 0;
return paddingLeft ? (
<div key={`controlsetrow-${i}`} style={{ paddingLeft }}>
<ControlRow controls={renderedControls} />
</div>
) : (
return (
<ControlRow
key={`controlsetrow-${i}`}
controls={renderedControls}
/>
);
})}
</div>
</>
)}
</>
);

View File

@@ -206,7 +206,6 @@ export const DataTablesPane = ({
<StyledDiv>
<SamplesPane
datasource={datasource}
queryFormData={queryFormData}
queryForce={queryForce}
isRequest={isRequest.samples}
setForceQuery={setForceQuery}

View File

@@ -20,7 +20,6 @@ import { styled, css } from '@apache-superset/core/theme';
import { GenericDataType } from '@apache-superset/core/common';
import { useMemo } from 'react';
import { zip } from 'lodash';
import { Select } from 'antd';
import {
CopyToClipboardButton,
FilterInput,
@@ -30,19 +29,10 @@ import { getTimeColumns } from 'src/explore/components/DataTableControl/utils';
import RowCountLabel from 'src/components/RowCountLabel';
import { TableControlsProps } from '../types';
export const ROW_LIMIT_OPTIONS = [
{ value: 100, label: '100 rows' },
{ value: 500, label: '500 rows' },
{ value: 1000, label: '1k rows' },
{ value: 5000, label: '5k rows' },
{ value: 10000, label: '10k rows' },
];
export const TableControlsWrapper = styled.div`
${({ theme }) => `
display: flex;
align-items: center;
padding-top: ${theme.sizeUnit * 2}px;
padding-bottom: ${theme.sizeUnit * 2}px;
justify-content: space-between;
@@ -61,9 +51,6 @@ export const TableControls = ({
rowcount,
isLoading,
canDownload,
rowLimit,
rowLimitOptions,
onRowLimitChange,
}: TableControlsProps) => {
const originalTimeColumns = getTimeColumns(datasourceId);
const formattedTimeColumns = zip<string, GenericDataType>(
@@ -89,23 +76,9 @@ export const TableControls = ({
css={css`
display: flex;
align-items: center;
gap: 8px;
`}
>
{onRowLimitChange && (
<Select
value={rowLimit}
onChange={onRowLimitChange}
options={rowLimitOptions}
size="small"
css={css`
min-width: 110px;
`}
/>
)}
{(!onRowLimitChange || rowcount < (rowLimit ?? Infinity)) && (
<RowCountLabel rowcount={rowcount} loading={isLoading} />
)}
<RowCountLabel rowcount={rowcount} loading={isLoading} />
{canDownload && (
<CopyToClipboardButton data={formattedData} columns={columnNames} />
)}

View File

@@ -20,96 +20,64 @@ import { useState, useEffect, useMemo, useCallback } from 'react';
import { t } from '@apache-superset/core/translation';
import { ensureIsArray } from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import { EmptyState, Loading } from '@superset-ui/core/components';
import { GenericDataType } from '@apache-superset/core/common';
import { GridTable } from 'src/components/GridTable';
import { GridSize } from 'src/components/GridTable/constants';
import { getDatasourceSamples } from 'src/components/Chart/chartAction';
import { getDrillPayload } from 'src/components/Chart/DrillDetail/utils';
import {
useGridColumns,
useKeywordFilter,
useGridHeight,
} from './useGridResultTable';
import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls';
TableView,
TableSize,
EmptyState,
Loading,
EmptyWrapperType,
} from '@superset-ui/core/components';
import { GenericDataType } from '@apache-superset/core/common';
import {
useFilteredTableData,
useTableColumns,
} from 'src/explore/components/DataTableControl';
import { getDatasourceSamples } from 'src/components/Chart/chartAction';
import { TableControls } from './DataTableControls';
import { SamplesPaneProps } from '../types';
const Error = styled.pre`
margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`};
`;
const GridContainer = styled.div`
flex: 1;
min-height: 0;
position: relative;
`;
const GridSizer = styled.div`
position: absolute;
inset: 0;
`;
const cache = new WeakMap();
const DEFAULT_ROW_LIMIT = 100;
const cache = new WeakSet();
export const SamplesPane = ({
isRequest,
datasource,
queryFormData,
queryForce,
setForceQuery,
dataSize = 50,
isVisible,
canDownload,
}: SamplesPaneProps) => {
const [filterText, setFilterText] = useState('');
const [rowLimit, setRowLimit] = useState(DEFAULT_ROW_LIMIT);
const [data, setData] = useState<Record<string, any>[][]>([]);
const [colnames, setColnames] = useState<string[]>([]);
const [coltypes, setColtypes] = useState<GenericDataType[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [rowcount, setRowCount] = useState<number>(0);
const [responseError, setResponseError] = useState<string>('');
const { gridHeight, measuredRef } = useGridHeight();
const datasourceId = useMemo(
() => `${datasource.id}__${datasource.type}`,
[datasource],
);
const handleRowLimitChange = useCallback(
(limit: number) => {
setRowLimit(limit);
cache.delete(queryFormData);
},
[queryFormData],
);
useEffect(() => {
if (isRequest && queryForce) {
cache.delete(queryFormData);
cache.delete(datasource);
}
if (isRequest && !cache.has(queryFormData)) {
if (isRequest && !cache.has(datasource)) {
setIsLoading(true);
const payload =
getDrillPayload(
queryFormData as Parameters<typeof getDrillPayload>[0],
) ?? {};
getDatasourceSamples(
datasource.type,
datasource.id,
queryForce,
payload,
rowLimit,
1,
)
getDatasourceSamples(datasource.type, datasource.id, queryForce, {})
.then(response => {
setData(ensureIsArray(response.data));
setColnames(ensureIsArray(response.colnames));
setColtypes(ensureIsArray(response.coltypes));
setRowCount(response.rowcount);
setResponseError('');
cache.set(queryFormData, true);
cache.add(datasource);
if (queryForce) {
setForceQuery?.(false);
}
@@ -124,10 +92,20 @@ export const SamplesPane = ({
setIsLoading(false);
});
}
}, [datasource, queryFormData, isRequest, queryForce, rowLimit]);
}, [datasource, isRequest, queryForce]);
const columns = useGridColumns(colnames, coltypes, data);
const keywordFilter = useKeywordFilter(filterText);
// this is to preserve the order of the columns, even if there are integer values,
// while also only grabbing the first column's keys
const columns = useTableColumns(
colnames,
coltypes,
data,
datasourceId,
isVisible,
{}, // moreConfig
true, // allowHTML
);
const filteredData = useFilteredTableData(filterText, data);
const handleInputChange = useCallback(
(input: string) => setFilterText(input),
@@ -142,7 +120,7 @@ export const SamplesPane = ({
return (
<>
<TableControls
data={data}
data={filteredData}
columnNames={colnames}
columnTypes={coltypes}
rowcount={rowcount}
@@ -150,9 +128,6 @@ export const SamplesPane = ({
onInputChange={handleInputChange}
isLoading={isLoading}
canDownload={canDownload}
rowLimit={rowLimit}
rowLimitOptions={ROW_LIMIT_OPTIONS}
onRowLimitChange={handleRowLimitChange}
/>
<Error>{responseError}</Error>
</>
@@ -167,7 +142,7 @@ export const SamplesPane = ({
return (
<>
<TableControls
data={data}
data={filteredData}
columnNames={colnames}
columnTypes={coltypes}
rowcount={rowcount}
@@ -175,22 +150,19 @@ export const SamplesPane = ({
onInputChange={handleInputChange}
isLoading={isLoading}
canDownload={canDownload}
rowLimit={rowLimit}
rowLimitOptions={ROW_LIMIT_OPTIONS}
onRowLimitChange={handleRowLimitChange}
/>
<GridContainer>
<GridSizer ref={measuredRef}>
<GridTable
data={data}
columns={columns}
height={gridHeight}
size={GridSize.Small}
externalFilter={keywordFilter}
showRowNumber
/>
</GridSizer>
</GridContainer>
<TableView
columns={columns}
data={filteredData}
pageSize={dataSize}
noDataText={t('No results')}
emptyWrapperType={EmptyWrapperType.Small}
className="table-condensed"
isPaginationSticky
showRowCount={false}
size={TableSize.Small}
small
/>
</>
);
};

View File

@@ -17,52 +17,46 @@
* under the License.
*/
import { useState, useCallback } from 'react';
import { styled } from '@apache-superset/core/theme';
import { GridTable } from 'src/components/GridTable';
import { GridSize } from 'src/components/GridTable/constants';
import { t } from '@apache-superset/core/translation';
import {
useGridColumns,
useKeywordFilter,
useGridHeight,
} from './useGridResultTable';
TableView,
TableSize,
EmptyWrapperType,
} from '@superset-ui/core/components';
import {
useFilteredTableData,
useTableColumns,
} from 'src/explore/components/DataTableControl';
import { TableControls } from './DataTableControls';
import { SingleQueryResultPaneProp } from '../types';
const ResultPaneContainer = styled.div`
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
`;
const GridContainer = styled.div`
flex: 1;
min-height: 0;
position: relative;
`;
const GridSizer = styled.div`
position: absolute;
inset: 0;
`;
export const SingleQueryResultPane = ({
data,
colnames,
coltypes,
rowcount,
datasourceId,
dataSize = 50,
isVisible,
canDownload,
columnDisplayNames,
rowLimit,
rowLimitOptions,
onRowLimitChange,
isPaginationSticky = true,
}: SingleQueryResultPaneProp) => {
const [filterText, setFilterText] = useState('');
const { gridHeight, measuredRef } = useGridHeight();
const columns = useGridColumns(colnames, coltypes, data, columnDisplayNames);
const keywordFilter = useKeywordFilter(filterText);
// this is to preserve the order of the columns, even if there are integer values,
// while also only grabbing the first column's keys
const columns = useTableColumns(
colnames,
coltypes,
data,
datasourceId,
isVisible,
{}, // moreConfig
true, // allowHTML
columnDisplayNames,
);
const filteredData = useFilteredTableData(filterText, data);
const handleInputChange = useCallback(
(input: string) => setFilterText(input),
@@ -70,9 +64,9 @@ export const SingleQueryResultPane = ({
);
return (
<ResultPaneContainer>
<>
<TableControls
data={data}
data={filteredData}
columnNames={colnames}
columnTypes={coltypes}
rowcount={rowcount}
@@ -80,22 +74,19 @@ export const SingleQueryResultPane = ({
onInputChange={handleInputChange}
isLoading={false}
canDownload={canDownload}
rowLimit={rowLimit}
rowLimitOptions={rowLimitOptions}
onRowLimitChange={onRowLimitChange}
/>
<GridContainer>
<GridSizer ref={measuredRef}>
<GridTable
data={data}
columns={columns}
height={gridHeight}
size={GridSize.Small}
externalFilter={keywordFilter}
showRowNumber
/>
</GridSizer>
</GridContainer>
</ResultPaneContainer>
<TableView
columns={columns}
size={TableSize.Small}
data={filteredData}
pageSize={dataSize}
noDataText={t('No results')}
emptyWrapperType={EmptyWrapperType.Small}
className="table-condensed"
isPaginationSticky={isPaginationSticky}
showRowCount={false}
small
/>
</>
);
};

View File

@@ -1,123 +0,0 @@
/**
* 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 { useMemo, useCallback, useRef, useState } from 'react';
import { getTimeFormatter, safeHtmlSpan, TimeFormats } from '@superset-ui/core';
import { Constants } from '@superset-ui/core/components';
import { GenericDataType } from '@apache-superset/core/common';
import type { IRowNode } from 'ag-grid-community';
const timeFormatter = getTimeFormatter(TimeFormats.DATABASE_DATETIME);
export function useGridColumns(
colnames: string[] | undefined,
coltypes: GenericDataType[] | undefined,
data: Record<string, any>[] | undefined,
columnDisplayNames?: Record<string, string>,
) {
return useMemo(
() =>
colnames && data?.length
? colnames
.filter((column: string) => Object.keys(data[0]).includes(column))
.map((key, index) => {
const colType = coltypes?.[index];
const headerLabel = columnDisplayNames?.[key] ?? key;
return {
label: key,
headerName: headerLabel,
render: ({ value }: { value: unknown }) => {
if (value === true) {
return Constants.BOOL_TRUE_DISPLAY;
}
if (value === false) {
return Constants.BOOL_FALSE_DISPLAY;
}
if (value === null) {
return (
<span style={{ color: 'var(--ant-color-text-tertiary)' }}>
{Constants.NULL_DISPLAY}
</span>
);
}
if (
colType === GenericDataType.Temporal &&
typeof value === 'number'
) {
return timeFormatter(value);
}
if (typeof value === 'string') {
return safeHtmlSpan(value);
}
return String(value);
},
};
})
: [],
[colnames, data, coltypes, columnDisplayNames],
);
}
export function useKeywordFilter(filterText: string) {
return useCallback(
(node: IRowNode) => {
if (filterText && node.data) {
const lowerFilter = filterText.toLowerCase();
return Object.values(node.data).some(
(value: unknown) =>
value != null && String(value).toLowerCase().includes(lowerFilter),
);
}
return true;
},
[filterText],
);
}
/**
* Measures the height of an absolutely-positioned inner element that fills
* its relative-positioned parent. Uses a callback ref so the ResizeObserver
* is created when the element mounts (which may be after initial render if
* the component conditionally renders a loading state first).
*/
export function useGridHeight(fallbackHeight = 400) {
const [gridHeight, setGridHeight] = useState(fallbackHeight);
const observerRef = useRef<ResizeObserver | null>(null);
const measuredRef = useCallback((el: HTMLDivElement | null) => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
if (!el) return;
const observer = new ResizeObserver(entries => {
const entry = entries[0];
if (entry) {
const h = Math.floor(entry.contentRect.height);
if (h > 0) {
setGridHeight(prev => (prev !== h ? h : prev));
}
}
});
observer.observe(el);
observerRef.current = observer;
}, []);
return { gridHeight, measuredRef };
}

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useState, useEffect, useMemo, ReactElement, useCallback } from 'react';
import { useState, useEffect, ReactElement, useCallback } from 'react';
import { t } from '@apache-superset/core/translation';
import {
@@ -29,7 +29,7 @@ import { EmptyState, Loading } from '@superset-ui/core/components';
import { getChartDataRequest } from 'src/components/Chart/chartAction';
import { ResultsPaneProps, QueryResultInterface } from '../types';
import { SingleQueryResultPane } from './SingleQueryResultPane';
import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls';
import { TableControls } from './DataTableControls';
const Error = styled.pre`
margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`};
@@ -53,6 +53,7 @@ export const useResultsPane = ({
errorMessage,
setForceQuery,
isVisible,
dataSize = 50,
canDownload,
columnDisplayNames,
}: ResultsPaneProps): ReactElement[] => {
@@ -60,8 +61,6 @@ export const useResultsPane = ({
queryFormData?.viz_type || queryFormData?.vizType,
);
const chartRowLimit = Number(queryFormData?.row_limit) || 10000;
const [rowLimit, setRowLimit] = useState(1000);
const [resultResp, setResultResp] = useState<QueryResultInterface[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [responseError, setResponseError] = useState<string>('');
@@ -70,28 +69,12 @@ export const useResultsPane = ({
const noOpInputChange = useCallback(() => {}, []);
// Never exceed the chart's own row_limit
const effectiveRowLimit = Math.min(rowLimit, chartRowLimit);
const cappedFormData = useMemo(
() => ({ ...queryFormData, row_limit: effectiveRowLimit }),
[queryFormData, effectiveRowLimit],
);
const handleRowLimitChange = useCallback(
(limit: number) => {
setRowLimit(limit);
cache.delete(cappedFormData);
},
[cappedFormData],
);
useEffect(() => {
// it's an invalid formData when gets a errorMessage
if (errorMessage) return;
if (isRequest && cache.has(cappedFormData)) {
if (isRequest && cache.has(queryFormData)) {
setResultResp(
ensureIsArray(cache.get(cappedFormData)) as QueryResultInterface[],
ensureIsArray(cache.get(queryFormData)) as QueryResultInterface[],
);
setResponseError('');
if (queryForce) {
@@ -99,10 +82,10 @@ export const useResultsPane = ({
}
setIsLoading(false);
}
if (isRequest && !cache.has(cappedFormData)) {
if (isRequest && !cache.has(queryFormData)) {
setIsLoading(true);
getChartDataRequest({
formData: cappedFormData,
formData: queryFormData,
force: queryForce,
resultFormat: 'json',
resultType: 'results',
@@ -111,7 +94,7 @@ export const useResultsPane = ({
.then(({ json }) => {
setResultResp(ensureIsArray(json.result) as QueryResultInterface[]);
setResponseError('');
cache.set(cappedFormData, json.result);
cache.set(queryFormData, json.result);
if (queryForce) {
setForceQuery?.(false);
}
@@ -125,7 +108,7 @@ export const useResultsPane = ({
setIsLoading(false);
});
}
}, [cappedFormData, isRequest]);
}, [queryFormData, isRequest]);
useEffect(() => {
if (errorMessage) {
@@ -180,13 +163,11 @@ export const useResultsPane = ({
colnames={result.colnames}
coltypes={result.coltypes}
rowcount={result.rowcount}
dataSize={dataSize}
datasourceId={queryFormData.datasource}
isVisible={isVisible}
canDownload={canDownload}
columnDisplayNames={columnDisplayNames}
rowLimit={rowLimit}
rowLimitOptions={ROW_LIMIT_OPTIONS}
onRowLimitChange={handleRowLimitChange}
/>
</StyledDiv>
));

View File

@@ -19,16 +19,16 @@
import fetchMock from 'fetch-mock';
import { FeatureFlag } from '@superset-ui/core';
import * as copyUtils from 'src/utils/copy';
import { render, screen, userEvent } from 'spec/helpers/testing-library';
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
import {
render,
screen,
userEvent,
waitForElementToBeRemoved,
} from 'spec/helpers/testing-library';
import { setItem, LocalStorageKeys } from 'src/utils/localStorageHelpers';
import { DataTablesPane } from '..';
import { createDataTablesPaneProps } from './fixture';
beforeAll(() => {
setupAGGridModules();
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('DataTablesPane', () => {
// Collapsed/expanded state depends on local storage
@@ -175,6 +175,12 @@ describe('DataTablesPane', () => {
expect(screen.getByText('Action')).toBeVisible();
expect(screen.getByText('Horror')).toBeVisible();
userEvent.type(screen.getByPlaceholderText('Search'), 'hor');
await waitForElementToBeRemoved(() => screen.queryByText('Action'));
expect(screen.getByText('Horror')).toBeVisible();
expect(screen.queryByText('Action')).not.toBeInTheDocument();
fetchMock.clearHistory().removeRoutes();
});

View File

@@ -20,18 +20,14 @@ import fetchMock from 'fetch-mock';
import {
screen,
render,
userEvent,
waitForElementToBeRemoved,
waitFor,
} from 'spec/helpers/testing-library';
import { ChartMetadata, ChartPlugin, VizType } from '@superset-ui/core';
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
import { ResultsPaneOnDashboard } from '../components';
import { createResultsPaneOnDashboardProps } from './fixture';
beforeAll(() => {
setupAGGridModules();
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('ResultsPaneOnDashboard', () => {
// render and render errorMessage
@@ -130,12 +126,12 @@ describe('ResultsPaneOnDashboard', () => {
expect(await findByText('Bad request')).toBeVisible();
});
test('force query, render', async () => {
test('force query, render and search', async () => {
const props = createResultsPaneOnDashboardProps({
sliceId: 144,
queryForce: true,
});
const { queryByText } = render(
const { queryByText, getByPlaceholderText } = render(
<ResultsPaneOnDashboard {...props} setForceQuery={setForceQuery} />,
{
useRedux: true,
@@ -148,6 +144,11 @@ describe('ResultsPaneOnDashboard', () => {
expect(queryByText('2 rows')).toBeVisible();
expect(queryByText('Action')).toBeVisible();
expect(queryByText('Horror')).toBeVisible();
userEvent.type(getByPlaceholderText('Search'), 'hor');
await waitForElementToBeRemoved(() => queryByText('Action'));
expect(queryByText('Horror')).toBeVisible();
expect(queryByText('Action')).not.toBeInTheDocument();
});
test('multiple results pane', async () => {

View File

@@ -17,19 +17,19 @@
* under the License.
*/
import fetchMock from 'fetch-mock';
import { render, waitFor } from 'spec/helpers/testing-library';
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
import {
render,
userEvent,
waitForElementToBeRemoved,
waitFor,
} from 'spec/helpers/testing-library';
import { SamplesPane } from '../components';
import { createSamplesPaneProps } from './fixture';
beforeAll(() => {
setupAGGridModules();
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('SamplesPane', () => {
fetchMock.post(
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=34&per_page=100&page=1',
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=34',
{
result: {
data: [],
@@ -40,7 +40,7 @@ describe('SamplesPane', () => {
);
fetchMock.post(
'end:/datasource/samples?force=true&datasource_type=table&datasource_id=35&per_page=100&page=1',
'end:/datasource/samples?force=true&datasource_type=table&datasource_id=35',
{
result: {
data: [
@@ -56,7 +56,7 @@ describe('SamplesPane', () => {
);
fetchMock.post(
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=36&per_page=100&page=1',
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=36',
400,
);
@@ -91,12 +91,12 @@ describe('SamplesPane', () => {
expect(await findByText('Error: Bad request')).toBeVisible();
});
test('force query, render', async () => {
test('force query, render and search', async () => {
const props = createSamplesPaneProps({
datasourceId: 35,
queryForce: true,
});
const { queryByText } = render(
const { queryByText, getByPlaceholderText } = render(
<SamplesPane {...props} setForceQuery={setForceQuery} />,
{
useRedux: true,
@@ -109,5 +109,10 @@ describe('SamplesPane', () => {
expect(queryByText('2 rows')).toBeVisible();
expect(queryByText('Action')).toBeVisible();
expect(queryByText('Horror')).toBeVisible();
userEvent.type(getByPlaceholderText('Search'), 'hor');
await waitForElementToBeRemoved(() => queryByText('Action'));
expect(queryByText('Horror')).toBeVisible();
expect(queryByText('Action')).not.toBeInTheDocument();
});
});

View File

@@ -90,10 +90,6 @@ export const createSamplesPaneProps = ({
({
isRequest,
datasource: { ...datasource, id: datasourceId },
queryFormData: {
...queryFormData,
datasource: `${datasourceId}__table`,
},
queryForce,
isVisible: true,
setForceQuery: jest.fn(),

View File

@@ -56,9 +56,10 @@ export interface ResultsPaneProps {
export interface SamplesPaneProps {
isRequest: boolean;
datasource: Datasource;
queryFormData: LatestQueryFormData;
queryForce: boolean;
setForceQuery?: SetForceQueryAction;
dataSize?: number;
// reload OriginalFormattedTimeColumns from localStorage when isVisible is true
isVisible: boolean;
canDownload: boolean;
}
@@ -73,9 +74,6 @@ export interface TableControlsProps {
isLoading: boolean;
rowcount: number;
canDownload: boolean;
rowLimit?: number;
rowLimitOptions?: { value: number; label: string }[];
onRowLimitChange?: (limit: number) => void;
}
export interface QueryResultInterface {
@@ -88,11 +86,11 @@ export interface QueryResultInterface {
export interface SingleQueryResultPaneProp extends QueryResultInterface {
// {datasource.id}__{datasource.type}, eg: 1__table
datasourceId?: string;
dataSize?: number;
// reload OriginalFormattedTimeColumns from localStorage when isVisible is true
isVisible: boolean;
canDownload: boolean;
// Optional map of column/metric name -> verbose label
columnDisplayNames?: Record<string, string>;
rowLimit?: number;
rowLimitOptions?: { value: number; label: string }[];
onRowLimitChange?: (limit: number) => void;
isPaginationSticky?: boolean;
}

View File

@@ -204,6 +204,7 @@ const ExploreChartPanel = ({
const {
ref: chartPanelRef,
observerRef: resizeObserverRef,
width: chartPanelWidth,
height: chartPanelHeight,
} = useResizeDetectorByObserver();
@@ -377,6 +378,7 @@ const ExploreChartPanel = ({
flex-direction: column;
padding-top: ${theme.sizeUnit * 2}px;
`}
ref={resizeObserverRef}
>
{vizTypeNeedsDataset && (
<Alert
@@ -479,6 +481,7 @@ const ExploreChartPanel = ({
</div>
),
[
resizeObserverRef,
showAlertBanner,
errorMessage,
onQuery,
@@ -530,7 +533,7 @@ const ExploreChartPanel = ({
document.body.className += ` ${standaloneClass}`;
}
return (
<div id="app" data-test="standalone-app">
<div id="app" data-test="standalone-app" ref={resizeObserverRef}>
{standaloneChartBody}
</div>
);

View File

@@ -31,16 +31,15 @@ export default function useResizeDetectorByObserver() {
setChartPanelSize({ width, height });
}
}, []);
// Use targetRef to observe the same element we measure
useResizeDetector({
const { ref: observerRef } = useResizeDetector({
refreshMode: 'debounce',
refreshRate: 300,
onResize,
targetRef: ref,
});
return {
ref,
observerRef,
width,
height,
};

Some files were not shown because too many files have changed in this diff Show More