mirror of
https://github.com/apache/superset.git
synced 2026-05-02 22:44:28 +00:00
Compare commits
76 Commits
issue31403
...
fix-explor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
235d4ea516 | ||
|
|
860f8cbe0f | ||
|
|
2fad87569c | ||
|
|
c6f54471dc | ||
|
|
7539138702 | ||
|
|
e0b1b557d7 | ||
|
|
bc5a5c2ac5 | ||
|
|
3a562dbe29 | ||
|
|
73b780a28c | ||
|
|
caeb6a6b7c | ||
|
|
19072074c5 | ||
|
|
f2037fa332 | ||
|
|
6c71800436 | ||
|
|
d63308ca37 | ||
|
|
63cceb6a79 | ||
|
|
b8b2bdedf9 | ||
|
|
d5017e60c3 | ||
|
|
2e80f2a473 | ||
|
|
4c2dd63464 | ||
|
|
62302ad8c3 | ||
|
|
ed659958f3 | ||
|
|
36de05fe36 | ||
|
|
a64609f4f3 | ||
|
|
140f0001f2 | ||
|
|
587fe4af63 | ||
|
|
3a3a6536b7 | ||
|
|
4f695e1b4d | ||
|
|
6ba9096870 | ||
|
|
5106afb07f | ||
|
|
2bd4131636 | ||
|
|
7e452df1cc | ||
|
|
a626d06415 | ||
|
|
d159edc9a6 | ||
|
|
96fa2cbd2b | ||
|
|
9750881193 | ||
|
|
3db92021c7 | ||
|
|
5ccfc530b2 | ||
|
|
5f9fc31ae2 | ||
|
|
8e811de564 | ||
|
|
027de6339b | ||
|
|
bf9aff19b5 | ||
|
|
b05764d070 | ||
|
|
7be2acb2f3 | ||
|
|
83ad1eca26 | ||
|
|
92747246fc | ||
|
|
7380a59ab8 | ||
|
|
e56f8cc4fb | ||
|
|
7c79b9ab61 | ||
|
|
a62be684a0 | ||
|
|
a3dfbd7bff | ||
|
|
12eb40db01 | ||
|
|
d796543f5a | ||
|
|
e5ae626433 | ||
|
|
8195574345 | ||
|
|
6b029997d9 | ||
|
|
7a64483e6b | ||
|
|
e424b55036 | ||
|
|
613e6d6cde | ||
|
|
b3a402d936 | ||
|
|
c7d175b842 | ||
|
|
851bbeea48 | ||
|
|
c5bce756f0 | ||
|
|
3239f058c8 | ||
|
|
7e0c634c3a | ||
|
|
a9ced5c881 | ||
|
|
ace5f9d8c2 | ||
|
|
0452d1515a | ||
|
|
0330fdeb00 | ||
|
|
f2ff24d811 | ||
|
|
c51132f824 | ||
|
|
b4cb815ebf | ||
|
|
08d1ddd9fb | ||
|
|
23ac4cb3a4 | ||
|
|
5662ecab15 | ||
|
|
9e27d682f6 | ||
|
|
f0fcdcc76a |
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
@@ -76,7 +76,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude PR Action
|
||||
uses: anthropics/claude-code-action@bee87b3258c251f9279e5371b0cc3660f37f3f77 # beta
|
||||
uses: anthropics/claude-code-action@6e2bd52842c65e914eba5c8badd17560bd26b5de # beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
timeout_minutes: "60"
|
||||
|
||||
2
.github/workflows/ephemeral-env-pr-close.yml
vendored
2
.github/workflows/ephemeral-env-pr-close.yml
vendored
@@ -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@183a1442edf41672e66566b7fc560e297a290896 # v2
|
||||
uses: aws-actions/amazon-ecr-login@f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78 # v2
|
||||
|
||||
- name: Delete ECR image tag
|
||||
if: steps.describe-services.outputs.active == 'true'
|
||||
|
||||
4
.github/workflows/ephemeral-env.yml
vendored
4
.github/workflows/ephemeral-env.yml
vendored
@@ -199,7 +199,7 @@ jobs:
|
||||
|
||||
- name: Login to Amazon ECR
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@183a1442edf41672e66566b7fc560e297a290896 # v2
|
||||
uses: aws-actions/amazon-ecr-login@f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78 # 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@183a1442edf41672e66566b7fc560e297a290896 # v2
|
||||
uses: aws-actions/amazon-ecr-login@f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78 # v2
|
||||
|
||||
- name: Check target image exists in ECR
|
||||
id: check-image
|
||||
|
||||
4
.github/workflows/superset-docs-deploy.yml
vendored
4
.github/workflows/superset-docs-deploy.yml
vendored
@@ -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@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
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@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
continue-on-error: true
|
||||
with:
|
||||
workflow: superset-python-integrationtest.yml
|
||||
|
||||
2
.github/workflows/superset-docs-verify.yml
vendored
2
.github/workflows/superset-docs-verify.yml
vendored
@@ -111,7 +111,7 @@ jobs:
|
||||
run: |
|
||||
yarn install --check-cache
|
||||
- name: Download database diagnostics from integration tests
|
||||
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
with:
|
||||
workflow: superset-python-integrationtest.yml
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
@@ -115,6 +115,10 @@ 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:-}
|
||||
@@ -137,6 +141,10 @@ 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
|
||||
@@ -157,6 +165,7 @@ 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
|
||||
|
||||
@@ -80,7 +80,7 @@ case "${1}" in
|
||||
;;
|
||||
app)
|
||||
echo "Starting web app (using development server)..."
|
||||
flask run -p $PORT --reload --debugger --without-threads --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*"
|
||||
flask run -p $PORT --reload --debugger --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*:*/superset-frontend/*"
|
||||
;;
|
||||
app-gunicorn)
|
||||
echo "Starting web app..."
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
"@swc/core": "^1.15.21",
|
||||
"antd": "^6.3.5",
|
||||
"baseline-browser-mapping": "^2.10.13",
|
||||
"caniuse-lite": "^1.0.30001784",
|
||||
"caniuse-lite": "^1.0.30001786",
|
||||
"docusaurus-plugin-openapi-docs": "^4.6.0",
|
||||
"docusaurus-theme-openapi-docs": "^4.6.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
|
||||
@@ -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.30001784:
|
||||
version "1.0.30001784"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz#bdf9733a0813ccfb5ab4d02f2127e62ee4c6b718"
|
||||
integrity sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==
|
||||
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==
|
||||
|
||||
ccount@^2.0.0:
|
||||
version "2.0.1"
|
||||
|
||||
@@ -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.5
|
||||
cryptography==46.0.6
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# paramiko
|
||||
@@ -209,7 +209,7 @@ mako==1.3.10
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# alembic
|
||||
markdown==3.8
|
||||
markdown==3.8.1
|
||||
# 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==11.3.0
|
||||
pillow==12.1.1
|
||||
# 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.2
|
||||
pyasn1==0.6.3
|
||||
# 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.19.1
|
||||
pygments==2.20.0
|
||||
# via rich
|
||||
pyjwt==2.10.1
|
||||
pyjwt==2.12.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# flask-appbuilder
|
||||
|
||||
@@ -178,7 +178,7 @@ croniter==6.0.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
cryptography==46.0.5
|
||||
cryptography==46.0.6
|
||||
# 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
|
||||
markdown==3.8.1
|
||||
# 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==11.3.0
|
||||
pillow==12.1.1
|
||||
# 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.2
|
||||
pyasn1==0.6.3
|
||||
# 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.19.1
|
||||
pygments==2.20.0
|
||||
# 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.10.1
|
||||
pyjwt==2.12.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
@@ -1,171 +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 { 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,42 +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 { 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');
|
||||
});
|
||||
});
|
||||
@@ -23,18 +23,6 @@ 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');
|
||||
}
|
||||
@@ -43,32 +31,13 @@ export const interceptV1ChartData = (alias = 'v1Data') => {
|
||||
cy.intercept('**/api/v1/chart/data*').as(alias);
|
||||
};
|
||||
|
||||
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() {
|
||||
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();
|
||||
|
||||
@@ -25,28 +25,6 @@ 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');
|
||||
|
||||
@@ -25,8 +25,3 @@ 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/';
|
||||
|
||||
12
superset-frontend/cypress-base/package-lock.json
generated
12
superset-frontend/cypress-base/package-lock.json
generated
@@ -5809,9 +5809,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="
|
||||
},
|
||||
"node_modules/lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
@@ -13072,9 +13072,9 @@
|
||||
}
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="
|
||||
},
|
||||
"lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
|
||||
1404
superset-frontend/package-lock.json
generated
1404
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -169,7 +169,7 @@
|
||||
"fast-glob": "^3.3.2",
|
||||
"fs-extra": "^11.3.4",
|
||||
"fuse.js": "^7.1.0",
|
||||
"geolib": "^3.3.4",
|
||||
"geolib": "^3.3.14",
|
||||
"geostyler": "^18.3.1",
|
||||
"geostyler-data": "^1.1.0",
|
||||
"geostyler-openlayers-parser": "^5.4.1",
|
||||
@@ -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": "^2.1.0",
|
||||
"simple-zstd": "^1.4.2",
|
||||
"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.0",
|
||||
"@babel/preset-env": "^7.29.2",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@babel/register": "^7.23.7",
|
||||
@@ -270,8 +270,8 @@
|
||||
"@storybook/test": "^8.6.15",
|
||||
"@storybook/test-runner": "^0.17.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.21",
|
||||
"@swc/plugin-emotion": "^14.7.0",
|
||||
"@swc/core": "^1.15.24",
|
||||
"@swc/plugin-emotion": "^14.8.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^8.20.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -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.0",
|
||||
"babel-loader": "^10.1.1",
|
||||
"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.10",
|
||||
"baseline-browser-mapping": "^2.10.13",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -340,10 +340,10 @@
|
||||
"jest-html-reporter": "^4.4.0",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"jsdom": "^29.0.1",
|
||||
"jsdom": "^29.0.2",
|
||||
"lerna": "^9.0.4",
|
||||
"lightningcss": "^1.32.0",
|
||||
"mini-css-extract-plugin": "^2.10.1",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxlint": "^1.56.0",
|
||||
"po2json": "^0.4.5",
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"lodash-es": "^4.17.23",
|
||||
"yeoman-generator": "^7.5.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"yeoman-generator": "^8.1.2",
|
||||
"yosay": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.28.6",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/preset-env": "^7.29.0",
|
||||
"@babel/preset-env": "^7.29.2",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"typescript": "^5.0.0",
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"tinycolor2": "*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"@emotion/react": "^11.4.1",
|
||||
"@superset-ui/core": "*",
|
||||
"@testing-library/dom": "^8.20.1",
|
||||
|
||||
@@ -21,7 +21,11 @@ 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};
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -24,23 +24,24 @@
|
||||
"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",
|
||||
"csstype": "^3.2.3",
|
||||
"core-js": "^3.49.0",
|
||||
"csstype": "^3.2.3",
|
||||
"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",
|
||||
@@ -50,13 +51,13 @@
|
||||
"pretty-ms": "^9.3.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-draggable": "^4.5.0",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-ultimate-pagination": "^1.3.2",
|
||||
"react-error-boundary": "6.0.0",
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-ultimate-pagination": "^1.3.2",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
@@ -64,7 +65,6 @@
|
||||
"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/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,7 +88,6 @@
|
||||
"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",
|
||||
@@ -101,6 +100,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -319,6 +319,11 @@ 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;
|
||||
|
||||
@@ -115,6 +115,7 @@ import {
|
||||
PlusSquareOutlined,
|
||||
PlusOutlined,
|
||||
ProfileOutlined,
|
||||
PushpinFilled,
|
||||
PushpinOutlined,
|
||||
QuestionCircleOutlined,
|
||||
ReloadOutlined,
|
||||
@@ -270,6 +271,7 @@ const AntdIcons = {
|
||||
PlusSquareOutlined,
|
||||
PlusOutlined,
|
||||
ProfileOutlined,
|
||||
PushpinFilled,
|
||||
PushpinOutlined,
|
||||
ReloadOutlined,
|
||||
QuestionCircleOutlined,
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
*/
|
||||
|
||||
// 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';
|
||||
|
||||
@@ -47,7 +47,7 @@ export class ChartListPage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the chart list page.
|
||||
* Navigate to the chart list page in table view.
|
||||
* Forces table view via URL parameter to avoid card view default
|
||||
* (ListviewsDefaultCardView feature flag may enable card view).
|
||||
*/
|
||||
@@ -55,6 +55,13 @@ 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
|
||||
@@ -63,6 +70,16 @@ 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.
|
||||
@@ -129,4 +146,24 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,21 +17,20 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import { ChartListPage } from '../../pages/ChartListPage';
|
||||
import {
|
||||
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';
|
||||
ChartPropertiesModal,
|
||||
DeleteConfirmationModal,
|
||||
} from '../../components/modals';
|
||||
import { Toast } from '../../components/core';
|
||||
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).
|
||||
@@ -261,6 +260,60 @@ 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,
|
||||
@@ -18,9 +18,9 @@
|
||||
*/
|
||||
|
||||
import type { Page, TestInfo } from '@playwright/test';
|
||||
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
|
||||
import { apiPostChart } from '../../../helpers/api/chart';
|
||||
import { getDatasetByName } from '../../../helpers/api/dataset';
|
||||
import type { TestAssets } from '../../helpers/fixtures';
|
||||
import { apiPostChart } from '../../helpers/api/chart';
|
||||
import { getDatasetByName } from '../../helpers/api/dataset';
|
||||
|
||||
interface TestChartResult {
|
||||
id: number;
|
||||
@@ -17,28 +17,27 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import { DashboardListPage } from '../../pages/DashboardListPage';
|
||||
import {
|
||||
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';
|
||||
DeleteConfirmationModal,
|
||||
ImportDatasetModal,
|
||||
} from '../../components/modals';
|
||||
import { Toast } from '../../components/core';
|
||||
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).
|
||||
@@ -18,8 +18,8 @@
|
||||
*/
|
||||
|
||||
import type { Page, TestInfo } from '@playwright/test';
|
||||
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
|
||||
import { apiPostDashboard } from '../../../helpers/api/dashboard';
|
||||
import type { TestAssets } from '../../helpers/fixtures';
|
||||
import { apiPostDashboard } from '../../helpers/api/dashboard';
|
||||
|
||||
interface TestDashboardResult {
|
||||
id: number;
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -17,17 +17,17 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from '../../../helpers/fixtures/testAssets';
|
||||
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
|
||||
import { testWithAssets as test, expect } from '../../helpers/fixtures';
|
||||
import type { TestAssets } from '../../helpers/fixtures';
|
||||
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;
|
||||
@@ -17,37 +17,36 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
test as testWithAssets,
|
||||
expect,
|
||||
} from '../../../helpers/fixtures/testAssets';
|
||||
import { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
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 { DatasetListPage } from '../../pages/DatasetListPage';
|
||||
import { ExplorePage } from '../../pages/ExplorePage';
|
||||
import {
|
||||
ConfirmDialog,
|
||||
DeleteConfirmationModal,
|
||||
DuplicateDatasetModal,
|
||||
EditDatasetModal,
|
||||
ImportDatasetModal,
|
||||
} from '../../components/modals';
|
||||
import { Toast } from '../../components/core';
|
||||
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).
|
||||
@@ -458,11 +457,12 @@ test.describe('import dataset', () => {
|
||||
testAssets,
|
||||
}) => {
|
||||
// Dataset name from fixture (test_netflix_1768502050965)
|
||||
// Note: Fixture contains a Google Sheets dataset - test will skip if gsheets connector unavailable
|
||||
// Note: Fixture contains a Google Sheets dataset backed by shillelagh[gsheetsapi],
|
||||
// which is a base dependency — import failure fails the test hard (no skip).
|
||||
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,25 +518,12 @@ test.describe('import dataset', () => {
|
||||
importResponse = await importResponsePromise;
|
||||
}
|
||||
|
||||
// Check final import response for gsheets connector errors
|
||||
// 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.
|
||||
if (!importResponse.ok()) {
|
||||
const errorBody = await importResponse.json().catch(() => ({}));
|
||||
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}`);
|
||||
throw new Error(`Import failed: ${JSON.stringify(errorBody)}`);
|
||||
}
|
||||
|
||||
// Modal should close on success
|
||||
@@ -18,8 +18,8 @@
|
||||
*/
|
||||
|
||||
import type { Page, TestInfo } from '@playwright/test';
|
||||
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
|
||||
import { createTestVirtualDataset } from '../../../helpers/api/dataset';
|
||||
import type { TestAssets } from '../../helpers/fixtures';
|
||||
import { createTestVirtualDataset } from '../../helpers/api/dataset';
|
||||
|
||||
interface TestDatasetResult {
|
||||
id: number;
|
||||
@@ -21,6 +21,7 @@
|
||||
import d3 from 'd3';
|
||||
import { extent as d3Extent } from 'd3-array';
|
||||
import {
|
||||
ValueFormatter,
|
||||
getNumberFormatter,
|
||||
getSequentialSchemeRegistry,
|
||||
CategoricalColorNamespace,
|
||||
@@ -60,7 +61,8 @@ interface CountryMapProps {
|
||||
height: number;
|
||||
country: string;
|
||||
linearColorScheme: string;
|
||||
numberFormat: string;
|
||||
numberFormat?: string; // left for backward compatibility
|
||||
formatter: ValueFormatter;
|
||||
colorScheme: string;
|
||||
sliceId: number;
|
||||
}
|
||||
@@ -74,13 +76,12 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
height,
|
||||
country,
|
||||
linearColorScheme,
|
||||
numberFormat,
|
||||
formatter,
|
||||
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
|
||||
@@ -182,7 +183,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 ? format(result[0].metric) : ''}</div>`,
|
||||
`<div><strong>${getNameOfRegion(d)}</strong><br>${result.length > 0 ? formatter(result[0].metric) : ''}</div>`,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
['currency_format'],
|
||||
['linear_color_scheme'],
|
||||
],
|
||||
},
|
||||
|
||||
@@ -16,26 +16,48 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ChartProps } from '@superset-ui/core';
|
||||
import { ChartProps, getValueFormatter } from '@superset-ui/core';
|
||||
|
||||
export default function transformProps(chartProps: ChartProps) {
|
||||
const { width, height, formData, queriesData } = chartProps;
|
||||
const { width, height, formData, queriesData, datasource } = 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,
|
||||
numberFormat, // left for backward compatibility
|
||||
colorScheme,
|
||||
sliceId,
|
||||
formatter,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ describe('CountryMap (legacy d3)', () => {
|
||||
linearColorScheme="bnbColors"
|
||||
colorScheme=""
|
||||
numberFormat=".2f"
|
||||
formatter={jest.fn().mockReturnValue('100')}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -115,6 +116,7 @@ describe('CountryMap (legacy d3)', () => {
|
||||
country="canada"
|
||||
linearColorScheme="bnbColors"
|
||||
colorScheme=""
|
||||
formatter={jest.fn().mockReturnValue('100')}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -144,6 +146,7 @@ describe('CountryMap (legacy d3)', () => {
|
||||
country="canada"
|
||||
linearColorScheme="bnbColors"
|
||||
colorScheme=""
|
||||
formatter={jest.fn().mockReturnValue('100')}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
@@ -24,13 +24,13 @@
|
||||
"lib"
|
||||
],
|
||||
"dependencies": {
|
||||
"@deck.gl/aggregation-layers": "~9.2.9",
|
||||
"@deck.gl/aggregation-layers": "~9.2.11",
|
||||
"@deck.gl/core": "~9.2.5",
|
||||
"@deck.gl/extensions": "~9.2.9",
|
||||
"@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.9",
|
||||
"@deck.gl/react": "~9.2.11",
|
||||
"@luma.gl/constants": "~9.2.5",
|
||||
"@luma.gl/core": "~9.2.5",
|
||||
"@luma.gl/engine": "~9.2.6",
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
"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",
|
||||
|
||||
@@ -34,11 +34,11 @@
|
||||
"lodash": "^4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"@apache-superset/core": "*",
|
||||
"@reduxjs/toolkit": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"@apache-superset/core": "*",
|
||||
"@types/react-redux": "*",
|
||||
"geostyler": "^18.3.1",
|
||||
"geostyler-data": "^1.0.0",
|
||||
|
||||
@@ -30,10 +30,12 @@ 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
|
||||
@@ -48,10 +50,16 @@ const StyleControl = (props: CustomControlConfig<StyleCustomControlProps>) => {
|
||||
<ControlHeader>
|
||||
<div>
|
||||
{props.label}
|
||||
<InfoTooltip
|
||||
iconStyle={{ marginLeft: theme.sizeUnit }}
|
||||
tooltip={t('You need to configure HTML sanitization to use CSS')}
|
||||
/>
|
||||
{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.',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ControlHeader>
|
||||
<CodeEditor
|
||||
@@ -79,8 +87,9 @@ export const styleControlSetItem: ControlSetItem = {
|
||||
valueKey: null,
|
||||
|
||||
validators: [],
|
||||
mapStateToProps: ({ controls }) => ({
|
||||
mapStateToProps: ({ controls, common }) => ({
|
||||
value: controls?.handlebars_template?.value,
|
||||
htmlSanitization: common?.conf?.HTML_SANITIZATION ?? true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -27,9 +27,8 @@
|
||||
"access": "public"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-icons": "5.4.0",
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"@apache-superset/core": "*",
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"lodash": "^4.18.1",
|
||||
|
||||
@@ -22,9 +22,11 @@ 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 { FaSort } from 'react-icons/fa';
|
||||
import { FaSortDown as FaSortDesc } from 'react-icons/fa';
|
||||
import { FaSortUp as FaSortAsc } from 'react-icons/fa';
|
||||
import {
|
||||
CaretUpOutlined,
|
||||
CaretDownOutlined,
|
||||
ColumnHeightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
ColorFormatters,
|
||||
getTextColorForBackground,
|
||||
@@ -855,7 +857,7 @@ export class TableRenderer extends Component<
|
||||
|
||||
if (activeSortColumn !== key) {
|
||||
return (
|
||||
<FaSort
|
||||
<ColumnHeightOutlined
|
||||
onClick={() =>
|
||||
this.sortData(key, visibleColKeys, pivotData, maxRowIndex)
|
||||
}
|
||||
@@ -863,7 +865,8 @@ export class TableRenderer extends Component<
|
||||
);
|
||||
}
|
||||
|
||||
const SortIcon = sortingOrder[key] === 'asc' ? FaSortAsc : FaSortDesc;
|
||||
const SortIcon =
|
||||
sortingOrder[key] === 'asc' ? CaretUpOutlined : CaretDownOutlined;
|
||||
return (
|
||||
<SortIcon
|
||||
onClick={() =>
|
||||
@@ -873,7 +876,9 @@ 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,
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"lib"
|
||||
],
|
||||
"dependencies": {
|
||||
"react-icons": "5.4.0",
|
||||
"@types/d3-array": "^3.2.2",
|
||||
"@types/react-table": "^7.7.20",
|
||||
"classnames": "^2.5.1",
|
||||
@@ -36,8 +35,8 @@
|
||||
"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",
|
||||
|
||||
@@ -35,9 +35,11 @@ import {
|
||||
Row,
|
||||
} from 'react-table';
|
||||
import { extent as d3Extent, max as d3Max } from 'd3-array';
|
||||
import { FaSort } from 'react-icons/fa';
|
||||
import { FaSortDown as FaSortDesc } from 'react-icons/fa';
|
||||
import { FaSortUp as FaSortAsc } from 'react-icons/fa';
|
||||
import {
|
||||
CaretUpOutlined,
|
||||
CaretDownOutlined,
|
||||
ColumnHeightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
DataRecord,
|
||||
@@ -221,9 +223,9 @@ function cellBackground({
|
||||
|
||||
function SortIcon<D extends object>({ column }: { column: ColumnInstance<D> }) {
|
||||
const { isSorted, isSortedDesc } = column;
|
||||
let sortIcon = <FaSort />;
|
||||
let sortIcon = <ColumnHeightOutlined />;
|
||||
if (isSorted) {
|
||||
sortIcon = isSortedDesc ? <FaSortDesc /> : <FaSortAsc />;
|
||||
sortIcon = isSortedDesc ? <CaretDownOutlined /> : <CaretUpOutlined />;
|
||||
}
|
||||
return sortIcon;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -74,13 +74,16 @@ interface ColumnElementProps {
|
||||
keys?: { type: ColumnKeyTypeType }[];
|
||||
type: string;
|
||||
};
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
const NowrapDiv = styled.div`
|
||||
const ColumnType = styled.div`
|
||||
white-space: nowrap;
|
||||
color: ${({ theme }) => theme.colorTextDescription};
|
||||
font-size: ${({ theme }) => theme.fontSizeSM}px;
|
||||
`;
|
||||
|
||||
const ColumnElement = ({ column }: ColumnElementProps) => {
|
||||
const ColumnElement = ({ column, actions }: ColumnElementProps) => {
|
||||
let columnName: ReactNode = column.name;
|
||||
let icons;
|
||||
if (column.keys && column.keys.length > 0) {
|
||||
@@ -110,10 +113,9 @@ const ColumnElement = ({ column }: ColumnElementProps) => {
|
||||
<div data-test="col-name">
|
||||
{columnName}
|
||||
{icons}
|
||||
{actions}
|
||||
</div>
|
||||
<NowrapDiv className="text-muted">
|
||||
<small> {column.type}</small>
|
||||
</NowrapDiv>
|
||||
<ColumnType>{column.type}</ColumnType>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -257,6 +257,8 @@ 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({
|
||||
@@ -268,31 +270,14 @@ test('returns column keywords among selected tables', async () => {
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current).not.toContainEqual(
|
||||
expect(result.current).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 () => {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useSelector, useDispatch, shallowEqual, useStore } from 'react-redux';
|
||||
import { useDispatch, useStore } from 'react-redux';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { getExtensionsRegistry } from '@superset-ui/core';
|
||||
|
||||
@@ -30,15 +30,10 @@ import {
|
||||
COLUMN_AUTOCOMPLETE_SCORE,
|
||||
SQL_FUNCTIONS_AUTOCOMPLETE_SCORE,
|
||||
} from 'src/SqlLab/constants';
|
||||
import {
|
||||
schemaEndpoints,
|
||||
tableEndpoints,
|
||||
skipToken,
|
||||
} from 'src/hooks/apiResources';
|
||||
import { schemaEndpoints } 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;
|
||||
@@ -51,7 +46,6 @@ 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 && {
|
||||
@@ -87,16 +81,6 @@ 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 },
|
||||
@@ -110,41 +94,64 @@ 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>();
|
||||
tablesForColumnMetadata.forEach(table => {
|
||||
tableEndpoints.tableMetadata
|
||||
.select(
|
||||
dbId && schema
|
||||
? {
|
||||
dbId,
|
||||
catalog,
|
||||
schema,
|
||||
table,
|
||||
}
|
||||
: skipToken,
|
||||
)({
|
||||
[api.reducerPath]: apiState,
|
||||
})
|
||||
.data?.columns?.forEach(({ name }) => {
|
||||
columns.add(name);
|
||||
});
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...columns];
|
||||
}, [dbId, catalog, schema, apiState, tablesForColumnMetadata]);
|
||||
}, [dbId, normalizedCatalog, apiState, skipFetch]);
|
||||
|
||||
const insertMatch = useEffectEvent((editor: Editor, data: any) => {
|
||||
if (data.meta === 'table') {
|
||||
@@ -153,7 +160,7 @@ export function useKeywords(
|
||||
{ id: String(queryEditorId), dbId: dbId as number, tabViewId },
|
||||
data.value,
|
||||
catalog ?? null,
|
||||
schema ?? '',
|
||||
data.schema ?? schema ?? '',
|
||||
false, // Don't auto-expand/switch tabs when adding via autocomplete
|
||||
),
|
||||
);
|
||||
@@ -187,9 +194,10 @@ export function useKeywords(
|
||||
|
||||
const tableKeywords = useMemo(
|
||||
() =>
|
||||
(tableData?.options ?? []).map(({ value, label }) => ({
|
||||
allCachedTables.map(({ value, label, schema: tableSchema }) => ({
|
||||
name: label,
|
||||
value,
|
||||
schema: tableSchema,
|
||||
score: TABLE_AUTOCOMPLETE_SCORE,
|
||||
meta: 'table',
|
||||
completer: {
|
||||
@@ -197,7 +205,7 @@ export function useKeywords(
|
||||
},
|
||||
...getHelperText(value),
|
||||
})),
|
||||
[tableData?.options, insertMatch],
|
||||
[allCachedTables, insertMatch],
|
||||
);
|
||||
|
||||
const columnKeywords = useMemo(
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* 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';
|
||||
@@ -40,6 +41,12 @@ 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');
|
||||
@@ -63,14 +70,32 @@ 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>
|
||||
<h4>{t('Source SQL')}</h4>
|
||||
<CodeSyntaxHighlighter language="sql">{sql}</CodeSyntaxHighlighter>
|
||||
<div
|
||||
css={css`
|
||||
margin: -${theme.sizeUnit * 6}px;
|
||||
`}
|
||||
>
|
||||
<Title>{t('Source SQL')}</Title>
|
||||
<CodeSyntaxHighlighter language="sql" customStyle={codeBlockStyle}>
|
||||
{sql}
|
||||
</CodeSyntaxHighlighter>
|
||||
{rawSql && rawSql !== sql && (
|
||||
<div>
|
||||
<h4>{t('Executed SQL')}</h4>
|
||||
<CodeSyntaxHighlighter language="sql">{rawSql}</CodeSyntaxHighlighter>
|
||||
<Title>{t('Executed SQL')}</Title>
|
||||
<CodeSyntaxHighlighter language="sql" customStyle={codeBlockStyle}>
|
||||
{rawSql}
|
||||
</CodeSyntaxHighlighter>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -89,7 +89,7 @@ const QueryLimitSelect = ({
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
color="default"
|
||||
variant="text"
|
||||
showMarginRight={false}
|
||||
>
|
||||
|
||||
@@ -31,7 +31,7 @@ const SaveDatasetActionButton = ({
|
||||
}: SaveDatasetActionButtonProps) => (
|
||||
<>
|
||||
<Button
|
||||
color="primary"
|
||||
color="default"
|
||||
variant="text"
|
||||
onClick={() => setShowSave(true)}
|
||||
icon={<Icons.SaveOutlined />}
|
||||
@@ -40,7 +40,7 @@ const SaveDatasetActionButton = ({
|
||||
/>
|
||||
{onSaveAsExplore && (
|
||||
<Button
|
||||
color="primary"
|
||||
color="default"
|
||||
variant="text"
|
||||
onClick={() => onSaveAsExplore?.()}
|
||||
icon={<Icons.TableOutlined />}
|
||||
|
||||
@@ -233,7 +233,7 @@ const SaveQuery = ({
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
buttonStyle={isSaved ? undefined : 'primary'}
|
||||
buttonStyle={isSaved ? 'secondary' : 'primary'}
|
||||
onClick={onSaveWrapper}
|
||||
cta
|
||||
>
|
||||
|
||||
@@ -71,7 +71,7 @@ const ShareSqlLabQuery = ({
|
||||
const tooltip = t('Copy query link to your clipboard');
|
||||
return (
|
||||
<Button
|
||||
color="primary"
|
||||
color="default"
|
||||
variant="text"
|
||||
tooltip={tooltip}
|
||||
css={css`
|
||||
|
||||
@@ -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 or type to search schemas',
|
||||
name: 'Select schema',
|
||||
});
|
||||
userEvent.click(select);
|
||||
expect(
|
||||
|
||||
@@ -134,9 +134,9 @@ test('filters schemas when searching', async () => {
|
||||
expect(screen.getByText('public')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify selected schemas are initially visible
|
||||
expect(screen.queryByText('test_schema')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('information_schema')).not.toBeInTheDocument();
|
||||
// All schemas are visible (no longer filtered to selected schema)
|
||||
expect(screen.getByText('test_schema')).toBeInTheDocument();
|
||||
expect(screen.getByText('information_schema')).toBeInTheDocument();
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
'Enter a part of the object name',
|
||||
|
||||
@@ -16,21 +16,32 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import type { NodeRendererProps } from 'react-arborist';
|
||||
import { Icons, Tooltip, Typography } from '@superset-ui/core/components';
|
||||
import { Icons, Typography } from '@superset-ui/core/components';
|
||||
import RefreshLabel from '@superset-ui/core/components/RefreshLabel';
|
||||
import ColumnElement from 'src/SqlLab/components/ColumnElement';
|
||||
import IconButton from 'src/dashboard/components/IconButton';
|
||||
import type { TreeNodeData, FetchLazyTablesParams } from './types';
|
||||
import { ActionButton } from '@superset-ui/core/components/ActionButton';
|
||||
import copyTextToClipboard from 'src/utils/copy';
|
||||
import type { TreeNodeData } from './types';
|
||||
|
||||
const StyledColumnNode = styled.div`
|
||||
& > .ant-flex {
|
||||
flex: 1;
|
||||
margin-right: ${({ theme }) => theme.sizeUnit * 1.5}px;
|
||||
margin-right: ${({ theme }) => theme.sizeUnit * 4}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) =>
|
||||
@@ -67,12 +78,19 @@ export interface TreeNodeRendererProps extends NodeRendererProps<TreeNodeData> {
|
||||
loadingNodes: Record<string, boolean>;
|
||||
searchTerm: string;
|
||||
catalog: string | null | undefined;
|
||||
fetchLazyTables: (params: FetchLazyTablesParams) => void;
|
||||
pinnedTableKeys: Set<string>;
|
||||
selectStarMap: Record<string, string>;
|
||||
handleRefreshTables: (params: {
|
||||
dbId: number;
|
||||
catalog: string | null | undefined;
|
||||
schema: string;
|
||||
}) => void;
|
||||
handlePinTable: (
|
||||
tableName: string,
|
||||
schemaName: string,
|
||||
catalogName: string | null,
|
||||
) => void;
|
||||
handleUnpinTable: (tableName: string, schemaName: string) => void;
|
||||
}
|
||||
|
||||
const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
|
||||
@@ -82,9 +100,13 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
|
||||
loadingNodes,
|
||||
searchTerm,
|
||||
catalog,
|
||||
fetchLazyTables,
|
||||
pinnedTableKeys,
|
||||
selectStarMap,
|
||||
handleRefreshTables,
|
||||
handlePinTable,
|
||||
handleUnpinTable,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { data } = node;
|
||||
const parts = data.id.split(':');
|
||||
const [identifier, _dbId, schema, tableName] = parts;
|
||||
@@ -109,8 +131,9 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
|
||||
|
||||
if (identifier === 'table') {
|
||||
const TableTypeIcon =
|
||||
data.tableType === 'view' ? Icons.EyeOutlined : Icons.TableOutlined;
|
||||
// Show loading icon with table type icon when loading
|
||||
data.tableType === 'view'
|
||||
? Icons.FunctionOutlined
|
||||
: Icons.TableOutlined;
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
@@ -119,15 +142,7 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
|
||||
</>
|
||||
);
|
||||
}
|
||||
const ExpandIcon = isManuallyOpen
|
||||
? Icons.MinusSquareOutlined
|
||||
: Icons.PlusSquareOutlined;
|
||||
return (
|
||||
<>
|
||||
<ExpandIcon iconSize="l" />
|
||||
<TableTypeIcon iconSize="l" />
|
||||
</>
|
||||
);
|
||||
return <TableTypeIcon iconSize="l" />;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -162,7 +177,24 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
|
||||
data-selected={node.isSelected}
|
||||
onClick={() => node.select()}
|
||||
>
|
||||
<ColumnElement column={data.columnData} />
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
</StyledColumnNode>
|
||||
);
|
||||
}
|
||||
@@ -205,38 +237,94 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
|
||||
<RefreshLabel
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
fetchLazyTables({
|
||||
dbId: _dbId,
|
||||
handleRefreshTables({
|
||||
dbId: Number(_dbId),
|
||||
catalog,
|
||||
schema,
|
||||
forceRefresh: true,
|
||||
});
|
||||
}}
|
||||
tooltipContent={t('Force refresh table list')}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 } from 'src/SqlLab/actions/sqlLab';
|
||||
import { addTable, removeTables } from 'src/SqlLab/actions/sqlLab';
|
||||
import PanelToolbar from 'src/components/PanelToolbar';
|
||||
import { ViewLocations } from 'src/SqlLab/contributions';
|
||||
import TreeNodeRenderer from './TreeNodeRenderer';
|
||||
@@ -64,16 +64,24 @@ const StyledTreeContainer = styled.div`
|
||||
&:hover {
|
||||
background-color: ${({ theme }) => theme.colorBgTextHover};
|
||||
|
||||
.side-action-container {
|
||||
opacity: 1;
|
||||
.action-static {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.action-hover {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-selected='true'] {
|
||||
background-color: ${({ theme }) => theme.colorBgTextActive};
|
||||
|
||||
.side-action-container {
|
||||
opacity: 1;
|
||||
.action-static {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.action-hover {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,12 +106,21 @@ const StyledTreeContainer = styled.div`
|
||||
}
|
||||
|
||||
.side-action-container {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
right: ${({ theme }) => theme.sizeUnit * 1.5}px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: ${({ theme }) => theme.zIndexPopupBase};
|
||||
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;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -119,19 +136,20 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
);
|
||||
const queryEditor = useQueryEditor(queryEditorId, [
|
||||
'dbId',
|
||||
'schema',
|
||||
'catalog',
|
||||
'tabViewId',
|
||||
]);
|
||||
const { dbId, catalog, schema: selectedSchema } = queryEditor;
|
||||
const { dbId, catalog } = queryEditor;
|
||||
const editorId = queryEditor.tabViewId ?? queryEditor.id;
|
||||
const pinnedTables = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
tables.map(({ queryEditorId, dbId, schema, name, persistData }) => [
|
||||
queryEditor.id === queryEditorId ? `${dbId}:${schema}:${name}` : '',
|
||||
editorId === queryEditorId ? `${dbId}:${schema}:${name}` : '',
|
||||
persistData,
|
||||
]),
|
||||
),
|
||||
[tables, queryEditor.id],
|
||||
[tables, editorId],
|
||||
);
|
||||
|
||||
// Tree data hook - manages schema/table/column data fetching and tree structure
|
||||
@@ -140,21 +158,47 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
isFetching,
|
||||
refetch,
|
||||
loadingNodes,
|
||||
selectStarMap,
|
||||
handleToggle,
|
||||
fetchLazyTables,
|
||||
handleRefreshTables,
|
||||
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),
|
||||
@@ -238,14 +282,20 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
loadingNodes={loadingNodes}
|
||||
searchTerm={searchTerm}
|
||||
catalog={catalog}
|
||||
fetchLazyTables={fetchLazyTables}
|
||||
pinnedTableKeys={pinnedTableKeys}
|
||||
selectStarMap={selectStarMap}
|
||||
handleRefreshTables={handleRefreshTables}
|
||||
handlePinTable={handlePinTable}
|
||||
handleUnpinTable={handleUnpinTable}
|
||||
/>
|
||||
),
|
||||
[
|
||||
catalog,
|
||||
fetchLazyTables,
|
||||
pinnedTableKeys,
|
||||
selectStarMap,
|
||||
handleRefreshTables,
|
||||
handlePinTable,
|
||||
handleUnpinTable,
|
||||
loadingNodes,
|
||||
manuallyOpenedNodes,
|
||||
searchTerm,
|
||||
|
||||
@@ -93,7 +93,6 @@ function treeDataReducer(
|
||||
interface UseTreeDataParams {
|
||||
dbId: number | undefined;
|
||||
catalog: string | null | undefined;
|
||||
selectedSchema: string | undefined;
|
||||
pinnedTables: Record<string, TableMetaData | undefined>;
|
||||
}
|
||||
|
||||
@@ -102,8 +101,13 @@ interface UseTreeDataResult {
|
||||
isFetching: boolean;
|
||||
refetch: () => void;
|
||||
loadingNodes: Record<string, boolean>;
|
||||
selectStarMap: Record<string, string>;
|
||||
handleToggle: (id: string, isOpen: boolean) => Promise<void>;
|
||||
fetchLazyTables: ReturnType<typeof useLazyTablesQuery>[0];
|
||||
handleRefreshTables: (params: {
|
||||
dbId: number;
|
||||
catalog: string | null | undefined;
|
||||
schema: string;
|
||||
}) => void;
|
||||
errorPayload: SupersetError | null;
|
||||
}
|
||||
|
||||
@@ -116,7 +120,6 @@ const createEmptyNode = (parentId: string): TreeNodeData => ({
|
||||
const useTreeData = ({
|
||||
dbId,
|
||||
catalog,
|
||||
selectedSchema,
|
||||
pinnedTables,
|
||||
}: UseTreeDataParams): UseTreeDataResult => {
|
||||
// Schema data from API
|
||||
@@ -247,14 +250,48 @@ 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[] => {
|
||||
// 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 data = schemaData?.map(schema => {
|
||||
const schemaKey = `${dbId}:${schema.value}`;
|
||||
const schemaId = `schema:${dbId}:${schema.value}`;
|
||||
const tablesData = tableData?.[schemaKey];
|
||||
@@ -316,22 +353,31 @@ const useTreeData = ({
|
||||
});
|
||||
|
||||
return data ?? [];
|
||||
}, [
|
||||
dbId,
|
||||
schemaData,
|
||||
tableData,
|
||||
tableSchemaData,
|
||||
pinnedTables,
|
||||
selectedSchema,
|
||||
]);
|
||||
}, [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]);
|
||||
|
||||
return {
|
||||
treeData,
|
||||
isFetching,
|
||||
refetch,
|
||||
loadingNodes,
|
||||
selectStarMap,
|
||||
handleToggle,
|
||||
fetchLazyTables,
|
||||
handleRefreshTables,
|
||||
errorPayload,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -29,9 +29,12 @@ 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),
|
||||
@@ -406,16 +409,9 @@ 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 handle pagination in table view', async () => {
|
||||
test('should render data in table view', async () => {
|
||||
await renderModal({
|
||||
column: { column_name: 'state', verbose_name: null },
|
||||
drillByConfig: {
|
||||
@@ -432,19 +428,9 @@ describe('Table view with pagination', () => {
|
||||
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 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)
|
||||
// Check that data is rendered in the grid
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('State0')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('State0')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -542,11 +528,12 @@ describe('Table view with pagination', () => {
|
||||
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should show empty state
|
||||
expect(screen.getByText('No data')).toBeInTheDocument();
|
||||
// ag-grid shows its own empty overlay when there are no rows
|
||||
const tableContainer = screen.getByTestId('drill-by-results-table');
|
||||
expect(tableContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should handle sorting in table view', async () => {
|
||||
test('should render grid in table view', async () => {
|
||||
await renderModal({
|
||||
column: { column_name: 'state', verbose_name: null },
|
||||
drillByConfig: {
|
||||
@@ -563,16 +550,7 @@ 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
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,25 +25,12 @@ import {
|
||||
within,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { useResultsTableView } from './useResultsTableView';
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
beforeAll(() => {
|
||||
setupAGGridModules();
|
||||
});
|
||||
|
||||
const MOCK_CHART_DATA_RESULT = [
|
||||
{
|
||||
@@ -92,9 +79,9 @@ test('Displays results table for 1 query', () => {
|
||||
);
|
||||
render(result.current, { useRedux: true });
|
||||
expect(screen.queryByRole('tablist')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId('sort-header')).toHaveLength(2);
|
||||
expect(screen.getAllByTestId('table-row')).toHaveLength(4);
|
||||
expect(screen.getByText('name')).toBeInTheDocument();
|
||||
expect(screen.getByText('sum__num')).toBeInTheDocument();
|
||||
expect(screen.getByText('Michael')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Displays results for 2 queries', async () => {
|
||||
@@ -102,60 +89,18 @@ 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(within(getActiveTabElement()).getByRole('table')).toBeInTheDocument();
|
||||
expect(
|
||||
within(getActiveTabElement()).getAllByTestId('sort-header'),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
within(getActiveTabElement()).getAllByTestId('table-row'),
|
||||
).toHaveLength(4);
|
||||
expect(screen.getByText('Michael')).toBeInTheDocument();
|
||||
|
||||
userEvent.click(screen.getByText('Results 2'));
|
||||
|
||||
await waitFor(() => {
|
||||
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('gender')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('boy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -22,13 +22,12 @@ 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 DATA_SIZE = 15;
|
||||
|
||||
const PaginationContainer = styled.div`
|
||||
${({ theme }) => css`
|
||||
& .pagination-container {
|
||||
bottom: ${-theme.sizeUnit * 4}px;
|
||||
}
|
||||
const ResultContainer = styled.div`
|
||||
${() => css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -42,19 +41,17 @@ export const useResultsTableView = (
|
||||
}
|
||||
if (chartDataResult.length === 1) {
|
||||
return (
|
||||
<PaginationContainer data-test="drill-by-results-table">
|
||||
<ResultContainer 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}
|
||||
/>
|
||||
</PaginationContainer>
|
||||
</ResultContainer>
|
||||
);
|
||||
}
|
||||
return (
|
||||
@@ -64,19 +61,17 @@ export const useResultsTableView = (
|
||||
key: `result-tab-${index}`,
|
||||
label: t('Results %s', index + 1),
|
||||
children: (
|
||||
<PaginationContainer>
|
||||
<ResultContainer>
|
||||
<SingleQueryResultPane
|
||||
colnames={res.colnames}
|
||||
coltypes={res.coltypes}
|
||||
data={res.data}
|
||||
rowcount={res.sql_rowcount}
|
||||
dataSize={DATA_SIZE}
|
||||
datasourceId={datasourceId}
|
||||
isVisible
|
||||
canDownload={canDownload}
|
||||
isPaginationSticky={false}
|
||||
/>
|
||||
</PaginationContainer>
|
||||
</ResultContainer>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
|
||||
@@ -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 or type to search schemas: public',
|
||||
name: 'Select schema: 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 or type to search schemas: public',
|
||||
name: 'Select schema: 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 or type to search schemas: public',
|
||||
name: 'Select schema: public',
|
||||
});
|
||||
expect(select).toBeInTheDocument();
|
||||
await userEvent.click(select);
|
||||
|
||||
@@ -515,17 +515,12 @@ export function DatabaseSelector({
|
||||
|
||||
function renderSchemaSelect() {
|
||||
if (sqlLabMode) {
|
||||
return renderSelectRow(
|
||||
t('Select schema or type to search schemas'),
|
||||
null,
|
||||
null,
|
||||
{
|
||||
displayValue: currentSchema?.label,
|
||||
disabled: !currentDb || readOnly,
|
||||
loading: loadingSchemas,
|
||||
icon: <Icons.RightOutlined />,
|
||||
},
|
||||
);
|
||||
return renderSelectRow(t('Select schema'), null, null, {
|
||||
displayValue: currentSchema?.label,
|
||||
disabled: !currentDb || readOnly,
|
||||
loading: loadingSchemas,
|
||||
icon: <Icons.RightOutlined />,
|
||||
});
|
||||
}
|
||||
const refreshIcon = !readOnly && (
|
||||
<RefreshLabel
|
||||
@@ -539,13 +534,13 @@ export function DatabaseSelector({
|
||||
{renderSelectRow(
|
||||
t('Schema'),
|
||||
<Select
|
||||
ariaLabel={t('Select schema or type to search schemas')}
|
||||
ariaLabel={t('Select schema')}
|
||||
disabled={!currentDb || readOnly}
|
||||
labelInValue
|
||||
loading={loadingSchemas}
|
||||
name="select-schema"
|
||||
notFoundContent={t('No compatible schema found')}
|
||||
placeholder={t('Select schema or type to search schemas')}
|
||||
placeholder={t('Select schema')}
|
||||
onChange={item => changeSchema(item as SchemaOption)}
|
||||
options={schemaOptions}
|
||||
showSearch
|
||||
|
||||
@@ -99,6 +99,7 @@ export function DatabaseErrorMessage({
|
||||
<ErrorAlert
|
||||
errorType={t('%s Error', extra?.engine_name || t('DB engine'))}
|
||||
message={alertMessage}
|
||||
messagePre
|
||||
description={alertDescription}
|
||||
type={level}
|
||||
descriptionDetails={body}
|
||||
|
||||
@@ -35,6 +35,7 @@ export const ErrorAlert: React.FC<ErrorAlertProps> = ({
|
||||
description,
|
||||
descriptionDetails,
|
||||
descriptionDetailsCollapsed = true,
|
||||
messagePre = false,
|
||||
descriptionPre = true,
|
||||
compact = false,
|
||||
children,
|
||||
@@ -69,13 +70,20 @@ export const ErrorAlert: React.FC<ErrorAlertProps> = ({
|
||||
);
|
||||
};
|
||||
const preStyle = {
|
||||
whiteSpace: 'pre-wrap',
|
||||
whiteSpace: 'pre-wrap' as const,
|
||||
fontFamily: theme.fontFamilyCode,
|
||||
margin: `${theme.sizeUnit}px 0`,
|
||||
};
|
||||
const renderDescription = () => (
|
||||
<div>
|
||||
{message && <div>{message}</div>}
|
||||
{message &&
|
||||
(messagePre ? (
|
||||
<Typography.Paragraph style={preStyle}>
|
||||
{message}
|
||||
</Typography.Paragraph>
|
||||
) : (
|
||||
<div>{message}</div>
|
||||
))}
|
||||
{description && (
|
||||
<Typography.Paragraph
|
||||
style={descriptionPre ? preStyle : {}}
|
||||
|
||||
@@ -38,6 +38,7 @@ 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
|
||||
|
||||
@@ -62,7 +62,7 @@ const PanelToolbar = ({
|
||||
buttonSize="small"
|
||||
aria-label={command?.title}
|
||||
variant="text"
|
||||
color="primary"
|
||||
color="default"
|
||||
/>
|
||||
);
|
||||
})
|
||||
@@ -140,7 +140,7 @@ const PanelToolbar = ({
|
||||
>
|
||||
<Button
|
||||
showMarginRight={false}
|
||||
color="primary"
|
||||
color="default"
|
||||
variant="text"
|
||||
css={css`
|
||||
padding: 8px;
|
||||
|
||||
@@ -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 or type to search schemas: test_schema',
|
||||
name: 'Select schema: test_schema',
|
||||
});
|
||||
const tableSelect = screen.getByRole('combobox', {
|
||||
name: 'Select table or type to search tables',
|
||||
|
||||
@@ -181,7 +181,7 @@ const DetailsPanelPopover = ({
|
||||
|
||||
return (
|
||||
<Popover
|
||||
color={`${theme.colorBgElevated}cc`}
|
||||
color={theme.colorBgElevated}
|
||||
content={content}
|
||||
open={popoverVisible}
|
||||
onOpenChange={handleVisibility}
|
||||
|
||||
@@ -288,8 +288,15 @@ const VerticalFilterBar: FC<VerticalBarProps> = ({
|
||||
<Bar className={cx({ open: filtersOpen })} width={width}>
|
||||
<Header toggleFiltersBar={toggleFiltersBar} />
|
||||
{!isInitialized ? (
|
||||
<div css={{ height }}>
|
||||
<Loading size="s" muted />
|
||||
<div
|
||||
css={{
|
||||
height,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Loading position="inline-centered" size="s" muted />
|
||||
</div>
|
||||
) : (
|
||||
<div css={tabPaneStyle} onScroll={onScroll}>
|
||||
|
||||
@@ -654,7 +654,8 @@ test('reorders filters via keyboard (Space, ArrowDown, Space)', async () => {
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test('updates sidebar title when filter name changes', async () => {
|
||||
// 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 () => {
|
||||
const nativeFilterConfig = [
|
||||
buildNativeFilter('NATIVE_FILTER-1', 'state', []),
|
||||
buildNativeFilter('NATIVE_FILTER-2', 'country', []),
|
||||
|
||||
@@ -648,6 +648,7 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
|
||||
</span>
|
||||
);
|
||||
|
||||
let isInSubSection = false;
|
||||
const PanelChildren = (
|
||||
<>
|
||||
<StashFormDataContainer
|
||||
@@ -665,8 +666,19 @@ 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) {
|
||||
@@ -715,14 +727,23 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
|
||||
if (renderedControls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
// 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>
|
||||
) : (
|
||||
<ControlRow
|
||||
key={`controlsetrow-${i}`}
|
||||
controls={renderedControls}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -206,6 +206,7 @@ export const DataTablesPane = ({
|
||||
<StyledDiv>
|
||||
<SamplesPane
|
||||
datasource={datasource}
|
||||
queryFormData={queryFormData}
|
||||
queryForce={queryForce}
|
||||
isRequest={isRequest.samples}
|
||||
setForceQuery={setForceQuery}
|
||||
|
||||
@@ -20,6 +20,7 @@ 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,
|
||||
@@ -29,10 +30,19 @@ 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;
|
||||
|
||||
@@ -51,6 +61,9 @@ export const TableControls = ({
|
||||
rowcount,
|
||||
isLoading,
|
||||
canDownload,
|
||||
rowLimit,
|
||||
rowLimitOptions,
|
||||
onRowLimitChange,
|
||||
}: TableControlsProps) => {
|
||||
const originalTimeColumns = getTimeColumns(datasourceId);
|
||||
const formattedTimeColumns = zip<string, GenericDataType>(
|
||||
@@ -76,9 +89,23 @@ export const TableControls = ({
|
||||
css={css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`}
|
||||
>
|
||||
<RowCountLabel rowcount={rowcount} loading={isLoading} />
|
||||
{onRowLimitChange && (
|
||||
<Select
|
||||
value={rowLimit}
|
||||
onChange={onRowLimitChange}
|
||||
options={rowLimitOptions}
|
||||
size="small"
|
||||
css={css`
|
||||
min-width: 110px;
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
{(!onRowLimitChange || rowcount < (rowLimit ?? Infinity)) && (
|
||||
<RowCountLabel rowcount={rowcount} loading={isLoading} />
|
||||
)}
|
||||
{canDownload && (
|
||||
<CopyToClipboardButton data={formattedData} columns={columnNames} />
|
||||
)}
|
||||
|
||||
@@ -20,64 +20,96 @@ 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 {
|
||||
TableView,
|
||||
TableSize,
|
||||
EmptyState,
|
||||
Loading,
|
||||
EmptyWrapperType,
|
||||
} from '@superset-ui/core/components';
|
||||
import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import {
|
||||
useFilteredTableData,
|
||||
useTableColumns,
|
||||
} from 'src/explore/components/DataTableControl';
|
||||
import { GridTable } from 'src/components/GridTable';
|
||||
import { GridSize } from 'src/components/GridTable/constants';
|
||||
import { getDatasourceSamples } from 'src/components/Chart/chartAction';
|
||||
import { TableControls } from './DataTableControls';
|
||||
import { getDrillPayload } from 'src/components/Chart/DrillDetail/utils';
|
||||
import {
|
||||
useGridColumns,
|
||||
useKeywordFilter,
|
||||
useGridHeight,
|
||||
} from './useGridResultTable';
|
||||
import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls';
|
||||
import { SamplesPaneProps } from '../types';
|
||||
|
||||
const Error = styled.pre`
|
||||
margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`};
|
||||
`;
|
||||
|
||||
const cache = new WeakSet();
|
||||
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;
|
||||
|
||||
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(datasource);
|
||||
cache.delete(queryFormData);
|
||||
}
|
||||
|
||||
if (isRequest && !cache.has(datasource)) {
|
||||
if (isRequest && !cache.has(queryFormData)) {
|
||||
setIsLoading(true);
|
||||
getDatasourceSamples(datasource.type, datasource.id, queryForce, {})
|
||||
const payload =
|
||||
getDrillPayload(
|
||||
queryFormData as Parameters<typeof getDrillPayload>[0],
|
||||
) ?? {};
|
||||
getDatasourceSamples(
|
||||
datasource.type,
|
||||
datasource.id,
|
||||
queryForce,
|
||||
payload,
|
||||
rowLimit,
|
||||
1,
|
||||
)
|
||||
.then(response => {
|
||||
setData(ensureIsArray(response.data));
|
||||
setColnames(ensureIsArray(response.colnames));
|
||||
setColtypes(ensureIsArray(response.coltypes));
|
||||
setRowCount(response.rowcount);
|
||||
setResponseError('');
|
||||
cache.add(datasource);
|
||||
cache.set(queryFormData, true);
|
||||
if (queryForce) {
|
||||
setForceQuery?.(false);
|
||||
}
|
||||
@@ -92,20 +124,10 @@ export const SamplesPane = ({
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [datasource, isRequest, queryForce]);
|
||||
}, [datasource, queryFormData, isRequest, queryForce, rowLimit]);
|
||||
|
||||
// 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 columns = useGridColumns(colnames, coltypes, data);
|
||||
const keywordFilter = useKeywordFilter(filterText);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(input: string) => setFilterText(input),
|
||||
@@ -120,7 +142,7 @@ export const SamplesPane = ({
|
||||
return (
|
||||
<>
|
||||
<TableControls
|
||||
data={filteredData}
|
||||
data={data}
|
||||
columnNames={colnames}
|
||||
columnTypes={coltypes}
|
||||
rowcount={rowcount}
|
||||
@@ -128,6 +150,9 @@ export const SamplesPane = ({
|
||||
onInputChange={handleInputChange}
|
||||
isLoading={isLoading}
|
||||
canDownload={canDownload}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
onRowLimitChange={handleRowLimitChange}
|
||||
/>
|
||||
<Error>{responseError}</Error>
|
||||
</>
|
||||
@@ -142,7 +167,7 @@ export const SamplesPane = ({
|
||||
return (
|
||||
<>
|
||||
<TableControls
|
||||
data={filteredData}
|
||||
data={data}
|
||||
columnNames={colnames}
|
||||
columnTypes={coltypes}
|
||||
rowcount={rowcount}
|
||||
@@ -150,19 +175,22 @@ export const SamplesPane = ({
|
||||
onInputChange={handleInputChange}
|
||||
isLoading={isLoading}
|
||||
canDownload={canDownload}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
onRowLimitChange={handleRowLimitChange}
|
||||
/>
|
||||
<TableView
|
||||
columns={columns}
|
||||
data={filteredData}
|
||||
pageSize={dataSize}
|
||||
noDataText={t('No results')}
|
||||
emptyWrapperType={EmptyWrapperType.Small}
|
||||
className="table-condensed"
|
||||
isPaginationSticky
|
||||
showRowCount={false}
|
||||
size={TableSize.Small}
|
||||
small
|
||||
/>
|
||||
<GridContainer>
|
||||
<GridSizer ref={measuredRef}>
|
||||
<GridTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
height={gridHeight}
|
||||
size={GridSize.Small}
|
||||
externalFilter={keywordFilter}
|
||||
showRowNumber
|
||||
/>
|
||||
</GridSizer>
|
||||
</GridContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,46 +17,52 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useState, useCallback } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { GridTable } from 'src/components/GridTable';
|
||||
import { GridSize } from 'src/components/GridTable/constants';
|
||||
import {
|
||||
TableView,
|
||||
TableSize,
|
||||
EmptyWrapperType,
|
||||
} from '@superset-ui/core/components';
|
||||
import {
|
||||
useFilteredTableData,
|
||||
useTableColumns,
|
||||
} from 'src/explore/components/DataTableControl';
|
||||
useGridColumns,
|
||||
useKeywordFilter,
|
||||
useGridHeight,
|
||||
} from './useGridResultTable';
|
||||
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,
|
||||
isPaginationSticky = true,
|
||||
rowLimit,
|
||||
rowLimitOptions,
|
||||
onRowLimitChange,
|
||||
}: SingleQueryResultPaneProp) => {
|
||||
const [filterText, setFilterText] = useState('');
|
||||
const { gridHeight, measuredRef } = useGridHeight();
|
||||
|
||||
// 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 columns = useGridColumns(colnames, coltypes, data, columnDisplayNames);
|
||||
const keywordFilter = useKeywordFilter(filterText);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(input: string) => setFilterText(input),
|
||||
@@ -64,9 +70,9 @@ export const SingleQueryResultPane = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResultPaneContainer>
|
||||
<TableControls
|
||||
data={filteredData}
|
||||
data={data}
|
||||
columnNames={colnames}
|
||||
columnTypes={coltypes}
|
||||
rowcount={rowcount}
|
||||
@@ -74,19 +80,22 @@ export const SingleQueryResultPane = ({
|
||||
onInputChange={handleInputChange}
|
||||
isLoading={false}
|
||||
canDownload={canDownload}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={rowLimitOptions}
|
||||
onRowLimitChange={onRowLimitChange}
|
||||
/>
|
||||
<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
|
||||
/>
|
||||
</>
|
||||
<GridContainer>
|
||||
<GridSizer ref={measuredRef}>
|
||||
<GridTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
height={gridHeight}
|
||||
size={GridSize.Small}
|
||||
externalFilter={keywordFilter}
|
||||
showRowNumber
|
||||
/>
|
||||
</GridSizer>
|
||||
</GridContainer>
|
||||
</ResultPaneContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useState, useEffect, ReactElement, useCallback } from 'react';
|
||||
import { useState, useEffect, useMemo, 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 } from './DataTableControls';
|
||||
import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls';
|
||||
|
||||
const Error = styled.pre`
|
||||
margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`};
|
||||
@@ -53,7 +53,6 @@ export const useResultsPane = ({
|
||||
errorMessage,
|
||||
setForceQuery,
|
||||
isVisible,
|
||||
dataSize = 50,
|
||||
canDownload,
|
||||
columnDisplayNames,
|
||||
}: ResultsPaneProps): ReactElement[] => {
|
||||
@@ -61,6 +60,8 @@ 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>('');
|
||||
@@ -69,12 +70,28 @@ 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(queryFormData)) {
|
||||
if (isRequest && cache.has(cappedFormData)) {
|
||||
setResultResp(
|
||||
ensureIsArray(cache.get(queryFormData)) as QueryResultInterface[],
|
||||
ensureIsArray(cache.get(cappedFormData)) as QueryResultInterface[],
|
||||
);
|
||||
setResponseError('');
|
||||
if (queryForce) {
|
||||
@@ -82,10 +99,10 @@ export const useResultsPane = ({
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
if (isRequest && !cache.has(queryFormData)) {
|
||||
if (isRequest && !cache.has(cappedFormData)) {
|
||||
setIsLoading(true);
|
||||
getChartDataRequest({
|
||||
formData: queryFormData,
|
||||
formData: cappedFormData,
|
||||
force: queryForce,
|
||||
resultFormat: 'json',
|
||||
resultType: 'results',
|
||||
@@ -94,7 +111,7 @@ export const useResultsPane = ({
|
||||
.then(({ json }) => {
|
||||
setResultResp(ensureIsArray(json.result) as QueryResultInterface[]);
|
||||
setResponseError('');
|
||||
cache.set(queryFormData, json.result);
|
||||
cache.set(cappedFormData, json.result);
|
||||
if (queryForce) {
|
||||
setForceQuery?.(false);
|
||||
}
|
||||
@@ -108,7 +125,7 @@ export const useResultsPane = ({
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [queryFormData, isRequest]);
|
||||
}, [cappedFormData, isRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
if (errorMessage) {
|
||||
@@ -163,11 +180,13 @@ 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>
|
||||
));
|
||||
|
||||
@@ -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,
|
||||
waitForElementToBeRemoved,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
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,12 +175,6 @@ 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();
|
||||
});
|
||||
|
||||
|
||||
@@ -20,14 +20,18 @@ 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
|
||||
@@ -126,12 +130,12 @@ describe('ResultsPaneOnDashboard', () => {
|
||||
expect(await findByText('Bad request')).toBeVisible();
|
||||
});
|
||||
|
||||
test('force query, render and search', async () => {
|
||||
test('force query, render', async () => {
|
||||
const props = createResultsPaneOnDashboardProps({
|
||||
sliceId: 144,
|
||||
queryForce: true,
|
||||
});
|
||||
const { queryByText, getByPlaceholderText } = render(
|
||||
const { queryByText } = render(
|
||||
<ResultsPaneOnDashboard {...props} setForceQuery={setForceQuery} />,
|
||||
{
|
||||
useRedux: true,
|
||||
@@ -144,11 +148,6 @@ 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 () => {
|
||||
|
||||
@@ -17,19 +17,19 @@
|
||||
* under the License.
|
||||
*/
|
||||
import fetchMock from 'fetch-mock';
|
||||
import {
|
||||
render,
|
||||
userEvent,
|
||||
waitForElementToBeRemoved,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { render, waitFor } from 'spec/helpers/testing-library';
|
||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
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',
|
||||
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=34&per_page=100&page=1',
|
||||
{
|
||||
result: {
|
||||
data: [],
|
||||
@@ -40,7 +40,7 @@ describe('SamplesPane', () => {
|
||||
);
|
||||
|
||||
fetchMock.post(
|
||||
'end:/datasource/samples?force=true&datasource_type=table&datasource_id=35',
|
||||
'end:/datasource/samples?force=true&datasource_type=table&datasource_id=35&per_page=100&page=1',
|
||||
{
|
||||
result: {
|
||||
data: [
|
||||
@@ -56,7 +56,7 @@ describe('SamplesPane', () => {
|
||||
);
|
||||
|
||||
fetchMock.post(
|
||||
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=36',
|
||||
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=36&per_page=100&page=1',
|
||||
400,
|
||||
);
|
||||
|
||||
@@ -91,12 +91,12 @@ describe('SamplesPane', () => {
|
||||
expect(await findByText('Error: Bad request')).toBeVisible();
|
||||
});
|
||||
|
||||
test('force query, render and search', async () => {
|
||||
test('force query, render', async () => {
|
||||
const props = createSamplesPaneProps({
|
||||
datasourceId: 35,
|
||||
queryForce: true,
|
||||
});
|
||||
const { queryByText, getByPlaceholderText } = render(
|
||||
const { queryByText } = render(
|
||||
<SamplesPane {...props} setForceQuery={setForceQuery} />,
|
||||
{
|
||||
useRedux: true,
|
||||
@@ -109,10 +109,5 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +90,10 @@ export const createSamplesPaneProps = ({
|
||||
({
|
||||
isRequest,
|
||||
datasource: { ...datasource, id: datasourceId },
|
||||
queryFormData: {
|
||||
...queryFormData,
|
||||
datasource: `${datasourceId}__table`,
|
||||
},
|
||||
queryForce,
|
||||
isVisible: true,
|
||||
setForceQuery: jest.fn(),
|
||||
|
||||
@@ -56,10 +56,9 @@ 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;
|
||||
}
|
||||
@@ -74,6 +73,9 @@ export interface TableControlsProps {
|
||||
isLoading: boolean;
|
||||
rowcount: number;
|
||||
canDownload: boolean;
|
||||
rowLimit?: number;
|
||||
rowLimitOptions?: { value: number; label: string }[];
|
||||
onRowLimitChange?: (limit: number) => void;
|
||||
}
|
||||
|
||||
export interface QueryResultInterface {
|
||||
@@ -86,11 +88,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>;
|
||||
isPaginationSticky?: boolean;
|
||||
rowLimit?: number;
|
||||
rowLimitOptions?: { value: number; label: string }[];
|
||||
onRowLimitChange?: (limit: number) => void;
|
||||
}
|
||||
|
||||
@@ -204,7 +204,6 @@ const ExploreChartPanel = ({
|
||||
|
||||
const {
|
||||
ref: chartPanelRef,
|
||||
observerRef: resizeObserverRef,
|
||||
width: chartPanelWidth,
|
||||
height: chartPanelHeight,
|
||||
} = useResizeDetectorByObserver();
|
||||
@@ -378,7 +377,6 @@ const ExploreChartPanel = ({
|
||||
flex-direction: column;
|
||||
padding-top: ${theme.sizeUnit * 2}px;
|
||||
`}
|
||||
ref={resizeObserverRef}
|
||||
>
|
||||
{vizTypeNeedsDataset && (
|
||||
<Alert
|
||||
@@ -481,7 +479,6 @@ const ExploreChartPanel = ({
|
||||
</div>
|
||||
),
|
||||
[
|
||||
resizeObserverRef,
|
||||
showAlertBanner,
|
||||
errorMessage,
|
||||
onQuery,
|
||||
@@ -533,7 +530,7 @@ const ExploreChartPanel = ({
|
||||
document.body.className += ` ${standaloneClass}`;
|
||||
}
|
||||
return (
|
||||
<div id="app" data-test="standalone-app" ref={resizeObserverRef}>
|
||||
<div id="app" data-test="standalone-app">
|
||||
{standaloneChartBody}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -31,15 +31,16 @@ export default function useResizeDetectorByObserver() {
|
||||
setChartPanelSize({ width, height });
|
||||
}
|
||||
}, []);
|
||||
const { ref: observerRef } = useResizeDetector({
|
||||
// Use targetRef to observe the same element we measure
|
||||
useResizeDetector({
|
||||
refreshMode: 'debounce',
|
||||
refreshRate: 300,
|
||||
onResize,
|
||||
targetRef: ref,
|
||||
});
|
||||
|
||||
return {
|
||||
ref,
|
||||
observerRef,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
|
||||
19
superset-frontend/src/explore/components/SaveModal.tsx
Normal file → Executable file
19
superset-frontend/src/explore/components/SaveModal.tsx
Normal file → Executable file
@@ -93,11 +93,6 @@ export const StyledModal = styled(Modal)`
|
||||
.ant-modal-body {
|
||||
overflow: visible;
|
||||
}
|
||||
i {
|
||||
position: absolute;
|
||||
top: -${({ theme }) => theme.sizeUnit * 5.25}px;
|
||||
left: ${({ theme }) => theme.sizeUnit * 26.75}px;
|
||||
}
|
||||
`;
|
||||
|
||||
class SaveModal extends Component<SaveModalProps, SaveModalState> {
|
||||
@@ -172,17 +167,21 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
|
||||
this.setState({ newSliceName: event.target.value });
|
||||
}
|
||||
|
||||
onDashboardChange = async (dashboard: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}) => {
|
||||
onDashboardChange = async (
|
||||
dashboard:
|
||||
| {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
| undefined,
|
||||
) => {
|
||||
this.setState({
|
||||
dashboard,
|
||||
tabsData: [],
|
||||
selectedTab: undefined,
|
||||
});
|
||||
|
||||
if (typeof dashboard.value === 'number') {
|
||||
if (dashboard && typeof dashboard.value === 'number') {
|
||||
await this.loadTabs(dashboard.value);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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 { render, screen } from 'spec/helpers/testing-library';
|
||||
import { Comparator } from '@superset-ui/chart-controls';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import ConditionalFormattingControl from './ConditionalFormattingControl';
|
||||
import { ConditionalFormattingConfig } from './types';
|
||||
|
||||
const columnOptions = [
|
||||
{ label: 'My Column', value: 'my_col', dataType: GenericDataType.Boolean },
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
columnOptions,
|
||||
verboseMap: {} as Record<string, string>,
|
||||
removeIrrelevantConditions: false,
|
||||
label: 'Conditional Formatting',
|
||||
description: 'Test',
|
||||
name: 'conditional_formatting',
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
|
||||
test('renders "is false" operator label without trailing undefined', () => {
|
||||
const value: ConditionalFormattingConfig[] = [
|
||||
{ column: 'my_col', operator: Comparator.IsFalse, colorScheme: 'colorSuccess' },
|
||||
];
|
||||
render(<ConditionalFormattingControl {...defaultProps} value={value} />);
|
||||
expect(screen.getByText('my_col is false')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders "is true" operator label without trailing undefined', () => {
|
||||
const value: ConditionalFormattingConfig[] = [
|
||||
{ column: 'my_col', operator: Comparator.IsTrue, colorScheme: 'colorSuccess' },
|
||||
];
|
||||
render(<ConditionalFormattingControl {...defaultProps} value={value} />);
|
||||
expect(screen.getByText('my_col is true')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders "is null" operator label without trailing undefined', () => {
|
||||
const value: ConditionalFormattingConfig[] = [
|
||||
{ column: 'my_col', operator: Comparator.IsNull, colorScheme: 'colorSuccess' },
|
||||
];
|
||||
render(<ConditionalFormattingControl {...defaultProps} value={value} />);
|
||||
expect(screen.getByText('my_col is null')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders "is not null" operator label without trailing undefined', () => {
|
||||
const value: ConditionalFormattingConfig[] = [
|
||||
{ column: 'my_col', operator: Comparator.IsNotNull, colorScheme: 'colorSuccess' },
|
||||
];
|
||||
render(<ConditionalFormattingControl {...defaultProps} value={value} />);
|
||||
expect(screen.getByText('my_col is not null')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders verbose column name when available', () => {
|
||||
const value: ConditionalFormattingConfig[] = [
|
||||
{ column: 'my_col', operator: Comparator.IsFalse, colorScheme: 'colorSuccess' },
|
||||
];
|
||||
render(
|
||||
<ConditionalFormattingControl
|
||||
{...defaultProps}
|
||||
verboseMap={{ my_col: 'My Column' }}
|
||||
value={value}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('My Column is false')).toBeInTheDocument();
|
||||
});
|
||||
@@ -136,6 +136,11 @@ const ConditionalFormattingControl = ({
|
||||
return `${targetValueLeft} ${Comparator.LessOrEqual} ${columnName} ${Comparator.LessThan} ${targetValueRight}`;
|
||||
case Comparator.BetweenOrRightEqual:
|
||||
return `${targetValueLeft} ${Comparator.LessThan} ${columnName} ${Comparator.LessOrEqual} ${targetValueRight}`;
|
||||
case Comparator.IsTrue:
|
||||
case Comparator.IsFalse:
|
||||
case Comparator.IsNull:
|
||||
case Comparator.IsNotNull:
|
||||
return `${columnName} ${operator}`;
|
||||
default:
|
||||
return `${columnName} ${operator} ${targetValue}`;
|
||||
}
|
||||
|
||||
@@ -269,6 +269,26 @@ test('will convert from individual comparator to array if the operator changes t
|
||||
).toEqual(Operators.In);
|
||||
});
|
||||
|
||||
test('will preserve boolean false comparator when converting to multi operator', () => {
|
||||
const booleanFalseFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'value',
|
||||
operatorId: Operators.Equals,
|
||||
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.Equals].operation,
|
||||
comparator: false,
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
const props = setup({ adhocFilter: booleanFalseFilter });
|
||||
const { onOperatorChange } = useSimpleTabFilterProps(
|
||||
props as unknown as Props,
|
||||
);
|
||||
onOperatorChange(Operators.In);
|
||||
expect(
|
||||
props.onChange.mock.calls[props.onChange.mock.calls.length - 1][0]
|
||||
.comparator,
|
||||
).toEqual([false]);
|
||||
});
|
||||
|
||||
test('will convert from array to individual comparators if the operator changes from multi', () => {
|
||||
const props = setup({
|
||||
adhocFilter: simpleMultiAdhocFilter,
|
||||
|
||||
@@ -199,7 +199,7 @@ export const useSimpleTabFilterProps = (props: Props) => {
|
||||
if (MULTI_OPERATORS.has(operatorId)) {
|
||||
newComparator = Array.isArray(currentComparator)
|
||||
? currentComparator
|
||||
: [currentComparator].filter(element => element);
|
||||
: [currentComparator].filter(element => element != null);
|
||||
} else {
|
||||
newComparator = Array.isArray(currentComparator)
|
||||
? currentComparator[0]
|
||||
@@ -396,7 +396,8 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
};
|
||||
|
||||
const comparatorHasValue =
|
||||
comparator &&
|
||||
comparator != null &&
|
||||
comparator !== '' &&
|
||||
(Array.isArray(comparator)
|
||||
? comparator.length > 0
|
||||
: String(comparator).length > 0);
|
||||
|
||||
@@ -70,3 +70,15 @@ test('Should return correct string when subject and operator are valid values',
|
||||
]),
|
||||
).toBe("subject operator 'comparator', 'comparator-2'");
|
||||
});
|
||||
|
||||
test('Should handle boolean false comparator as a string value', () => {
|
||||
expect(getSimpleSQLExpression(params.subject, params.operator, false)).toBe(
|
||||
"subject operator 'FALSE'",
|
||||
);
|
||||
});
|
||||
|
||||
test('Should handle boolean true comparator as a string value', () => {
|
||||
expect(getSimpleSQLExpression(params.subject, params.operator, true)).toBe(
|
||||
"subject operator 'TRUE'",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -458,7 +458,8 @@ export const getSimpleSQLExpression = (
|
||||
isMulti && Array.isArray(comparator) ? comparator[0] : comparator;
|
||||
const comparatorArray = ensureIsArray(comparator);
|
||||
const isString =
|
||||
firstValue !== undefined && Number.isNaN(Number(firstValue));
|
||||
firstValue !== undefined &&
|
||||
(typeof firstValue === 'boolean' || Number.isNaN(Number(firstValue)));
|
||||
const quote = isString ? "'" : '';
|
||||
const [prefix, suffix] = isMulti ? ['(', ')'] : ['', ''];
|
||||
if (comparatorArray.length > 0 && showComparator) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { AlertReportCronScheduler } from './AlertReportCronScheduler';
|
||||
|
||||
const defaultProps = {
|
||||
value: '0 12 * * 1',
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
defaultProps.onChange = jest.fn();
|
||||
});
|
||||
|
||||
test('renders CronPicker by default (picker mode)', () => {
|
||||
render(<AlertReportCronScheduler {...defaultProps} />);
|
||||
expect(screen.getByText('Schedule type')).toBeInTheDocument();
|
||||
expect(screen.getByText('Schedule')).toBeInTheDocument();
|
||||
// CronPicker renders combobox elements; CRON text input does not
|
||||
expect(
|
||||
screen.queryByPlaceholderText('CRON expression'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
async function switchToCronInputMode() {
|
||||
const scheduleTypeSelect = screen.getByRole('combobox', {
|
||||
name: /Schedule type/i,
|
||||
});
|
||||
fireEvent.mouseDown(scheduleTypeSelect);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('CRON Schedule')).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByText('CRON Schedule'));
|
||||
}
|
||||
|
||||
test('switches to CRON input mode and shows text input', async () => {
|
||||
render(<AlertReportCronScheduler {...defaultProps} />);
|
||||
|
||||
await switchToCronInputMode();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('CRON expression')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Controlled wrapper: the component is fully controlled (value from props),
|
||||
// so blur/enter tests need a parent that updates value on onChange.
|
||||
function ControlledScheduler({
|
||||
initialValue,
|
||||
onChangeSpy,
|
||||
}: {
|
||||
initialValue: string;
|
||||
onChangeSpy: jest.Mock;
|
||||
}) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
return (
|
||||
<AlertReportCronScheduler
|
||||
value={value}
|
||||
onChange={(v: string) => {
|
||||
setValue(v);
|
||||
onChangeSpy(v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
test('calls onChange on blur in CRON input mode', async () => {
|
||||
const onChangeSpy = jest.fn();
|
||||
render(
|
||||
<ControlledScheduler initialValue="0 12 * * 1" onChangeSpy={onChangeSpy} />,
|
||||
);
|
||||
|
||||
await switchToCronInputMode();
|
||||
|
||||
const input = await screen.findByPlaceholderText('CRON expression');
|
||||
fireEvent.change(input, { target: { value: '*/5 * * * *' } });
|
||||
|
||||
// Clear spy so we only assert the blur-specific call
|
||||
onChangeSpy.mockClear();
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(onChangeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(onChangeSpy).toHaveBeenCalledWith('*/5 * * * *');
|
||||
});
|
||||
|
||||
test('calls onChange on Enter key press in CRON input mode', async () => {
|
||||
const onChangeSpy = jest.fn();
|
||||
render(
|
||||
<ControlledScheduler initialValue="0 12 * * 1" onChangeSpy={onChangeSpy} />,
|
||||
);
|
||||
|
||||
await switchToCronInputMode();
|
||||
|
||||
const input = await screen.findByPlaceholderText('CRON expression');
|
||||
fireEvent.change(input, { target: { value: '0 9 * * 1-5' } });
|
||||
|
||||
// Clear spy so we only assert the Enter-specific call
|
||||
onChangeSpy.mockClear();
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(onChangeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(onChangeSpy).toHaveBeenCalledWith('0 9 * * 1-5');
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 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 { render, screen, fireEvent } from 'spec/helpers/testing-library';
|
||||
import NumberInput from './NumberInput';
|
||||
|
||||
const defaultProps = {
|
||||
timeUnit: 'seconds',
|
||||
min: 0,
|
||||
name: 'timeout',
|
||||
value: '30',
|
||||
placeholder: 'Enter value',
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
|
||||
test('renders value with timeUnit suffix when not focused', () => {
|
||||
render(<NumberInput {...defaultProps} />);
|
||||
const input = screen.getByPlaceholderText('Enter value');
|
||||
expect(input).toHaveValue('30 seconds');
|
||||
});
|
||||
|
||||
test('strips suffix on focus and restores on blur', () => {
|
||||
render(<NumberInput {...defaultProps} />);
|
||||
const input = screen.getByPlaceholderText('Enter value');
|
||||
|
||||
fireEvent.focus(input);
|
||||
expect(input).toHaveValue('30');
|
||||
|
||||
fireEvent.blur(input);
|
||||
expect(input).toHaveValue('30 seconds');
|
||||
});
|
||||
|
||||
test('renders empty string when value is falsy', () => {
|
||||
render(<NumberInput {...defaultProps} value="" />);
|
||||
const input = screen.getByPlaceholderText('Enter value');
|
||||
expect(input).toHaveValue('');
|
||||
});
|
||||
|
||||
test('renders empty string when value is zero', () => {
|
||||
render(<NumberInput {...defaultProps} value={0} />);
|
||||
const input = screen.getByPlaceholderText('Enter value');
|
||||
expect(input).toHaveValue('');
|
||||
});
|
||||
|
||||
test('calls onChange when input changes', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<NumberInput {...defaultProps} onChange={onChange} />);
|
||||
const input = screen.getByPlaceholderText('Enter value');
|
||||
|
||||
fireEvent.change(input, { target: { value: '60' } });
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user