mirror of
https://github.com/apache/superset.git
synced 2026-08-28 11:01:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
135e0f8099 | ||
|
|
25eea295f6 | ||
|
|
c372f5980c | ||
|
|
3802acb1e0 | ||
|
|
bdb0030cf8 | ||
|
|
87f0540acd | ||
|
|
985d7b6a79 | ||
|
|
59f92f979a | ||
|
|
5cc286e383 | ||
|
|
26f4a5acad | ||
|
|
fdd08d3b70 | ||
|
|
1aac6c9474 | ||
|
|
7acb0c6d05 | ||
|
|
00eb86d03f | ||
|
|
1d0e836a29 | ||
|
|
ec6640b188 | ||
|
|
ff3b8d8398 | ||
|
|
022342839a | ||
|
|
38f0dc74f7 | ||
|
|
0bae05d4a9 | ||
|
|
1bb41a6e60 | ||
|
|
4423134739 | ||
|
|
190f1a59c5 | ||
|
|
5f99d613a0 | ||
|
|
6adc816805 | ||
|
|
aa97679327 | ||
|
|
94d8735d4b | ||
|
|
64c8d652e1 | ||
|
|
d30c5b4eee | ||
|
|
8ed75787cb | ||
|
|
4ee391e0d7 |
@@ -76,7 +76,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude PR Action
|
||||
uses: anthropics/claude-code-action@88c168b39e7e64da0286d812b6e9fbebb6708185 # beta
|
||||
uses: anthropics/claude-code-action@6e2bd52842c65e914eba5c8badd17560bd26b5de # beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
timeout_minutes: "60"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4
|
||||
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
|
||||
with:
|
||||
version: v3.16.4
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4
|
||||
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
|
||||
with:
|
||||
version: v3.5.4
|
||||
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@
|
||||
"@swc/core": "^1.15.21",
|
||||
"antd": "^6.3.5",
|
||||
"baseline-browser-mapping": "^2.10.13",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
"caniuse-lite": "^1.0.30001786",
|
||||
"docusaurus-plugin-openapi-docs": "^4.6.0",
|
||||
"docusaurus-theme-openapi-docs": "^4.6.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
|
||||
+4
-4
@@ -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.30001782:
|
||||
version "1.0.30001782"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz#f2b8617f998bc134701c54ce9748af44f646e062"
|
||||
integrity sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==
|
||||
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/';
|
||||
|
||||
+6
-6
@@ -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",
|
||||
|
||||
Generated
+897
-931
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",
|
||||
@@ -182,9 +182,9 @@
|
||||
"js-levenshtein": "^1.1.6",
|
||||
"json-bigint": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^2.0.0",
|
||||
"lodash": "^4.17.23",
|
||||
"lodash": "^4.18.1",
|
||||
"mapbox-gl": "^3.20.0",
|
||||
"markdown-to-jsx": "^9.7.6",
|
||||
"markdown-to-jsx": "^9.7.13",
|
||||
"match-sorter": "^8.2.0",
|
||||
"memoize-one": "^5.2.1",
|
||||
"mousetrap": "^1.6.5",
|
||||
@@ -223,7 +223,7 @@
|
||||
"redux-undo": "^1.0.0-beta9-9-7",
|
||||
"rison": "^0.1.1",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
"simple-zstd": "^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",
|
||||
@@ -256,7 +256,7 @@
|
||||
"@emotion/jest": "^11.14.2",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@mihkeleidast/storybook-addon-source": "^1.0.1",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-actions": "^8.6.17",
|
||||
"@storybook/addon-controls": "^8.6.17",
|
||||
@@ -270,8 +270,8 @@
|
||||
"@storybook/test": "^8.6.15",
|
||||
"@storybook/test-runner": "^0.17.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.18",
|
||||
"@swc/plugin-emotion": "^14.6.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",
|
||||
@@ -284,7 +284,7 @@
|
||||
"@types/js-levenshtein": "^1.1.3",
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/react": "^17.0.83",
|
||||
"@types/react-dom": "^17.0.26",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
@@ -301,11 +301,11 @@
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"babel-jest": "^30.0.2",
|
||||
"babel-loader": "^10.1.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",
|
||||
@@ -327,7 +327,7 @@
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.9.2",
|
||||
"eslint-plugin-storybook": "^0.8.0",
|
||||
"eslint-plugin-testing-library": "^7.16.1",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^12.6.0",
|
||||
"fork-ts-checker-webpack-plugin": "^9.1.0",
|
||||
@@ -337,13 +337,13 @@
|
||||
"imports-loader": "^5.0.0",
|
||||
"jest": "^30.3.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-html-reporter": "^4.3.0",
|
||||
"jest-html-reporter": "^4.4.0",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"jsdom": "^28.1.0",
|
||||
"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",
|
||||
@@ -361,7 +361,7 @@
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.4.0",
|
||||
"thread-loader": "^4.0.4",
|
||||
"ts-jest": "^29.4.6",
|
||||
"ts-jest": "^29.4.9",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "5.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",
|
||||
@@ -102,7 +102,7 @@
|
||||
"react-dom": "^17.0.2",
|
||||
"react-loadable": "^5.5.0",
|
||||
"tinycolor2": "*",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash": "^4.18.1",
|
||||
"antd": "^5.26.0",
|
||||
"jed": "^1.1.1"
|
||||
},
|
||||
|
||||
@@ -26,11 +26,11 @@
|
||||
"dependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@types/react": "*",
|
||||
"lodash": "^4.17.23",
|
||||
"lodash": "^4.18.1",
|
||||
"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",
|
||||
|
||||
@@ -24,39 +24,40 @@
|
||||
"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",
|
||||
"jed": "^1.1.1",
|
||||
"lodash": "^4.17.23",
|
||||
"lodash": "^4.18.1",
|
||||
"math-expression-evaluator": "^2.0.7",
|
||||
"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.3.3",
|
||||
"@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",
|
||||
|
||||
@@ -283,6 +283,16 @@ export function AsyncAceEditor(
|
||||
color: ${token.colorText} !important;
|
||||
}
|
||||
|
||||
/* Fix cursor misalignment by ensuring consistent font-family */
|
||||
.ace_editor .ace_content {
|
||||
font-family: ${editorFontFamily} !important;
|
||||
}
|
||||
|
||||
/* Ensure the text layer uses the same font-family */
|
||||
.ace_editor .ace_text-layer {
|
||||
font-family: ${editorFontFamily} !important;
|
||||
}
|
||||
|
||||
/* Adjust gutter colors */
|
||||
.ace_editor .ace_gutter {
|
||||
background-color: ${token.colorBgElevated} !important;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+63
-10
@@ -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,
|
||||
+3
-3
@@ -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;
|
||||
+10
-11
@@ -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).
|
||||
+2
-2
@@ -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;
|
||||
+3
-3
@@ -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.
|
||||
+6
-6
@@ -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.
|
||||
+10
-10
@@ -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;
|
||||
+22
-35
@@ -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
|
||||
+2
-2
@@ -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.5",
|
||||
"@deck.gl/aggregation-layers": "~9.2.11",
|
||||
"@deck.gl/core": "~9.2.5",
|
||||
"@deck.gl/extensions": "~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.5",
|
||||
"@deck.gl/react": "~9.2.11",
|
||||
"@luma.gl/constants": "~9.2.5",
|
||||
"@luma.gl/core": "~9.2.5",
|
||||
"@luma.gl/engine": "~9.2.6",
|
||||
@@ -46,7 +46,7 @@
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"handlebars": "^4.7.9",
|
||||
"lodash": "^4.17.23",
|
||||
"lodash": "^4.18.1",
|
||||
"mousetrap": "^1.6.5",
|
||||
"ngeohash": "^0.6.3",
|
||||
"prop-types": "^15.8.1",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"d3": "^3.5.17",
|
||||
"d3-tip": "^0.9.1",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"lodash": "^4.17.23",
|
||||
"lodash": "^4.18.1",
|
||||
"nvd3-fork": "^2.0.5",
|
||||
"dompurify": "^3.3.3",
|
||||
"prop-types": "^15.8.1",
|
||||
|
||||
@@ -28,15 +28,15 @@
|
||||
"@types/react-table": "^7.7.20",
|
||||
"classnames": "^2.5.1",
|
||||
"d3-array": "^3.2.4",
|
||||
"lodash": "^4.17.23",
|
||||
"lodash": "^4.18.1",
|
||||
"memoize-one": "^5.2.1",
|
||||
"react-table": "^7.8.0",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"xss": "^1.0.15"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"@apache-superset/core": "*",
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"@testing-library/dom": "^8.20.1",
|
||||
|
||||
@@ -31,14 +31,14 @@
|
||||
"dependencies": {
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"geojson": "^0.5.0",
|
||||
"lodash": "^4.17.23"
|
||||
"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",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"@types/react-redux": "^7.1.34",
|
||||
"acorn": "^8.16.0",
|
||||
"d3-array": "^3.2.4",
|
||||
"lodash": "^4.17.23",
|
||||
"lodash": "^4.18.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -659,7 +659,10 @@ export default function transformProps(
|
||||
for (const s of series) {
|
||||
if (s.id) {
|
||||
const columnsArr = labelMap[s.id];
|
||||
(s as any).stack = columnsArr[idxSelectedDimension];
|
||||
const dimensionValue = columnsArr?.[idxSelectedDimension];
|
||||
if (dimensionValue !== undefined) {
|
||||
(s as any).stack = dimensionValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -682,9 +685,24 @@ export default function transformProps(
|
||||
|
||||
// For horizontal bar charts, set max/min from calculated data bounds
|
||||
if (shouldCalculateDataBounds) {
|
||||
// Set max to actual data max to avoid gaps and ensure labels are visible
|
||||
if (dataMax !== undefined && yAxisMax === undefined) {
|
||||
yAxisMax = dataMax;
|
||||
// For stacked charts, clamp against the per-row stacked total to avoid
|
||||
// clipping bars. Also keep dataMax so that mixed-sign stacks (where
|
||||
// positive and negative values cancel in the algebraic row sum) cannot
|
||||
// produce an axis max smaller than the largest individual positive segment.
|
||||
const stackedTotalMax = Math.max(
|
||||
...sortedTotalValues.filter(
|
||||
(v): v is number => typeof v === 'number' && !Number.isNaN(v),
|
||||
),
|
||||
);
|
||||
const effectiveDataMax = stack
|
||||
? Math.max(dataMax ?? Number.NEGATIVE_INFINITY, stackedTotalMax)
|
||||
: dataMax;
|
||||
if (
|
||||
effectiveDataMax !== undefined &&
|
||||
Number.isFinite(effectiveDataMax) &&
|
||||
yAxisMax === undefined
|
||||
) {
|
||||
yAxisMax = effectiveDataMax;
|
||||
}
|
||||
// Set min to actual data min for diverging bars
|
||||
if (dataMin !== undefined && yAxisMin === undefined && dataMin < 0) {
|
||||
|
||||
+92
@@ -24,6 +24,7 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import { StackControlsValue } from '../../../src/constants';
|
||||
import type {
|
||||
GridComponentOption,
|
||||
LegendComponentOption,
|
||||
@@ -727,6 +728,97 @@ describe('Bar Chart X-axis Time Formatting', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Horizontal stacked bar chart axis bounds', () => {
|
||||
// Dataset where each series max = 4 but stacked total max = 8
|
||||
const stackedData: ChartDataResponseResult[] = [
|
||||
createTestQueryData(
|
||||
[
|
||||
{ team: 'Team A', High: 2, Low: 2, Medium: 4 },
|
||||
{ team: 'Team B', High: null, Low: null, Medium: 3 },
|
||||
{ team: 'Team C', High: null, Low: null, Medium: 1 },
|
||||
],
|
||||
{
|
||||
colnames: ['team', 'High', 'Low', 'Medium'],
|
||||
coltypes: [
|
||||
GenericDataType.String,
|
||||
GenericDataType.Numeric,
|
||||
GenericDataType.Numeric,
|
||||
GenericDataType.Numeric,
|
||||
],
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
const horizontalStackedFormData: EchartsTimeseriesFormData = {
|
||||
...(baseFormData as EchartsTimeseriesFormData),
|
||||
x_axis: 'team',
|
||||
metric: ['High', 'Low', 'Medium'],
|
||||
groupby: [],
|
||||
orientation: OrientationType.Horizontal,
|
||||
seriesType: EchartsTimeseriesSeriesType.Bar,
|
||||
stack: StackControlsValue.Stack,
|
||||
truncateYAxis: true,
|
||||
};
|
||||
|
||||
test('xAxis.max uses stacked total, not individual series max', () => {
|
||||
// Individual series max = 4 (Medium), stacked total for Team A = 8
|
||||
// Without the fix, xAxis.max would be 4, clipping bars and duplicating labels
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsTimeseriesFormData,
|
||||
EchartsTimeseriesChartProps
|
||||
>({
|
||||
defaultFormData: horizontalStackedFormData,
|
||||
defaultVizType: 'echarts_timeseries_bar',
|
||||
defaultQueriesData: stackedData,
|
||||
});
|
||||
|
||||
const { echartOptions } = transformProps(chartProps);
|
||||
const xAxis = echartOptions.xAxis as any;
|
||||
|
||||
// xAxis.max must be >= stacked total (8), not capped at individual series max (4)
|
||||
expect(xAxis.max).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
test('xAxis.max is not set to individual series max when stacking', () => {
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsTimeseriesFormData,
|
||||
EchartsTimeseriesChartProps
|
||||
>({
|
||||
defaultFormData: horizontalStackedFormData,
|
||||
defaultVizType: 'echarts_timeseries_bar',
|
||||
defaultQueriesData: stackedData,
|
||||
});
|
||||
|
||||
const { echartOptions } = transformProps(chartProps);
|
||||
const xAxis = echartOptions.xAxis as any;
|
||||
|
||||
// 4 is the individual series max — the axis should not be clipped there
|
||||
expect(xAxis.max).not.toBe(4);
|
||||
});
|
||||
|
||||
test('non-stacked horizontal bar chart still uses individual series max', () => {
|
||||
const nonStackedFormData: EchartsTimeseriesFormData = {
|
||||
...horizontalStackedFormData,
|
||||
stack: null,
|
||||
};
|
||||
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsTimeseriesFormData,
|
||||
EchartsTimeseriesChartProps
|
||||
>({
|
||||
defaultFormData: nonStackedFormData,
|
||||
defaultVizType: 'echarts_timeseries_bar',
|
||||
defaultQueriesData: stackedData,
|
||||
});
|
||||
|
||||
const { echartOptions } = transformProps(chartProps);
|
||||
const xAxis = echartOptions.xAxis as any;
|
||||
|
||||
// Without stacking, xAxis.max should be based on individual series values
|
||||
expect(xAxis.max).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Legend layout regressions', () => {
|
||||
const getBottomLegendLayout = (
|
||||
chartWidth: number,
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"@superset-ui/core": "*",
|
||||
"ace-builds": "^1.4.14",
|
||||
"handlebars": "^4.7.8",
|
||||
"lodash": "^4.17.11",
|
||||
"lodash": "^4.18.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"react": "^17.0.2",
|
||||
"react-ace": "^10.1.0",
|
||||
|
||||
@@ -27,15 +27,15 @@
|
||||
"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.17.11",
|
||||
"lodash": "^4.18.1",
|
||||
"prop-types": "*",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2"
|
||||
"react-dom": "^17.0.2",
|
||||
"react-icons": "5.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.29.0",
|
||||
|
||||
@@ -24,20 +24,20 @@
|
||||
"lib"
|
||||
],
|
||||
"dependencies": {
|
||||
"react-icons": "5.4.0",
|
||||
"@types/d3-array": "^3.2.2",
|
||||
"@types/react-table": "^7.7.20",
|
||||
"classnames": "^2.5.1",
|
||||
"d3-array": "^3.2.4",
|
||||
"lodash": "^4.17.23",
|
||||
"lodash": "^4.18.1",
|
||||
"memoize-one": "^5.2.1",
|
||||
"react-icons": "5.4.0",
|
||||
"react-table": "^7.8.0",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"xss": "^1.0.15"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"@apache-superset/core": "*",
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"@testing-library/dom": "^8.20.1",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -45,11 +45,19 @@ const DatasourceEditor = AsyncEsmComponent(
|
||||
() => import('../components/DatasourceEditor'),
|
||||
);
|
||||
|
||||
const MODAL_HEIGHT_VH = 90;
|
||||
const TOP_MARGIN_VH = (100 - MODAL_HEIGHT_VH) / 2;
|
||||
|
||||
const StyledDatasourceModal = styled(Modal)`
|
||||
top: ${TOP_MARGIN_VH}vh;
|
||||
padding-bottom: 0;
|
||||
|
||||
&& .ant-modal-content {
|
||||
max-height: none;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
min-height: 500px;
|
||||
min-width: 500px;
|
||||
}
|
||||
|
||||
&& .ant-modal-body {
|
||||
@@ -367,7 +375,9 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
}
|
||||
responsive
|
||||
resizable
|
||||
resizableConfig={{ defaultSize: { width: 'auto', height: '900px' } }}
|
||||
resizableConfig={{
|
||||
defaultSize: { width: 'auto', height: `${MODAL_HEIGHT_VH}vh` },
|
||||
}}
|
||||
draggable
|
||||
>
|
||||
<DatasourceEditor
|
||||
|
||||
@@ -33,7 +33,6 @@ export const FoldersToolbar = styled.div`
|
||||
top: -${theme.margin}px; // offsets tabs component bottom margin
|
||||
z-index: 10;
|
||||
background: ${theme.colorBgContainer};
|
||||
padding-top: ${theme.paddingMD}px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.paddingLG}px;
|
||||
|
||||
+15
-12
@@ -370,6 +370,7 @@ const StyledTableTabs = styled(Tabs)`
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-top: ${({ theme }) => theme.paddingMD}px;
|
||||
}
|
||||
|
||||
.ant-tabs-content {
|
||||
@@ -2525,18 +2526,20 @@ class DatasourceEditor extends PureComponent<
|
||||
key: TABS_KEYS.SETTINGS,
|
||||
label: t('Settings'),
|
||||
children: (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<FormContainer>
|
||||
{this.renderSettingsFieldset()}
|
||||
</FormContainer>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<FormContainer>
|
||||
{this.renderAdvancedFieldset()}
|
||||
</FormContainer>
|
||||
</Col>
|
||||
</Row>
|
||||
<div style={{ overflowX: 'hidden' }}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<FormContainer>
|
||||
{this.renderSettingsFieldset()}
|
||||
</FormContainer>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<FormContainer>
|
||||
{this.renderAdvancedFieldset()}
|
||||
</FormContainer>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -181,7 +181,7 @@ const DetailsPanelPopover = ({
|
||||
|
||||
return (
|
||||
<Popover
|
||||
color={`${theme.colorBgElevated}cc`}
|
||||
color={theme.colorBgElevated}
|
||||
content={content}
|
||||
open={popoverVisible}
|
||||
onOpenChange={handleVisibility}
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -22,9 +22,10 @@ import {
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
createStore,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import reducerIndex from 'spec/helpers/reducerIndex';
|
||||
import { FeatureFlag, VizType, isFeatureEnabled } from '@superset-ui/core';
|
||||
import * as actions from 'src/features/reports/ReportModal/actions';
|
||||
import ReportModal from '.';
|
||||
|
||||
const REPORT_ENDPOINT = 'glob:*/api/v1/report*';
|
||||
@@ -56,115 +57,260 @@ jest.mock('@superset-ui/core', () => ({
|
||||
}));
|
||||
|
||||
const mockedIsFeatureEnabled = isFeatureEnabled as jest.Mock;
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('Email Report Modal', () => {
|
||||
beforeEach(() => {
|
||||
mockedIsFeatureEnabled.mockImplementation(
|
||||
featureFlag => featureFlag === FeatureFlag.AlertReports,
|
||||
);
|
||||
render(<ReportModal {...defaultProps} />, { useRedux: true });
|
||||
});
|
||||
|
||||
test('inputs respond correctly', () => {
|
||||
// ----- Report name textbox
|
||||
// Initial value
|
||||
const reportNameTextbox = screen.getByTestId('report-name-test');
|
||||
expect(reportNameTextbox).toHaveDisplayValue('Weekly Report');
|
||||
// Type in the textbox and assert that it worked
|
||||
userEvent.clear(reportNameTextbox);
|
||||
userEvent.type(reportNameTextbox, 'Report name text test');
|
||||
expect(reportNameTextbox).toHaveDisplayValue('Report name text test');
|
||||
|
||||
// ----- Report description textbox
|
||||
// Initial value
|
||||
const reportDescriptionTextbox = screen.getByTestId(
|
||||
'report-description-test',
|
||||
);
|
||||
expect(reportDescriptionTextbox).toHaveDisplayValue('');
|
||||
// Type in the textbox and assert that it worked
|
||||
userEvent.type(reportDescriptionTextbox, 'Report description text test');
|
||||
expect(reportDescriptionTextbox).toHaveDisplayValue(
|
||||
'Report description text test',
|
||||
);
|
||||
|
||||
// ----- Crontab
|
||||
const crontabInputs = screen.getAllByRole('combobox');
|
||||
expect(crontabInputs).toHaveLength(5);
|
||||
beforeEach(() => {
|
||||
mockedIsFeatureEnabled.mockImplementation(
|
||||
featureFlag => featureFlag === FeatureFlag.AlertReports,
|
||||
);
|
||||
});
|
||||
|
||||
test('inputs respond correctly', () => {
|
||||
render(<ReportModal {...defaultProps} />, { useRedux: true });
|
||||
// ----- Report name textbox
|
||||
const reportNameTextbox = screen.getByTestId('report-name-test');
|
||||
expect(reportNameTextbox).toHaveDisplayValue('Weekly Report');
|
||||
userEvent.clear(reportNameTextbox);
|
||||
userEvent.type(reportNameTextbox, 'Report name text test');
|
||||
expect(reportNameTextbox).toHaveDisplayValue('Report name text test');
|
||||
|
||||
// ----- Report description textbox
|
||||
const reportDescriptionTextbox = screen.getByTestId(
|
||||
'report-description-test',
|
||||
);
|
||||
expect(reportDescriptionTextbox).toHaveDisplayValue('');
|
||||
userEvent.type(reportDescriptionTextbox, 'Report description text test');
|
||||
expect(reportDescriptionTextbox).toHaveDisplayValue(
|
||||
'Report description text test',
|
||||
);
|
||||
|
||||
// ----- Crontab
|
||||
const crontabInputs = screen.getAllByRole('combobox');
|
||||
expect(crontabInputs).toHaveLength(5);
|
||||
});
|
||||
|
||||
test('does not allow user to create a report without a name', () => {
|
||||
render(<ReportModal {...defaultProps} />, { useRedux: true });
|
||||
const reportNameTextbox = screen.getByTestId('report-name-test');
|
||||
const addButton = screen.getByRole('button', { name: /add/i });
|
||||
|
||||
expect(reportNameTextbox).toHaveDisplayValue('Weekly Report');
|
||||
expect(addButton).toBeEnabled();
|
||||
|
||||
userEvent.clear(reportNameTextbox);
|
||||
|
||||
expect(reportNameTextbox).toHaveDisplayValue('');
|
||||
expect(addButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('creates a new email report via modal Add button', async () => {
|
||||
fetchMock.post(
|
||||
REPORT_ENDPOINT,
|
||||
{ id: 1, result: {} },
|
||||
{ name: 'post-report' },
|
||||
);
|
||||
|
||||
render(<ReportModal {...defaultProps} />, { useRedux: true });
|
||||
|
||||
const addButton = screen.getByRole('button', { name: /add/i });
|
||||
await waitFor(() => userEvent.click(addButton));
|
||||
|
||||
// Verify exactly one POST from the modal submit path
|
||||
await waitFor(() => {
|
||||
const postCalls = fetchMock.callHistory.calls('post-report');
|
||||
expect(postCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
const postCalls = fetchMock.callHistory.calls('post-report');
|
||||
const body = JSON.parse(postCalls[0].options.body as string);
|
||||
expect(body.name).toBe('Weekly Report');
|
||||
expect(body.type).toBe('Report');
|
||||
expect(body.creation_method).toBe('dashboards');
|
||||
expect(body.crontab).toBeDefined();
|
||||
expect(body.recipients).toBeDefined();
|
||||
expect(body.recipients[0].type).toBe('Email');
|
||||
|
||||
fetchMock.removeRoute('post-report');
|
||||
});
|
||||
|
||||
test('text-based chart hides screenshot width and shows message content', () => {
|
||||
// Table is text-based: should show message content but hide custom width
|
||||
const textChartProps = {
|
||||
...defaultProps,
|
||||
dashboardId: undefined,
|
||||
chart: { id: 1, sliceFormData: { viz_type: VizType.Table } },
|
||||
chartName: 'My Table Chart',
|
||||
creationMethod: 'charts' as const,
|
||||
};
|
||||
render(<ReportModal {...textChartProps} />, { useRedux: true });
|
||||
|
||||
// Message content section should be visible
|
||||
expect(screen.getByText('Message content')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Text embedded in email/i)).toBeInTheDocument();
|
||||
|
||||
// Screenshot width should NOT be visible for text-based chart
|
||||
expect(screen.queryByText('Screenshot width')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('non-text chart shows screenshot width and message content', () => {
|
||||
const lineChartProps = {
|
||||
...defaultProps,
|
||||
dashboardId: undefined,
|
||||
chart: { id: 1, sliceFormData: { viz_type: VizType.Line } },
|
||||
chartName: 'My Line Chart',
|
||||
creationMethod: 'charts' as const,
|
||||
};
|
||||
render(<ReportModal {...lineChartProps} />, { useRedux: true });
|
||||
|
||||
test('does not allow user to create a report without a name', () => {
|
||||
// Grab name textbox and add button
|
||||
const reportNameTextbox = screen.getByTestId('report-name-test');
|
||||
const addButton = screen.getByRole('button', { name: /add/i });
|
||||
// Both message content and screenshot width should be visible
|
||||
expect(screen.getByText('Message content')).toBeInTheDocument();
|
||||
expect(screen.getByText('Screenshot width')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Add button should be enabled while name textbox has text
|
||||
expect(reportNameTextbox).toHaveDisplayValue('Weekly Report');
|
||||
expect(addButton).toBeEnabled();
|
||||
test('dashboard report hides message content section', () => {
|
||||
const dashboardProps = {
|
||||
...defaultProps,
|
||||
chart: undefined,
|
||||
dashboardName: 'My Dashboard',
|
||||
};
|
||||
render(<ReportModal {...dashboardProps} />, { useRedux: true });
|
||||
|
||||
// Clear the text from the name textbox
|
||||
userEvent.clear(reportNameTextbox);
|
||||
// Message content (radio group) should NOT be visible for dashboard
|
||||
expect(screen.queryByText('Message content')).not.toBeInTheDocument();
|
||||
// Screenshot width SHOULD be visible
|
||||
expect(screen.getByText('Screenshot width')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Add button should now be disabled, blocking user from creation
|
||||
expect(reportNameTextbox).toHaveDisplayValue('');
|
||||
expect(addButton).toBeDisabled();
|
||||
test('renders edit mode when report exists in store', () => {
|
||||
const existingReport = {
|
||||
id: 42,
|
||||
name: 'Existing Dashboard Report',
|
||||
description: 'An existing report',
|
||||
crontab: '0 9 * * 1',
|
||||
creation_method: 'dashboards',
|
||||
report_format: 'PNG',
|
||||
timezone: 'America/New_York',
|
||||
active: true,
|
||||
type: 'Report',
|
||||
dashboard: 1,
|
||||
owners: [1],
|
||||
recipients: [
|
||||
{
|
||||
recipient_config_json: { target: 'test@test.com' },
|
||||
type: 'Email',
|
||||
},
|
||||
],
|
||||
};
|
||||
const store = createStore(
|
||||
{
|
||||
reports: {
|
||||
dashboards: { 1: existingReport },
|
||||
},
|
||||
},
|
||||
reducerIndex,
|
||||
);
|
||||
|
||||
render(<ReportModal {...defaultProps} />, { useRedux: true, store });
|
||||
|
||||
// Edit mode title
|
||||
expect(screen.getByText('Edit email report')).toBeInTheDocument();
|
||||
// Report name populated from store
|
||||
expect(screen.getByTestId('report-name-test')).toHaveDisplayValue(
|
||||
'Existing Dashboard Report',
|
||||
);
|
||||
// Save button instead of Add
|
||||
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('edit mode dispatches editReport via PUT on save', async () => {
|
||||
const existingReport = {
|
||||
id: 42,
|
||||
name: 'Existing Report',
|
||||
description: '',
|
||||
crontab: '0 12 * * 1',
|
||||
creation_method: 'dashboards',
|
||||
report_format: 'PNG',
|
||||
timezone: 'America/New_York',
|
||||
active: true,
|
||||
type: 'Report',
|
||||
dashboard: 1,
|
||||
owners: [1],
|
||||
recipients: [
|
||||
{
|
||||
recipient_config_json: { target: 'test@test.com' },
|
||||
type: 'Email',
|
||||
},
|
||||
],
|
||||
};
|
||||
const store = createStore(
|
||||
{
|
||||
reports: {
|
||||
dashboards: { 1: existingReport },
|
||||
},
|
||||
},
|
||||
reducerIndex,
|
||||
);
|
||||
|
||||
fetchMock.put(
|
||||
'glob:*/api/v1/report/42',
|
||||
{ id: 42, result: {} },
|
||||
{
|
||||
name: 'put-report-42',
|
||||
},
|
||||
);
|
||||
|
||||
render(<ReportModal {...defaultProps} />, { useRedux: true, store });
|
||||
|
||||
expect(screen.getByText('Edit email report')).toBeInTheDocument();
|
||||
const saveButton = screen.getByRole('button', { name: /save/i });
|
||||
await waitFor(() => userEvent.click(saveButton));
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = fetchMock.callHistory.calls('put-report-42');
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const calls = fetchMock.callHistory.calls('put-report-42');
|
||||
const body = JSON.parse(calls[calls.length - 1].options.body as string);
|
||||
|
||||
// Pin critical payload fields to catch regressions
|
||||
expect(body.type).toBe('Report');
|
||||
expect(body.name).toBe('Existing Report');
|
||||
expect(body.crontab).toBe('0 12 * * 1');
|
||||
expect(body.report_format).toBe('PNG');
|
||||
expect(body.dashboard).toBe(1);
|
||||
expect(body.recipients).toBeDefined();
|
||||
expect(body.recipients[0].type).toBe('Email');
|
||||
|
||||
fetchMock.removeRoute('put-report-42');
|
||||
});
|
||||
|
||||
test('submit failure dispatches danger toast and keeps modal open', async () => {
|
||||
fetchMock.post(REPORT_ENDPOINT, 500, { name: 'post-fail' });
|
||||
const onHide = jest.fn();
|
||||
|
||||
const store = createStore({}, reducerIndex);
|
||||
render(<ReportModal {...defaultProps} onHide={onHide} />, {
|
||||
useRedux: true,
|
||||
store,
|
||||
});
|
||||
|
||||
const addButton = screen.getByRole('button', { name: /add/i });
|
||||
await waitFor(() => userEvent.click(addButton));
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('Email Report Modal', () => {
|
||||
let dispatch: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
dispatch = jest.fn();
|
||||
});
|
||||
|
||||
test('creates a new email report', async () => {
|
||||
// ---------- Render/value setup ----------
|
||||
const reportValues = {
|
||||
id: 1,
|
||||
result: {
|
||||
active: true,
|
||||
creation_method: 'dashboards',
|
||||
crontab: '0 12 * * 1',
|
||||
dashboard: 1,
|
||||
name: 'Weekly Report',
|
||||
owners: [1],
|
||||
recipients: [
|
||||
{
|
||||
recipient_config_json: {
|
||||
target: 'test@test.com',
|
||||
},
|
||||
type: 'Email',
|
||||
},
|
||||
],
|
||||
type: 'Report',
|
||||
},
|
||||
};
|
||||
// This is needed to structure the reportValues to match the fetchMock return
|
||||
const stringyReportValues = `{"id":1,"result":{"active":true,"creation_method":"dashboards","crontab":"0 12 * * 1","dashboard":${1},"name":"Weekly Report","owners":[${1}],"recipients":[{"recipient_config_json":{"target":"test@test.com"},"type":"Email"}],"type":"Report"}}`;
|
||||
// Watch for report POST
|
||||
fetchMock.post(REPORT_ENDPOINT, reportValues);
|
||||
|
||||
// Click "Add" button to create a new email report
|
||||
const addButton = screen.getByRole('button', { name: /add/i });
|
||||
await waitFor(() => userEvent.click(addButton));
|
||||
|
||||
// Mock addReport from Redux
|
||||
const makeRequest = () => {
|
||||
const request = actions.addReport(reportValues);
|
||||
return request(dispatch);
|
||||
};
|
||||
|
||||
await makeRequest();
|
||||
|
||||
// 🐞 ----- There are 2 POST calls at this point ----- 🐞
|
||||
|
||||
// addReport's mocked POST return should match the mocked values
|
||||
expect(fetchMock.callHistory.lastCall()?.options?.body).toEqual(
|
||||
stringyReportValues,
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
const reportCalls = fetchMock.callHistory.calls(REPORT_ENDPOINT);
|
||||
expect(reportCalls).toHaveLength(2);
|
||||
});
|
||||
// The addReport action catches 500 errors, dispatches a danger toast, and re-throws
|
||||
await waitFor(() => {
|
||||
const toasts = (store.getState() as any).messageToasts;
|
||||
expect(toasts.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
toasts.some((t: { text: string }) =>
|
||||
t.text.includes('Failed to create report'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// Modal stays open — onHide should NOT have been called
|
||||
expect(onHide).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('Schedule a new email report')).toBeInTheDocument();
|
||||
|
||||
fetchMock.removeRoute('post-fail');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 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 fetchMock from 'fetch-mock';
|
||||
import {
|
||||
addReport,
|
||||
editReport,
|
||||
deleteActiveReport,
|
||||
fetchUISpecificReport,
|
||||
ADD_REPORT,
|
||||
EDIT_REPORT,
|
||||
DELETE_REPORT,
|
||||
SET_REPORT,
|
||||
} from './actions';
|
||||
|
||||
const REPORT_ENDPOINT = 'glob:*/api/v1/report/*';
|
||||
const REPORT_POST_ENDPOINT = 'glob:*/api/v1/report/';
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
});
|
||||
|
||||
test('addReport dispatches ADD_REPORT and success toast on success', async () => {
|
||||
const jsonResponse = { id: 1, result: { name: 'New Report' } };
|
||||
fetchMock.post(REPORT_POST_ENDPOINT, jsonResponse);
|
||||
const dispatch = jest.fn();
|
||||
|
||||
await addReport({ name: 'New Report' })(dispatch);
|
||||
|
||||
const types = dispatch.mock.calls.map(([action]: any) => action.type);
|
||||
expect(types).toContain(ADD_REPORT);
|
||||
expect(types).toContain('ADD_TOAST');
|
||||
const toastAction = dispatch.mock.calls.find(
|
||||
([a]: any) => a.type === 'ADD_TOAST',
|
||||
)?.[0];
|
||||
expect(toastAction.payload.toastType).toBe('SUCCESS_TOAST');
|
||||
});
|
||||
|
||||
test('addReport dispatches danger toast on failure and rejects', async () => {
|
||||
fetchMock.post(REPORT_POST_ENDPOINT, 500);
|
||||
const dispatch = jest.fn();
|
||||
|
||||
await expect(
|
||||
addReport({ name: 'Bad Report' })(dispatch),
|
||||
).rejects.toBeDefined();
|
||||
|
||||
const types = dispatch.mock.calls.map(([action]: any) => action.type);
|
||||
expect(types).not.toContain(ADD_REPORT);
|
||||
expect(types).toContain('ADD_TOAST');
|
||||
const toastAction = dispatch.mock.calls.find(
|
||||
([a]: any) => a.type === 'ADD_TOAST',
|
||||
)?.[0];
|
||||
expect(toastAction.payload.toastType).toBe('DANGER_TOAST');
|
||||
});
|
||||
|
||||
test('editReport dispatches EDIT_REPORT and success toast on success', async () => {
|
||||
const jsonResponse = { id: 5, result: { name: 'Updated Report' } };
|
||||
fetchMock.put(REPORT_ENDPOINT, jsonResponse);
|
||||
const dispatch = jest.fn();
|
||||
|
||||
await editReport(5, { name: 'Updated Report' })(dispatch);
|
||||
|
||||
const types = dispatch.mock.calls.map(([action]: any) => action.type);
|
||||
expect(types).toContain(EDIT_REPORT);
|
||||
expect(types).toContain('ADD_TOAST');
|
||||
const toastAction = dispatch.mock.calls.find(
|
||||
([a]: any) => a.type === 'ADD_TOAST',
|
||||
)?.[0];
|
||||
expect(toastAction.payload.toastType).toBe('SUCCESS_TOAST');
|
||||
});
|
||||
|
||||
test('editReport dispatches danger toast on failure and rejects', async () => {
|
||||
fetchMock.put(REPORT_ENDPOINT, 500);
|
||||
const dispatch = jest.fn();
|
||||
|
||||
await expect(
|
||||
editReport(5, { name: 'Bad Update' })(dispatch),
|
||||
).rejects.toBeDefined();
|
||||
|
||||
const types = dispatch.mock.calls.map(([action]: any) => action.type);
|
||||
expect(types).not.toContain(EDIT_REPORT);
|
||||
const toastAction = dispatch.mock.calls.find(
|
||||
([a]: any) => a.type === 'ADD_TOAST',
|
||||
)?.[0];
|
||||
expect(toastAction.payload.toastType).toBe('DANGER_TOAST');
|
||||
});
|
||||
|
||||
test('deleteActiveReport dispatches DELETE_REPORT and success toast on success', async () => {
|
||||
fetchMock.delete(REPORT_ENDPOINT, {});
|
||||
const dispatch = jest.fn();
|
||||
const report = {
|
||||
id: 10,
|
||||
name: 'To Delete',
|
||||
creation_method: 'dashboards',
|
||||
dashboard: 1,
|
||||
};
|
||||
|
||||
await deleteActiveReport(report)(dispatch);
|
||||
|
||||
const types = dispatch.mock.calls.map(([action]: any) => action.type);
|
||||
expect(types).toContain(DELETE_REPORT);
|
||||
expect(types).toContain('ADD_TOAST');
|
||||
const toastAction = dispatch.mock.calls.find(
|
||||
([a]: any) => a.type === 'ADD_TOAST',
|
||||
)?.[0];
|
||||
expect(toastAction.payload.toastType).toBe('SUCCESS_TOAST');
|
||||
});
|
||||
|
||||
test('deleteActiveReport dispatches danger toast on failure', async () => {
|
||||
fetchMock.delete(REPORT_ENDPOINT, 500);
|
||||
const dispatch = jest.fn();
|
||||
const report = { id: 10, name: 'To Delete' };
|
||||
|
||||
await deleteActiveReport(report)(dispatch);
|
||||
|
||||
const types = dispatch.mock.calls.map(([action]: any) => action.type);
|
||||
expect(types).not.toContain(DELETE_REPORT);
|
||||
const toastAction = dispatch.mock.calls.find(
|
||||
([a]: any) => a.type === 'ADD_TOAST',
|
||||
)?.[0];
|
||||
expect(toastAction.payload.toastType).toBe('DANGER_TOAST');
|
||||
});
|
||||
|
||||
test('fetchUISpecificReport dispatches SET_REPORT on success', async () => {
|
||||
const jsonResponse = { result: [{ id: 1, name: 'Dashboard Report' }] };
|
||||
fetchMock.get('glob:*/api/v1/report/?q=*', jsonResponse);
|
||||
const dispatch = jest.fn();
|
||||
|
||||
await fetchUISpecificReport({
|
||||
userId: 1,
|
||||
filterField: 'dashboard_id',
|
||||
creationMethod: 'dashboards',
|
||||
resourceId: 42,
|
||||
})(dispatch);
|
||||
|
||||
const setAction = dispatch.mock.calls.find(
|
||||
([a]: any) => a.type === SET_REPORT,
|
||||
)?.[0];
|
||||
expect(setAction).toBeDefined();
|
||||
expect(setAction.resourceId).toBe(42);
|
||||
expect(setAction.creationMethod).toBe('dashboards');
|
||||
expect(setAction.filterField).toBe('dashboard_id');
|
||||
});
|
||||
|
||||
test('fetchUISpecificReport dispatches danger toast on failure', async () => {
|
||||
fetchMock.get('glob:*/api/v1/report/?q=*', 500);
|
||||
const dispatch = jest.fn();
|
||||
|
||||
await fetchUISpecificReport({
|
||||
userId: 1,
|
||||
filterField: 'chart_id',
|
||||
creationMethod: 'charts',
|
||||
resourceId: 10,
|
||||
})(dispatch);
|
||||
|
||||
const types = dispatch.mock.calls.map(([action]: any) => action.type);
|
||||
expect(types).not.toContain(SET_REPORT);
|
||||
expect(types).toContain('ADD_TOAST');
|
||||
const toastAction = dispatch.mock.calls.find(
|
||||
([a]: any) => a.type === 'ADD_TOAST',
|
||||
)?.[0];
|
||||
expect(toastAction.payload.toastType).toBe('DANGER_TOAST');
|
||||
});
|
||||
@@ -169,8 +169,9 @@ export const addReport =
|
||||
dispatch({ type: ADD_REPORT, json } as AddReportAction);
|
||||
dispatch(addSuccessToast(t('The report has been created')));
|
||||
})
|
||||
.catch(() => {
|
||||
.catch(err => {
|
||||
dispatch(addDangerToast(t('Failed to create report')));
|
||||
throw err;
|
||||
});
|
||||
|
||||
export const EDIT_REPORT = 'EDIT_REPORT' as const;
|
||||
@@ -191,8 +192,9 @@ export const editReport =
|
||||
dispatch({ type: EDIT_REPORT, json } as EditReportAction);
|
||||
dispatch(addSuccessToast(t('Report updated')));
|
||||
})
|
||||
.catch(() => {
|
||||
.catch(err => {
|
||||
dispatch(addDangerToast(t('Failed to update report')));
|
||||
throw err;
|
||||
});
|
||||
|
||||
export function toggleActive(report: ReportObject, isActive: boolean) {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 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 reportsReducer, { ReportsState } from './reducer';
|
||||
import {
|
||||
SET_REPORT,
|
||||
ADD_REPORT,
|
||||
EDIT_REPORT,
|
||||
DELETE_REPORT,
|
||||
SetReportAction,
|
||||
AddReportAction,
|
||||
EditReportAction,
|
||||
DeleteReportAction,
|
||||
} from './actions';
|
||||
import { ReportObject } from 'src/features/reports/types';
|
||||
|
||||
const makeReport = (overrides: Partial<ReportObject> = {}): ReportObject => ({
|
||||
active: true,
|
||||
crontab: '0 12 * * 1',
|
||||
name: 'Test Report',
|
||||
owners: [1],
|
||||
recipients: [
|
||||
{
|
||||
recipient_config_json: { target: 'a@b.com', ccTarget: '', bccTarget: '' },
|
||||
type: 'Email',
|
||||
},
|
||||
],
|
||||
report_format: 'PNG',
|
||||
timezone: 'UTC',
|
||||
type: 'Report',
|
||||
validator_config_json: null,
|
||||
validator_type: '',
|
||||
working_timeout: 3600,
|
||||
log_retention: 90,
|
||||
creation_method: 'dashboards',
|
||||
force_screenshot: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('SET_REPORT stores report keyed by resourceId under creationMethod', () => {
|
||||
const report = makeReport({ id: 5, dashboard: 10 });
|
||||
const action: SetReportAction = {
|
||||
type: SET_REPORT,
|
||||
report: { result: [report] },
|
||||
resourceId: 10,
|
||||
creationMethod: 'dashboards',
|
||||
filterField: 'dashboard_id',
|
||||
};
|
||||
|
||||
const result = reportsReducer({}, action);
|
||||
|
||||
expect(result.dashboards?.[10]).toEqual(report);
|
||||
});
|
||||
|
||||
test('SET_REPORT removes entry when API returns empty result', () => {
|
||||
const initial: ReportsState = {
|
||||
dashboards: { 10: makeReport({ id: 5, dashboard: 10 }) },
|
||||
};
|
||||
const action: SetReportAction = {
|
||||
type: SET_REPORT,
|
||||
report: { result: [] },
|
||||
resourceId: 10,
|
||||
creationMethod: 'dashboards',
|
||||
filterField: 'dashboard_id',
|
||||
};
|
||||
|
||||
const result = reportsReducer(initial, action);
|
||||
|
||||
expect(result.dashboards?.[10]).toBeUndefined();
|
||||
});
|
||||
|
||||
test('SET_REPORT uses chart property when filterField is chart_id', () => {
|
||||
const report = makeReport({
|
||||
id: 7,
|
||||
chart: 42,
|
||||
creation_method: 'charts',
|
||||
});
|
||||
const action: SetReportAction = {
|
||||
type: SET_REPORT,
|
||||
report: { result: [report] },
|
||||
resourceId: 42,
|
||||
creationMethod: 'charts',
|
||||
filterField: 'chart_id',
|
||||
};
|
||||
|
||||
const result = reportsReducer({}, action);
|
||||
|
||||
expect(result.charts?.[42]).toEqual(report);
|
||||
});
|
||||
|
||||
test('ADD_REPORT keys dashboard report by dashboard id', () => {
|
||||
const action: AddReportAction = {
|
||||
type: ADD_REPORT,
|
||||
json: {
|
||||
id: 1,
|
||||
result: { dashboard: 10, creation_method: 'dashboards' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = reportsReducer({}, action);
|
||||
|
||||
expect(result.dashboards?.[10]).toMatchObject({ id: 1, dashboard: 10 });
|
||||
});
|
||||
|
||||
test('ADD_REPORT keys alerts_reports report by report id', () => {
|
||||
const action: AddReportAction = {
|
||||
type: ADD_REPORT,
|
||||
json: {
|
||||
id: 99,
|
||||
result: { creation_method: 'alerts_reports' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = reportsReducer({}, action);
|
||||
|
||||
expect(result.alerts_reports?.[99]).toMatchObject({ id: 99 });
|
||||
});
|
||||
|
||||
test('ADD_REPORT returns unchanged state when key is undefined', () => {
|
||||
const initial: ReportsState = { dashboards: {} };
|
||||
const action: AddReportAction = {
|
||||
type: ADD_REPORT,
|
||||
json: {
|
||||
id: 1,
|
||||
result: { creation_method: 'dashboards' },
|
||||
// no dashboard or chart field → key is undefined
|
||||
},
|
||||
};
|
||||
|
||||
const result = reportsReducer(initial, action);
|
||||
|
||||
expect(result).toBe(initial);
|
||||
});
|
||||
|
||||
test('EDIT_REPORT replaces existing report at same key', () => {
|
||||
const initial: ReportsState = {
|
||||
dashboards: {
|
||||
10: makeReport({ id: 1, dashboard: 10, name: 'Old Name' }),
|
||||
},
|
||||
};
|
||||
const action: EditReportAction = {
|
||||
type: EDIT_REPORT,
|
||||
json: {
|
||||
id: 1,
|
||||
result: {
|
||||
dashboard: 10,
|
||||
creation_method: 'dashboards',
|
||||
name: 'New Name',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = reportsReducer(initial, action);
|
||||
|
||||
expect(result.dashboards?.[10]?.name).toBe('New Name');
|
||||
});
|
||||
|
||||
test('DELETE_REPORT removes report from state', () => {
|
||||
const report = makeReport({
|
||||
id: 5,
|
||||
dashboard: 10,
|
||||
creation_method: 'dashboards',
|
||||
});
|
||||
const initial: ReportsState = { dashboards: { 10: report } };
|
||||
const action: DeleteReportAction = {
|
||||
type: DELETE_REPORT,
|
||||
report: {
|
||||
id: 5,
|
||||
dashboard: 10,
|
||||
creation_method: 'dashboards',
|
||||
},
|
||||
};
|
||||
|
||||
const result = reportsReducer(initial, action);
|
||||
|
||||
expect(result.dashboards?.[10]).toBeUndefined();
|
||||
});
|
||||
|
||||
test('DELETE_REPORT for alerts_reports keys by report id', () => {
|
||||
const report = makeReport({ id: 99, creation_method: 'alerts_reports' });
|
||||
const initial: ReportsState = { alerts_reports: { 99: report } };
|
||||
const action: DeleteReportAction = {
|
||||
type: DELETE_REPORT,
|
||||
report: { id: 99, creation_method: 'alerts_reports' },
|
||||
};
|
||||
|
||||
const result = reportsReducer(initial, action);
|
||||
|
||||
expect(result.alerts_reports?.[99]).toBeUndefined();
|
||||
});
|
||||
|
||||
test('unknown action type returns state unchanged', () => {
|
||||
const initial: ReportsState = { dashboards: { 1: makeReport({ id: 1 }) } };
|
||||
const action = { type: 'UNKNOWN_ACTION' } as any;
|
||||
|
||||
const result = reportsReducer(initial, action);
|
||||
|
||||
expect(result).toBe(initial);
|
||||
});
|
||||
@@ -16,271 +16,469 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type React from 'react';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
createStore,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { QueryParamProvider } from 'use-query-params';
|
||||
import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
|
||||
import React from 'react';
|
||||
import AlertListComponent from 'src/pages/AlertReportList';
|
||||
|
||||
// Cast to accept partial mock props in tests
|
||||
jest.setTimeout(30000);
|
||||
|
||||
const AlertList = AlertListComponent as unknown as React.FC<
|
||||
Record<string, any>
|
||||
>;
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
const store = mockStore({});
|
||||
// -- Mock data (IDs start at 1 to avoid the `if (data?.id)` falsy guard) --
|
||||
|
||||
const alertsEndpoint = 'glob:*/api/v1/report/?*';
|
||||
const alertEndpoint = 'glob:*/api/v1/report/*';
|
||||
const alertsInfoEndpoint = 'glob:*/api/v1/report/_info*';
|
||||
const alertsCreatedByEndpoint = 'glob:*/api/v1/report/related/created_by*';
|
||||
const mockAlerts = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Weekly Sales Alert',
|
||||
active: true,
|
||||
last_state: 'Success',
|
||||
type: 'Alert',
|
||||
owners: [{ id: 1, first_name: 'Admin', last_name: 'User' }],
|
||||
recipients: [{ id: 1, type: 'Email' }],
|
||||
changed_by: { id: 1, first_name: 'Admin', last_name: 'User' },
|
||||
changed_on_delta_humanized: '1 day ago',
|
||||
created_by: { id: 1, first_name: 'Admin', last_name: 'User' },
|
||||
created_on: new Date().toISOString(),
|
||||
last_eval_dttm: Date.now(),
|
||||
crontab: '0 9 * * 1',
|
||||
crontab_humanized: 'Every Monday at 09:00',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Daily Error Alert',
|
||||
active: true,
|
||||
last_state: 'Error',
|
||||
type: 'Alert',
|
||||
owners: [{ id: 2, first_name: 'Data', last_name: 'Analyst' }],
|
||||
recipients: [{ id: 2, type: 'Slack' }],
|
||||
changed_by: { id: 2, first_name: 'Data', last_name: 'Analyst' },
|
||||
changed_on_delta_humanized: '2 days ago',
|
||||
created_by: { id: 2, first_name: 'Data', last_name: 'Analyst' },
|
||||
created_on: new Date().toISOString(),
|
||||
last_eval_dttm: Date.now(),
|
||||
crontab: '0 8 * * *',
|
||||
crontab_humanized: 'Every day at 08:00',
|
||||
timezone: 'US/Pacific',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Monthly Revenue Alert',
|
||||
active: false,
|
||||
last_state: 'Working',
|
||||
type: 'Alert',
|
||||
owners: [{ id: 1, first_name: 'Admin', last_name: 'User' }],
|
||||
recipients: [{ id: 3, type: 'Email' }],
|
||||
changed_by: { id: 1, first_name: 'Admin', last_name: 'User' },
|
||||
changed_on_delta_humanized: '5 days ago',
|
||||
created_by: { id: 1, first_name: 'Admin', last_name: 'User' },
|
||||
created_on: new Date().toISOString(),
|
||||
last_eval_dttm: Date.now(),
|
||||
crontab: '0 0 1 * *',
|
||||
crontab_humanized: 'First day of the month',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
];
|
||||
|
||||
const mockalerts = Array.from({ length: 3 }, (_, i) => ({
|
||||
active: true,
|
||||
changed_by: {
|
||||
first_name: `user ${i}`,
|
||||
id: i,
|
||||
const mockReports = [
|
||||
{
|
||||
id: 10,
|
||||
name: 'Weekly Dashboard Report',
|
||||
active: true,
|
||||
last_state: 'Success',
|
||||
type: 'Report',
|
||||
owners: [{ id: 1, first_name: 'Admin', last_name: 'User' }],
|
||||
recipients: [{ id: 10, type: 'Email' }],
|
||||
changed_by: { id: 1, first_name: 'Admin', last_name: 'User' },
|
||||
changed_on_delta_humanized: '1 day ago',
|
||||
created_by: { id: 1, first_name: 'Admin', last_name: 'User' },
|
||||
created_on: new Date().toISOString(),
|
||||
last_eval_dttm: Date.now(),
|
||||
crontab: '0 9 * * 1',
|
||||
crontab_humanized: 'Every Monday at 09:00',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
changed_on_delta_humanized: `${i} day(s) ago`,
|
||||
created_by: {
|
||||
first_name: `user ${i}`,
|
||||
id: i,
|
||||
{
|
||||
id: 11,
|
||||
name: 'Monthly KPI Report',
|
||||
active: false,
|
||||
last_state: 'Not triggered',
|
||||
type: 'Report',
|
||||
owners: [{ id: 1, first_name: 'Admin', last_name: 'User' }],
|
||||
recipients: [{ id: 11, type: 'Slack' }],
|
||||
changed_by: { id: 1, first_name: 'Admin', last_name: 'User' },
|
||||
changed_on_delta_humanized: '3 days ago',
|
||||
created_by: { id: 1, first_name: 'Admin', last_name: 'User' },
|
||||
created_on: new Date().toISOString(),
|
||||
last_eval_dttm: Date.now(),
|
||||
crontab: '0 0 1 * *',
|
||||
crontab_humanized: 'First day of the month',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
created_on: new Date().toISOString,
|
||||
id: i,
|
||||
last_eval_dttm: Date.now(),
|
||||
last_state: 'ok',
|
||||
name: `alert ${i} `,
|
||||
owners: [{ id: 1 }],
|
||||
recipients: [
|
||||
{
|
||||
id: `${i}`,
|
||||
type: 'email',
|
||||
},
|
||||
],
|
||||
type: 'alert',
|
||||
}));
|
||||
];
|
||||
|
||||
const mockUser = {
|
||||
userId: 1,
|
||||
firstName: 'user 1',
|
||||
lastName: 'lastname',
|
||||
firstName: 'Admin',
|
||||
lastName: 'User',
|
||||
};
|
||||
|
||||
fetchMock.get(alertsEndpoint, {
|
||||
ids: [2, 0, 1],
|
||||
result: mockalerts,
|
||||
count: 3,
|
||||
});
|
||||
fetchMock.get(alertsInfoEndpoint, {
|
||||
permissions: ['can_write'],
|
||||
});
|
||||
fetchMock.get(alertsCreatedByEndpoint, { result: [] });
|
||||
fetchMock.put(alertEndpoint, { ...mockalerts[0], active: false });
|
||||
fetchMock.put(alertsEndpoint, { ...mockalerts[0], active: false });
|
||||
fetchMock.delete(alertEndpoint, {});
|
||||
fetchMock.delete(alertsEndpoint, {});
|
||||
// -- API endpoints (named for cleanup) --
|
||||
|
||||
const renderAlertList = (props = {}) =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<AlertList user={mockUser} {...props} />
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{
|
||||
useRedux: true,
|
||||
store,
|
||||
const ENDPOINTS = {
|
||||
LIST: 'glob:*/api/v1/report/?*',
|
||||
INFO: 'glob:*/api/v1/report/_info*',
|
||||
SINGLE: 'glob:*/api/v1/report/*',
|
||||
CREATED_BY: 'glob:*/api/v1/report/related/created_by*',
|
||||
OWNERS: 'glob:*/api/v1/report/related/owners*',
|
||||
CHANGED_BY: 'glob:*/api/v1/report/related/changed_by*',
|
||||
};
|
||||
|
||||
// -- Render helper --
|
||||
|
||||
const renderAlertList = (props: Record<string, any> = {}) => {
|
||||
const store = createStore();
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<AlertList user={mockUser} {...props} />
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>
|
||||
</Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
// -- Dynamic list endpoint: returns alerts or reports based on URL filter --
|
||||
|
||||
const setupMocks = (
|
||||
permissions: string[] = ['can_read', 'can_write'],
|
||||
listData?: typeof mockAlerts,
|
||||
) => {
|
||||
fetchMock.get(ENDPOINTS.INFO, { permissions }, { name: 'info' });
|
||||
|
||||
fetchMock.get(
|
||||
ENDPOINTS.LIST,
|
||||
({ url }: any) => {
|
||||
if (listData) {
|
||||
return {
|
||||
result: listData,
|
||||
count: listData.length,
|
||||
ids: listData.map(a => a.id),
|
||||
};
|
||||
}
|
||||
const data = url.includes('value:Report') ? mockReports : mockAlerts;
|
||||
return { result: data, count: data.length, ids: data.map(a => a.id) };
|
||||
},
|
||||
{ name: 'list' },
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('AlertList', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.clearHistory();
|
||||
});
|
||||
fetchMock.get(ENDPOINTS.CREATED_BY, { result: [] }, { name: 'created-by' });
|
||||
|
||||
test('renders', async () => {
|
||||
renderAlertList();
|
||||
expect(await screen.findByText('Alerts & reports')).toBeInTheDocument();
|
||||
});
|
||||
fetchMock.get(ENDPOINTS.OWNERS, { result: [], count: 0 }, { name: 'owners' });
|
||||
|
||||
test('renders a SubMenu', async () => {
|
||||
renderAlertList();
|
||||
expect(await screen.findByRole('navigation')).toBeInTheDocument();
|
||||
});
|
||||
fetchMock.get(
|
||||
ENDPOINTS.CHANGED_BY,
|
||||
{ result: [], count: 0 },
|
||||
{ name: 'changed-by' },
|
||||
);
|
||||
|
||||
test('renders a ListView', async () => {
|
||||
renderAlertList();
|
||||
expect(await screen.findByTestId('alerts-list-view')).toBeInTheDocument();
|
||||
});
|
||||
fetchMock.put(
|
||||
ENDPOINTS.SINGLE,
|
||||
{ result: { ...mockAlerts[0], active: false } },
|
||||
{ name: 'put-alert' },
|
||||
);
|
||||
|
||||
test('renders switches', async () => {
|
||||
renderAlertList();
|
||||
// Wait for the list to load first
|
||||
await screen.findByTestId('alerts-list-view');
|
||||
const switches = await screen.findAllByRole('switch');
|
||||
expect(switches).toHaveLength(3);
|
||||
});
|
||||
fetchMock.delete(ENDPOINTS.SINGLE, {}, { name: 'delete-alert' });
|
||||
|
||||
test('deletes', async () => {
|
||||
renderAlertList();
|
||||
fetchMock.delete(
|
||||
ENDPOINTS.LIST,
|
||||
{ message: 'Deleted' },
|
||||
{ name: 'delete-bulk' },
|
||||
);
|
||||
};
|
||||
|
||||
// Wait for list to load
|
||||
await screen.findByTestId('alerts-list-view');
|
||||
// -- Setup / teardown --
|
||||
|
||||
// Find and click first delete button
|
||||
const deleteButtons = await screen.findAllByTestId('delete-action');
|
||||
fireEvent.click(deleteButtons[0]);
|
||||
|
||||
// Wait for modal to appear and find the delete input
|
||||
const deleteInput = await screen.findByTestId('delete-modal-input');
|
||||
fireEvent.change(deleteInput, { target: { value: 'DELETE' } });
|
||||
|
||||
// Click confirm button
|
||||
const confirmButton = await screen.findByTestId('modal-confirm-button');
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
// Wait for delete request
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.callHistory.calls(/report\/0/, { method: 'DELETE' }),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
test('shows/hides bulk actions when bulk actions is clicked', async () => {
|
||||
renderAlertList();
|
||||
|
||||
// Wait for list to load and initial state
|
||||
await screen.findByTestId('alerts-list-view');
|
||||
expect(
|
||||
screen.queryByTestId('bulk-select-controls'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Click bulk select toggle
|
||||
const bulkSelectButton = await screen.findByTestId('bulk-select-toggle');
|
||||
fireEvent.click(bulkSelectButton);
|
||||
|
||||
// Verify bulk select controls appear
|
||||
expect(
|
||||
await screen.findByTestId('bulk-select-controls'),
|
||||
).toBeInTheDocument();
|
||||
}, 15000);
|
||||
|
||||
test('hides bulk actions when switch between alert and report list', async () => {
|
||||
// Start with alert list
|
||||
renderAlertList();
|
||||
|
||||
// Wait for list to load
|
||||
await screen.findByTestId('alerts-list-view');
|
||||
|
||||
// Click bulk select to show controls
|
||||
const bulkSelectButton = await screen.findByTestId('bulk-select-toggle');
|
||||
fireEvent.click(bulkSelectButton);
|
||||
|
||||
// Verify bulk select controls appear
|
||||
expect(
|
||||
await screen.findByTestId('bulk-select-controls'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Verify alert tab is active
|
||||
const alertTab = await screen.findByTestId('alert-list');
|
||||
expect(alertTab).toHaveClass('active');
|
||||
const reportTab = screen.getByTestId('report-list');
|
||||
expect(reportTab).not.toHaveClass('active');
|
||||
|
||||
// Switch to report list
|
||||
renderAlertList({ isReportEnabled: true });
|
||||
|
||||
// Wait for report list API call and tab states to update
|
||||
await waitFor(async () => {
|
||||
// Check API call
|
||||
const calls = fetchMock.callHistory.calls(/report\/\?q/);
|
||||
const hasReportCall = calls.some(call =>
|
||||
call.url.includes('filters:!((col:type,opr:eq,value:Report))'),
|
||||
);
|
||||
|
||||
// Check tab states
|
||||
const reportTabs = screen.getAllByTestId('report-list');
|
||||
const alertTabs = screen.getAllByTestId('alert-list');
|
||||
const hasActiveReport = reportTabs.some(tab =>
|
||||
tab.classList.contains('active'),
|
||||
);
|
||||
const hasNoActiveAlert = alertTabs.every(
|
||||
tab => !tab.classList.contains('active'),
|
||||
);
|
||||
|
||||
return hasReportCall && hasActiveReport && hasNoActiveAlert;
|
||||
});
|
||||
|
||||
// Click bulk select toggle again to hide controls
|
||||
const bulkSelectButtons =
|
||||
await screen.findAllByTestId('bulk-select-toggle');
|
||||
fireEvent.click(bulkSelectButtons[0]);
|
||||
|
||||
// Verify final state
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId('bulk-select-controls'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify correct API call was made
|
||||
const reportCalls = fetchMock.callHistory.calls(/report\/\?q/);
|
||||
const lastReportCall = reportCalls[reportCalls.length - 1].url;
|
||||
expect(lastReportCall).toContain(
|
||||
'filters:!((col:type,opr:eq,value:Report))',
|
||||
);
|
||||
}, 15000);
|
||||
|
||||
test('renders listview table correctly', async () => {
|
||||
renderAlertList();
|
||||
await screen.findByTestId('alerts-list-view');
|
||||
|
||||
const table = await screen.findByTestId('listview-table');
|
||||
expect(table).toBeInTheDocument();
|
||||
expect(table).toBeVisible();
|
||||
}, 15000);
|
||||
|
||||
test('renders correct column headers for alerts', async () => {
|
||||
renderAlertList();
|
||||
await screen.findByTestId('alerts-list-view');
|
||||
|
||||
expect(screen.getByTitle('Last run')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('columnheader', { name: /name/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Schedule')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Notification method')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Owners')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Last modified')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Active')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Actions')).toBeInTheDocument();
|
||||
}, 15000);
|
||||
|
||||
test('renders correct column headers for reports', async () => {
|
||||
renderAlertList({ isReportEnabled: true });
|
||||
await screen.findByTestId('alerts-list-view');
|
||||
|
||||
expect(screen.getByTitle('Last run')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('columnheader', { name: /name/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Schedule')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Notification method')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Owners')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Last modified')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Active')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Actions')).toBeInTheDocument();
|
||||
}, 15000);
|
||||
beforeEach(() => {
|
||||
fetchMock.removeRoutes().clearHistory();
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.removeRoutes().clearHistory();
|
||||
});
|
||||
|
||||
// -- Tests --
|
||||
|
||||
test('loads rows from API and renders alert names, status, and actions', async () => {
|
||||
renderAlertList();
|
||||
|
||||
// All 3 alert names appear
|
||||
await screen.findByText('Weekly Sales Alert');
|
||||
expect(screen.getByText('Daily Error Alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Monthly Revenue Alert')).toBeInTheDocument();
|
||||
|
||||
// Active switches rendered for each row
|
||||
const switches = screen.getAllByRole('switch');
|
||||
expect(switches).toHaveLength(3);
|
||||
|
||||
// Delete actions present for owned alerts (userId=1 owns alerts 1 and 3)
|
||||
const deleteButtons = screen.getAllByTestId('delete-action');
|
||||
expect(deleteButtons.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Column headers
|
||||
expect(screen.getByTitle('Last run')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('columnheader', { name: /name/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Schedule')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Active')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Actions')).toBeInTheDocument();
|
||||
|
||||
// API was called with Alert filter
|
||||
const listCalls = fetchMock.callHistory.calls('list');
|
||||
expect(listCalls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(listCalls[0].url).toContain('value:Alert');
|
||||
});
|
||||
|
||||
test('toggle active sends PUT and updates switch state', async () => {
|
||||
renderAlertList();
|
||||
await screen.findByText('Weekly Sales Alert');
|
||||
|
||||
const switches = screen.getAllByRole('switch');
|
||||
// First switch is for alert id=1 (owned by user, active: true)
|
||||
expect(switches[0]).toBeChecked();
|
||||
|
||||
fireEvent.click(switches[0]);
|
||||
|
||||
// PUT called with active=false
|
||||
await waitFor(() => {
|
||||
const putCalls = fetchMock.callHistory.calls('put-alert');
|
||||
expect(putCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
const putCalls = fetchMock.callHistory.calls('put-alert');
|
||||
const body = JSON.parse(putCalls[0].options.body as string);
|
||||
expect(body.active).toBe(false);
|
||||
expect(putCalls[0].url).toContain('/report/1');
|
||||
});
|
||||
|
||||
test('toggle active rolls back on failed update', async () => {
|
||||
renderAlertList();
|
||||
await screen.findByText('Weekly Sales Alert');
|
||||
|
||||
// Replace PUT with 500 error
|
||||
fetchMock.removeRoute('put-alert');
|
||||
fetchMock.put(ENDPOINTS.SINGLE, 500, { name: 'put-fail' });
|
||||
|
||||
const switches = screen.getAllByRole('switch');
|
||||
expect(switches[0]).toBeChecked();
|
||||
|
||||
fireEvent.click(switches[0]);
|
||||
|
||||
// PUT was attempted
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.callHistory.calls('put-fail')).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Switch rolls back to checked (server rejected)
|
||||
await waitFor(() => {
|
||||
expect(switches[0]).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
test('switching to Reports refetches and renders only report rows', async () => {
|
||||
// Render alerts mode first
|
||||
const { unmount } = renderAlertList();
|
||||
await screen.findByText('Weekly Sales Alert');
|
||||
unmount();
|
||||
|
||||
// Render reports mode
|
||||
fetchMock.clearHistory();
|
||||
renderAlertList({ isReportEnabled: true });
|
||||
|
||||
await screen.findByText('Weekly Dashboard Report');
|
||||
expect(screen.getByText('Monthly KPI Report')).toBeInTheDocument();
|
||||
|
||||
// Alert names should not appear
|
||||
expect(screen.queryByText('Weekly Sales Alert')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Daily Error Alert')).not.toBeInTheDocument();
|
||||
|
||||
// API called with Report filter
|
||||
const listCalls = fetchMock.callHistory.calls('list');
|
||||
expect(listCalls.length).toBeGreaterThanOrEqual(1);
|
||||
const reportCall = listCalls.find((c: any) => c.url.includes('value:Report'));
|
||||
expect(reportCall).toBeDefined();
|
||||
});
|
||||
|
||||
test('delete removes row after confirmation', async () => {
|
||||
// Track deletions so the GET mock reflects them on refetch
|
||||
const deletedIds = new Set<number>();
|
||||
fetchMock.removeRoute('list');
|
||||
fetchMock.get(
|
||||
ENDPOINTS.LIST,
|
||||
(_callLog: any) => {
|
||||
const remaining = mockAlerts.filter(a => !deletedIds.has(a.id));
|
||||
return {
|
||||
result: remaining,
|
||||
count: remaining.length,
|
||||
ids: remaining.map(a => a.id),
|
||||
};
|
||||
},
|
||||
{ name: 'list' },
|
||||
);
|
||||
|
||||
// Override DELETE to mark the alert as deleted before returning
|
||||
fetchMock.removeRoute('delete-alert');
|
||||
fetchMock.delete(
|
||||
ENDPOINTS.SINGLE,
|
||||
({ url }: any) => {
|
||||
const match = url.match(/\/report\/(\d+)/);
|
||||
if (match) deletedIds.add(Number(match[1]));
|
||||
return {};
|
||||
},
|
||||
{ name: 'delete-alert' },
|
||||
);
|
||||
|
||||
renderAlertList();
|
||||
await screen.findByText('Weekly Sales Alert');
|
||||
|
||||
// Click delete on first owned alert
|
||||
const deleteButtons = screen.getAllByTestId('delete-action');
|
||||
fireEvent.click(deleteButtons[0]);
|
||||
|
||||
// Confirm in delete modal
|
||||
const deleteInput = await screen.findByTestId('delete-modal-input');
|
||||
fireEvent.change(deleteInput, { target: { value: 'DELETE' } });
|
||||
const confirmButton = await screen.findByTestId('modal-confirm-button');
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
// Row disappears after refetch
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Weekly Sales Alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Other alerts remain
|
||||
expect(screen.getByText('Daily Error Alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('delete failure leaves row visible', async () => {
|
||||
// Replace DELETE with 500
|
||||
fetchMock.removeRoute('delete-alert');
|
||||
fetchMock.delete(ENDPOINTS.SINGLE, 500, { name: 'delete-fail' });
|
||||
|
||||
renderAlertList();
|
||||
await screen.findByText('Weekly Sales Alert');
|
||||
|
||||
const deleteButtons = screen.getAllByTestId('delete-action');
|
||||
fireEvent.click(deleteButtons[0]);
|
||||
|
||||
const deleteInput = await screen.findByTestId('delete-modal-input');
|
||||
fireEvent.change(deleteInput, { target: { value: 'DELETE' } });
|
||||
const confirmButton = await screen.findByTestId('modal-confirm-button');
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.callHistory.calls('delete-fail')).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Row stays visible
|
||||
expect(screen.getByText('Weekly Sales Alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('bulk select shows selected count and enables bulk actions after row selection', async () => {
|
||||
renderAlertList();
|
||||
await screen.findByText('Weekly Sales Alert');
|
||||
|
||||
// Bulk select controls not visible initially
|
||||
expect(screen.queryByTestId('bulk-select-controls')).not.toBeInTheDocument();
|
||||
|
||||
// Toggle bulk select
|
||||
const bulkSelectButton = screen.getByTestId('bulk-select-toggle');
|
||||
fireEvent.click(bulkSelectButton);
|
||||
|
||||
// Controls appear with "0 Selected" text
|
||||
await screen.findByTestId('bulk-select-controls');
|
||||
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
|
||||
'0 Selected',
|
||||
);
|
||||
|
||||
// Deselect-all and action button not yet visible (nothing selected)
|
||||
expect(
|
||||
screen.queryByTestId('bulk-select-deselect-all'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Select all rows via checkboxes that appear in bulk mode
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
// First checkbox is the header "select all" toggle
|
||||
fireEvent.click(checkboxes[0]);
|
||||
|
||||
// Bulk action button and deselect-all appear after selection
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('bulk-select-action')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('bulk-select-deselect-all')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
|
||||
'3 Selected',
|
||||
);
|
||||
});
|
||||
|
||||
test('read-only users do not see delete and bulk select controls', async () => {
|
||||
fetchMock.removeRoutes().clearHistory();
|
||||
setupMocks(['can_read']); // no can_write
|
||||
|
||||
const readOnlyUser = {
|
||||
userId: 99,
|
||||
firstName: 'Read',
|
||||
lastName: 'Only',
|
||||
};
|
||||
|
||||
const store = createStore();
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<AlertList user={readOnlyUser} />
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
await screen.findByText('Weekly Sales Alert');
|
||||
|
||||
// No delete action buttons
|
||||
expect(screen.queryAllByTestId('delete-action')).toHaveLength(0);
|
||||
|
||||
// No bulk select toggle
|
||||
expect(screen.queryByTestId('bulk-select-toggle')).not.toBeInTheDocument();
|
||||
|
||||
// Switches are all disabled (user 99 doesn't own any alerts)
|
||||
const switches = screen.getAllByRole('switch');
|
||||
switches.forEach(sw => {
|
||||
expect(sw).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test('empty API result shows empty state', async () => {
|
||||
fetchMock.removeRoutes().clearHistory();
|
||||
setupMocks(['can_read', 'can_write'], []);
|
||||
|
||||
renderAlertList();
|
||||
|
||||
expect(await screen.findByText(/no alerts yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -242,9 +242,13 @@ function AlertList({
|
||||
}),
|
||||
);
|
||||
|
||||
updateResource(update_id, { active: checked }, false, false)
|
||||
.then()
|
||||
.catch(() => setResourceCollection(original));
|
||||
updateResource(update_id, { active: checked }, false, false).then(
|
||||
response => {
|
||||
if (!response) {
|
||||
setResourceCollection(original);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
[alerts, setResourceCollection, updateResource],
|
||||
|
||||
Generated
+212
-1207
File diff suppressed because it is too large
Load Diff
@@ -18,12 +18,12 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"cookie": "^1.1.1",
|
||||
"hot-shots": "^14.2.0",
|
||||
"ioredis": "^5.10.0",
|
||||
"hot-shots": "^14.3.1",
|
||||
"ioredis": "^5.10.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lodash": "^4.17.23",
|
||||
"lodash": "^4.18.1",
|
||||
"winston": "^3.19.0",
|
||||
"ws": "^8.19.0"
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.1",
|
||||
@@ -31,21 +31,21 @@
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.58.0",
|
||||
"@typescript-eslint/parser": "^8.57.0",
|
||||
"eslint": "^10.1.0",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-lodash": "^8.0.0",
|
||||
"globals": "^17.4.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.8.1",
|
||||
"ts-jest": "^29.4.6",
|
||||
"ts-jest": "^29.4.9",
|
||||
"ts-node": "^10.9.2",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.1"
|
||||
"typescript-eslint": "^8.58.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.0",
|
||||
|
||||
@@ -287,14 +287,19 @@ class BaseReportState:
|
||||
except json.JSONDecodeError:
|
||||
logger.debug("Anchor value is not a list, Fall back to single tab")
|
||||
|
||||
# Merge native_filters into existing urlParams instead of
|
||||
# overwriting — dashboard_state may already have urlParams
|
||||
# (e.g. standalone=true) that must be preserved.
|
||||
state: DashboardPermalinkState = {**dashboard_state}
|
||||
existing_params: list[tuple[str, str]] = state.get("urlParams") or []
|
||||
merged_params: list[list[str]] = [
|
||||
list(p) for p in existing_params if p[0] != "native_filters"
|
||||
]
|
||||
merged_params.append(["native_filters", native_filter_params or ""])
|
||||
state["urlParams"] = merged_params # type: ignore[typeddict-item]
|
||||
return [
|
||||
self._get_tab_url(
|
||||
{
|
||||
"urlParams": [
|
||||
["native_filters", native_filter_params] # type: ignore
|
||||
],
|
||||
**dashboard_state,
|
||||
},
|
||||
state,
|
||||
user_friendly=user_friendly,
|
||||
)
|
||||
]
|
||||
@@ -479,7 +484,7 @@ class BaseReportState:
|
||||
raise ReportScheduleCsvTimeout() from ex
|
||||
except Exception as ex:
|
||||
elapsed_seconds = (datetime.utcnow() - start_time).total_seconds()
|
||||
logger.error(
|
||||
logger.exception(
|
||||
"CSV generation failed after %.2fs - execution_id: %s",
|
||||
elapsed_seconds,
|
||||
self._execution_id,
|
||||
|
||||
@@ -344,7 +344,9 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
|
||||
if clause and self.datasource:
|
||||
try:
|
||||
database = self.datasource.database
|
||||
processor = get_template_processor(database=database)
|
||||
processor = get_template_processor(
|
||||
database=database, table=self.datasource
|
||||
)
|
||||
try:
|
||||
clause = processor.process_template(clause, force=True)
|
||||
except TemplateError as ex:
|
||||
|
||||
@@ -1257,7 +1257,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
|
||||
|
||||
if parse_boolean_string(request.args.get("include_rendered_sql")):
|
||||
try:
|
||||
processor = get_template_processor(database=table.database)
|
||||
processor = get_template_processor(database=table.database, table=table)
|
||||
response["result"] = self.render_dataset_fields(
|
||||
response["result"], processor
|
||||
)
|
||||
|
||||
@@ -37,7 +37,7 @@ class CrateEngineSpec(BaseEngineSpec):
|
||||
"CrateDB is a distributed SQL database for machine data and IoT workloads."
|
||||
),
|
||||
"logo": "cratedb.svg",
|
||||
"homepage_url": "https://crate.io/",
|
||||
"homepage_url": "https://cratedb.com",
|
||||
"categories": [DatabaseCategory.TIME_SERIES, DatabaseCategory.OPEN_SOURCE],
|
||||
"pypi_packages": ["crate", "sqlalchemy-cratedb"],
|
||||
"connection_string": "crate://{host}:{port}",
|
||||
|
||||
@@ -54,6 +54,10 @@ Dashboard Management:
|
||||
- generate_dashboard: Create a dashboard from chart IDs
|
||||
- add_chart_to_existing_dashboard: Add a chart to an existing dashboard
|
||||
|
||||
Database Connections:
|
||||
- list_databases: List database connections with advanced filters (1-based pagination)
|
||||
- get_database_info: Get detailed database connection info by ID (backend, capabilities)
|
||||
|
||||
Dataset Management:
|
||||
- list_datasets: List datasets with advanced filters (1-based pagination)
|
||||
- get_dataset_info: Get detailed dataset information by ID (includes columns/metrics)
|
||||
@@ -114,12 +118,14 @@ To create a chart:
|
||||
3. generate_explore_link(dataset_id, config) -> preview interactively
|
||||
4. generate_chart(dataset_id, config, save_chart=True) -> save permanently
|
||||
|
||||
To find your own charts/dashboards:
|
||||
To find your own charts/dashboards/databases:
|
||||
1. get_instance_info -> get current_user.id
|
||||
2. list_charts(filters=[{{"col": "created_by_fk",
|
||||
"opr": "eq", "value": current_user.id}}])
|
||||
3. Or: list_dashboards(filters=[{{"col": "created_by_fk",
|
||||
"opr": "eq", "value": current_user.id}}])
|
||||
4. Or: list_databases(filters=[{{"col": "created_by_fk",
|
||||
"opr": "eq", "value": current_user.id}}])
|
||||
|
||||
To explore data with SQL:
|
||||
1. list_datasets -> find a dataset and note its database_id
|
||||
@@ -168,6 +174,8 @@ Query Examples:
|
||||
filters=[{{"col": "created_by_fk", "opr": "eq", "value": <user_id>}}]
|
||||
- My dashboards:
|
||||
filters=[{{"col": "created_by_fk", "opr": "eq", "value": <user_id>}}]
|
||||
- My databases:
|
||||
filters=[{{"col": "created_by_fk", "opr": "eq", "value": <user_id>}}]
|
||||
|
||||
To modify an existing chart (add filters, change metrics, change dimensions, etc.):
|
||||
1. get_chart_info(chart_id) -> examine current configuration
|
||||
@@ -422,6 +430,7 @@ from superset.mcp_service.chart.tool import ( # noqa: F401, E402
|
||||
get_chart_data,
|
||||
get_chart_info,
|
||||
get_chart_preview,
|
||||
get_chart_type_schema,
|
||||
list_charts,
|
||||
update_chart,
|
||||
update_chart_preview,
|
||||
@@ -432,6 +441,10 @@ from superset.mcp_service.dashboard.tool import ( # noqa: F401, E402
|
||||
get_dashboard_info,
|
||||
list_dashboards,
|
||||
)
|
||||
from superset.mcp_service.database.tool import ( # noqa: F401, E402
|
||||
get_database_info,
|
||||
list_databases,
|
||||
)
|
||||
from superset.mcp_service.dataset.tool import ( # noqa: F401, E402
|
||||
get_dataset_info,
|
||||
list_datasets,
|
||||
|
||||
@@ -434,6 +434,10 @@ def _setup_user_context() -> User | None:
|
||||
"""
|
||||
Set up user context for MCP tool execution.
|
||||
|
||||
Includes retry logic for stale database connections (e.g., SSL dropped
|
||||
by proxy/load balancer after idle periods). On OperationalError, the
|
||||
session is reset and the user lookup is retried once.
|
||||
|
||||
Returns:
|
||||
User object with roles and groups loaded, or None if no Flask context
|
||||
"""
|
||||
@@ -446,38 +450,55 @@ def _setup_user_context() -> User | None:
|
||||
if not has_request_context():
|
||||
g.pop("user", None)
|
||||
|
||||
try:
|
||||
user = get_user_from_request()
|
||||
except RuntimeError as e:
|
||||
# No Flask application context (e.g., prompts before middleware runs)
|
||||
# This is expected for some FastMCP operations - return None gracefully
|
||||
if "application context" in str(e):
|
||||
logger.debug("No Flask app context available for user setup")
|
||||
return None
|
||||
raise
|
||||
except ValueError as e:
|
||||
# JWT user resolution failed (e.g. SAML subject not in DB).
|
||||
# If middleware already set g.user (request context exists),
|
||||
# use that instead of failing closed.
|
||||
from flask import has_request_context
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
if has_request_context() and hasattr(g, "user") and g.user:
|
||||
logger.warning(
|
||||
"JWT user resolution failed (%s), using middleware-provided g.user=%s",
|
||||
e,
|
||||
g.user.username,
|
||||
)
|
||||
# Assign to local so relationship validation below runs
|
||||
# (same as the normal path) to prevent detached instance errors.
|
||||
user = g.user
|
||||
else:
|
||||
user = None # Ensure defined before loop in case of unexpected exit
|
||||
|
||||
for attempt in range(2):
|
||||
try:
|
||||
user = get_user_from_request()
|
||||
|
||||
# Validate user has necessary relationships loaded.
|
||||
# Force access to ensure they're loaded if lazy.
|
||||
# This is inside the retry loop because relationship loading
|
||||
# also hits the DB and can fail on stale SSL connections.
|
||||
user_roles = user.roles # noqa: F841
|
||||
if hasattr(user, "groups"):
|
||||
user_groups = user.groups # noqa: F841
|
||||
|
||||
break
|
||||
except RuntimeError as e:
|
||||
# No Flask application context (e.g., prompts before middleware runs)
|
||||
if "application context" in str(e):
|
||||
logger.debug("No Flask app context available for user setup")
|
||||
return None
|
||||
raise
|
||||
except OperationalError as e:
|
||||
if attempt == 0:
|
||||
# Only retry on connection-level errors (SSL drops, server
|
||||
# closed connection). Other OperationalErrors (e.g., lock
|
||||
# timeouts) are unlikely to succeed on immediate retry but
|
||||
# are bounded to one attempt so the cost is acceptable.
|
||||
logger.warning(
|
||||
"Stale DB connection during user setup (attempt 1), "
|
||||
"resetting session and retrying: %s",
|
||||
e,
|
||||
)
|
||||
_cleanup_session_on_error()
|
||||
continue
|
||||
logger.error("DB connection failed on retry during user setup: %s", e)
|
||||
_cleanup_session_on_error()
|
||||
raise
|
||||
except ValueError as e:
|
||||
# User resolution failed — fail closed. Do not fall back to
|
||||
# g.user from middleware, as that could allow a request to
|
||||
# proceed as a different user in multi-tenant deployments.
|
||||
# Clear g.user so error/audit logging doesn't attribute
|
||||
# the denied request to the middleware-provided identity.
|
||||
logger.error("MCP user resolution failed, denying request: %s", e)
|
||||
if has_request_context():
|
||||
g.pop("user", None)
|
||||
raise
|
||||
|
||||
# Validate user has necessary relationships loaded
|
||||
# (Force access to ensure they're loaded if lazy)
|
||||
user_roles = user.roles # noqa: F841
|
||||
if hasattr(user, "groups"):
|
||||
user_groups = user.groups # noqa: F841
|
||||
|
||||
g.user = user
|
||||
return user
|
||||
|
||||
@@ -412,9 +412,12 @@ def map_table_config(config: TableChartConfig) -> Dict[str, Any]:
|
||||
# Handle raw columns (no aggregation)
|
||||
if raw_columns and not aggregated_metrics:
|
||||
# Pure raw columns - show individual rows
|
||||
# Include both "all_columns" (Superset table viz) and "columns"
|
||||
# (QueryContextFactory validation) to avoid "Empty query?" errors
|
||||
form_data.update(
|
||||
{
|
||||
"all_columns": raw_columns,
|
||||
"columns": raw_columns,
|
||||
"query_mode": "raw",
|
||||
"include_time": False,
|
||||
"order_desc": True,
|
||||
|
||||
@@ -39,6 +39,12 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def _build_query_columns(form_data: Dict[str, Any]) -> list[str]:
|
||||
"""Build query columns list from form_data, including both x_axis and groupby."""
|
||||
# Table charts in raw mode use all_columns or columns
|
||||
all_columns = form_data.get("all_columns", [])
|
||||
raw_columns_field = form_data.get("columns", [])
|
||||
if form_data.get("query_mode") == "raw" and (all_columns or raw_columns_field):
|
||||
return list(all_columns or raw_columns_field)
|
||||
|
||||
x_axis_config = form_data.get("x_axis")
|
||||
groupby_columns: list[str] = form_data.get("groupby") or []
|
||||
raw_columns: list[str] = form_data.get("columns") or []
|
||||
|
||||
@@ -36,6 +36,7 @@ from pydantic import (
|
||||
model_serializer,
|
||||
model_validator,
|
||||
PositiveInt,
|
||||
TypeAdapter,
|
||||
)
|
||||
from typing_extensions import Self
|
||||
|
||||
@@ -74,6 +75,8 @@ class ChartLike(Protocol):
|
||||
cache_timeout: int | None
|
||||
form_data: Dict[str, Any] | None
|
||||
query_context: Any | None
|
||||
certified_by: str | None
|
||||
certification_details: str | None
|
||||
changed_by: Any | None # User object
|
||||
changed_by_name: str | None
|
||||
changed_on: str | datetime | None
|
||||
@@ -113,6 +116,12 @@ class ChartInfo(BaseModel):
|
||||
created_on_humanized: str | None = Field(
|
||||
None, description="Humanized creation time"
|
||||
)
|
||||
certified_by: str | None = Field(
|
||||
None, description="Name of the person or team who certified this chart"
|
||||
)
|
||||
certification_details: str | None = Field(
|
||||
None, description="Certification details or reason"
|
||||
)
|
||||
uuid: str | None = Field(None, description="Chart UUID")
|
||||
tags: List[TagInfo] = Field(default_factory=list, description="Chart tags")
|
||||
owners: List[UserInfo] = Field(default_factory=list, description="Chart owners")
|
||||
@@ -284,14 +293,25 @@ def serialize_chart_object(chart: ChartLike | None) -> ChartInfo | None:
|
||||
if not chart:
|
||||
return None
|
||||
|
||||
# Use the chart's native URL (explore URL) instead of screenshot URL
|
||||
from superset.mcp_service.utils.url_utils import get_superset_base_url
|
||||
from superset.utils import json as utils_json
|
||||
|
||||
chart_id = getattr(chart, "id", None)
|
||||
chart_url = None
|
||||
if chart_id:
|
||||
chart_url = f"{get_superset_base_url()}/explore/?slice_id={chart_id}"
|
||||
|
||||
# Parse form_data from the chart's params JSON string
|
||||
chart_params = getattr(chart, "params", None)
|
||||
chart_form_data = None
|
||||
if chart_params and isinstance(chart_params, str):
|
||||
try:
|
||||
chart_form_data = utils_json.loads(chart_params)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
elif isinstance(chart_params, dict):
|
||||
chart_form_data = chart_params
|
||||
|
||||
return ChartInfo(
|
||||
id=chart_id,
|
||||
slice_name=getattr(chart, "slice_name", None),
|
||||
@@ -300,7 +320,10 @@ def serialize_chart_object(chart: ChartLike | None) -> ChartInfo | None:
|
||||
datasource_type=getattr(chart, "datasource_type", None),
|
||||
url=chart_url,
|
||||
description=getattr(chart, "description", None),
|
||||
certified_by=getattr(chart, "certified_by", None),
|
||||
certification_details=getattr(chart, "certification_details", None),
|
||||
cache_timeout=getattr(chart, "cache_timeout", None),
|
||||
form_data=chart_form_data,
|
||||
changed_by=getattr(chart, "changed_by_name", None)
|
||||
or (str(chart.changed_by) if getattr(chart, "changed_by", None) else None),
|
||||
changed_by_name=getattr(chart, "changed_by_name", None),
|
||||
@@ -1123,7 +1146,7 @@ class XYChartConfig(UnknownFieldCheckMixin):
|
||||
return self
|
||||
|
||||
|
||||
# Discriminated union entry point with custom error handling
|
||||
# Discriminated union for runtime validation (not exposed in JSON Schema)
|
||||
ChartConfig = Annotated[
|
||||
XYChartConfig
|
||||
| TableChartConfig
|
||||
@@ -1142,6 +1165,66 @@ ChartConfig = Annotated[
|
||||
),
|
||||
]
|
||||
|
||||
# Module-level TypeAdapter avoids repeated schema compilation in
|
||||
# parse_chart_config() — safe because ChartConfig is fully defined above.
|
||||
_CHART_CONFIG_ADAPTER: TypeAdapter[ChartConfig] = TypeAdapter(ChartConfig)
|
||||
|
||||
# Compact description for JSON Schema — keeps tool inputSchema small while
|
||||
# giving LLMs enough context to construct valid configs.
|
||||
_CHART_CONFIG_DESCRIPTION = (
|
||||
"Chart configuration object. MUST include 'chart_type' to select the "
|
||||
"schema. Types: 'xy' (x, y, kind: line/bar/area/scatter), "
|
||||
"'table' (columns), 'pie' (dimension, metric), "
|
||||
"'pivot_table' (rows, metrics), 'mixed_timeseries' (x, y, y_secondary), "
|
||||
"'handlebars' (columns, handlebars_template), "
|
||||
"'big_number' (metric). "
|
||||
"See chart://configs resource for full field reference and examples."
|
||||
)
|
||||
|
||||
|
||||
def parse_chart_config(
|
||||
config: Dict[str, Any],
|
||||
) -> (
|
||||
XYChartConfig
|
||||
| TableChartConfig
|
||||
| PieChartConfig
|
||||
| PivotTableChartConfig
|
||||
| MixedTimeseriesChartConfig
|
||||
| HandlebarsChartConfig
|
||||
| BigNumberChartConfig
|
||||
):
|
||||
"""Parse a raw dict into the appropriate typed ChartConfig subclass.
|
||||
|
||||
Validates the dict against the discriminated union using chart_type.
|
||||
Call this in tool function bodies to get a typed config object.
|
||||
"""
|
||||
try:
|
||||
return _CHART_CONFIG_ADAPTER.validate_python(config)
|
||||
except Exception as e:
|
||||
raise ValueError(
|
||||
f"{e}\n\n"
|
||||
f"Hint: read the chart://configs resource for valid configuration "
|
||||
f"examples and field reference."
|
||||
) from e
|
||||
|
||||
|
||||
def _coerce_config_to_dict(v: Any) -> Dict[str, Any]:
|
||||
"""Accept ChartConfig objects, dicts, or JSON strings for the config field."""
|
||||
if isinstance(v, str):
|
||||
from superset.utils import json as json_utils
|
||||
|
||||
try:
|
||||
v = json_utils.loads(v)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ValueError(
|
||||
f"config must be a JSON object string, got: {v!r}"
|
||||
) from exc
|
||||
if hasattr(v, "model_dump"):
|
||||
return v.model_dump()
|
||||
if isinstance(v, dict):
|
||||
return v
|
||||
raise TypeError(f"config must be a dict or JSON string, got {type(v).__name__}")
|
||||
|
||||
|
||||
class ListChartsRequest(MetadataCacheControl):
|
||||
"""Request schema for list_charts with clear, unambiguous types."""
|
||||
@@ -1237,7 +1320,7 @@ class ListChartsRequest(MetadataCacheControl):
|
||||
# The tool input models
|
||||
class GenerateChartRequest(QueryCacheControl):
|
||||
dataset_id: int | str = Field(..., description="Dataset identifier (ID, UUID)")
|
||||
config: ChartConfig = Field(..., description="Chart configuration")
|
||||
config: Dict[str, Any] = Field(..., description=_CHART_CONFIG_DESCRIPTION)
|
||||
chart_name: str | None = Field(
|
||||
None, description="Auto-generates if omitted", max_length=255
|
||||
)
|
||||
@@ -1247,6 +1330,11 @@ class GenerateChartRequest(QueryCacheControl):
|
||||
default_factory=lambda: ["url"],
|
||||
)
|
||||
|
||||
@field_validator("config", mode="before")
|
||||
@classmethod
|
||||
def coerce_config(cls, v: Any) -> Dict[str, Any]:
|
||||
return _coerce_config_to_dict(v)
|
||||
|
||||
@field_validator("chart_name")
|
||||
@classmethod
|
||||
def sanitize_chart_name(cls, v: str | None) -> str | None:
|
||||
@@ -1279,12 +1367,22 @@ class GenerateChartRequest(QueryCacheControl):
|
||||
|
||||
class GenerateExploreLinkRequest(FormDataCacheControl):
|
||||
dataset_id: int | str = Field(..., description="Dataset identifier (ID, UUID)")
|
||||
config: ChartConfig = Field(..., description="Chart configuration")
|
||||
config: Dict[str, Any] = Field(..., description=_CHART_CONFIG_DESCRIPTION)
|
||||
|
||||
@field_validator("config", mode="before")
|
||||
@classmethod
|
||||
def coerce_config(cls, v: Any) -> Dict[str, Any]:
|
||||
return _coerce_config_to_dict(v)
|
||||
|
||||
|
||||
class UpdateChartRequest(QueryCacheControl):
|
||||
identifier: int | str = Field(..., description="Chart ID or UUID")
|
||||
config: ChartConfig
|
||||
config: Dict[str, Any] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
f"{_CHART_CONFIG_DESCRIPTION} Optional; omit to only update chart_name."
|
||||
),
|
||||
)
|
||||
chart_name: str | None = Field(
|
||||
None, description="Auto-generates if omitted", max_length=255
|
||||
)
|
||||
@@ -1293,6 +1391,13 @@ class UpdateChartRequest(QueryCacheControl):
|
||||
default_factory=lambda: ["url"],
|
||||
)
|
||||
|
||||
@field_validator("config", mode="before")
|
||||
@classmethod
|
||||
def coerce_config(cls, v: Any) -> Dict[str, Any] | None:
|
||||
if v is None:
|
||||
return None
|
||||
return _coerce_config_to_dict(v)
|
||||
|
||||
@field_validator("chart_name")
|
||||
@classmethod
|
||||
def sanitize_chart_name(cls, v: str | None) -> str | None:
|
||||
@@ -1303,12 +1408,17 @@ class UpdateChartRequest(QueryCacheControl):
|
||||
class UpdateChartPreviewRequest(FormDataCacheControl):
|
||||
form_data_key: str = Field(..., description="Existing form_data_key to update")
|
||||
dataset_id: int | str = Field(..., description="Dataset ID or UUID")
|
||||
config: ChartConfig
|
||||
config: Dict[str, Any] = Field(..., description=_CHART_CONFIG_DESCRIPTION)
|
||||
generate_preview: bool = True
|
||||
preview_formats: List[Literal["url", "ascii", "vega_lite", "table"]] = Field(
|
||||
default_factory=lambda: ["url"],
|
||||
)
|
||||
|
||||
@field_validator("config", mode="before")
|
||||
@classmethod
|
||||
def coerce_config(cls, v: Any) -> Dict[str, Any]:
|
||||
return _coerce_config_to_dict(v)
|
||||
|
||||
|
||||
class GetChartDataRequest(QueryCacheControl):
|
||||
"""Request for chart data with cache control.
|
||||
|
||||
@@ -19,6 +19,7 @@ from .generate_chart import generate_chart
|
||||
from .get_chart_data import get_chart_data
|
||||
from .get_chart_info import get_chart_info
|
||||
from .get_chart_preview import get_chart_preview
|
||||
from .get_chart_type_schema import get_chart_type_schema
|
||||
from .list_charts import list_charts
|
||||
from .update_chart import update_chart
|
||||
from .update_chart_preview import update_chart_preview
|
||||
@@ -31,4 +32,5 @@ __all__ = [
|
||||
"update_chart_preview",
|
||||
"get_chart_preview",
|
||||
"get_chart_data",
|
||||
"get_chart_type_schema",
|
||||
]
|
||||
|
||||
@@ -43,6 +43,7 @@ from superset.mcp_service.chart.schemas import (
|
||||
ChartError,
|
||||
GenerateChartRequest,
|
||||
GenerateChartResponse,
|
||||
parse_chart_config,
|
||||
PerformanceMetadata,
|
||||
)
|
||||
from superset.mcp_service.utils.url_utils import get_superset_base_url
|
||||
@@ -209,13 +210,17 @@ async def generate_chart( # noqa: C901
|
||||
"save_chart=%s, preview_formats=%s"
|
||||
% (
|
||||
request.dataset_id,
|
||||
request.config.chart_type,
|
||||
request.config.get("chart_type", "unknown"),
|
||||
request.save_chart,
|
||||
request.preview_formats,
|
||||
)
|
||||
)
|
||||
await ctx.debug(
|
||||
"Chart configuration details: config=%s" % (request.config.model_dump(),)
|
||||
"Chart configuration details: chart_type=%s, keys=%s"
|
||||
% (
|
||||
request.config.get("chart_type", "unknown"),
|
||||
sorted(request.config.keys()),
|
||||
)
|
||||
)
|
||||
|
||||
# Track runtime warnings to include in response
|
||||
@@ -269,11 +274,12 @@ async def generate_chart( # noqa: C901
|
||||
}
|
||||
)
|
||||
|
||||
# Parse the raw config dict into a typed ChartConfig for downstream use
|
||||
config = parse_chart_config(request.config)
|
||||
|
||||
# Map the simplified config to Superset's form_data format
|
||||
# Pass dataset_id to enable column type checking for proper viz_type selection
|
||||
form_data = map_config_to_form_data(
|
||||
request.config, dataset_id=request.dataset_id
|
||||
)
|
||||
form_data = map_config_to_form_data(config, dataset_id=request.dataset_id)
|
||||
|
||||
chart = None
|
||||
chart_id = None
|
||||
@@ -367,7 +373,7 @@ async def generate_chart( # noqa: C901
|
||||
dataset, "table_name", None
|
||||
)
|
||||
chart_name = request.chart_name or generate_chart_name(
|
||||
request.config, dataset_name=dataset_name
|
||||
config, dataset_name=dataset_name
|
||||
)
|
||||
await ctx.debug("Chart name: chart_name=%s" % (chart_name,))
|
||||
|
||||
@@ -607,8 +613,8 @@ async def generate_chart( # noqa: C901
|
||||
response_warnings.extend(compile_result.warnings)
|
||||
|
||||
# Generate semantic analysis
|
||||
capabilities = analyze_chart_capabilities(chart, request.config)
|
||||
semantics = analyze_chart_semantics(chart, request.config)
|
||||
capabilities = analyze_chart_capabilities(chart, config)
|
||||
semantics = analyze_chart_semantics(chart, config)
|
||||
|
||||
# Create performance metadata
|
||||
execution_time = int((time.time() - start_time) * 1000)
|
||||
@@ -622,7 +628,7 @@ async def generate_chart( # noqa: C901
|
||||
chart_name = (
|
||||
chart.slice_name
|
||||
if chart and hasattr(chart, "slice_name")
|
||||
else generate_chart_name(request.config)
|
||||
else generate_chart_name(config)
|
||||
)
|
||||
accessibility = AccessibilityMetadata(
|
||||
color_blind_safe=True, # Would need actual analysis
|
||||
@@ -843,9 +849,9 @@ async def generate_chart( # noqa: C901
|
||||
# Extract chart_type from different sources for better error context
|
||||
chart_type = "unknown"
|
||||
try:
|
||||
if hasattr(request, "config") and hasattr(request.config, "chart_type"):
|
||||
chart_type = request.config.chart_type
|
||||
except AttributeError as extract_error:
|
||||
if hasattr(request, "config") and isinstance(request.config, dict):
|
||||
chart_type = request.config.get("chart_type", "unknown")
|
||||
except (AttributeError, TypeError) as extract_error:
|
||||
# Ignore errors when extracting chart type for error context
|
||||
logger.debug("Could not extract chart type: %s", extract_error)
|
||||
|
||||
|
||||
@@ -301,7 +301,13 @@ async def get_chart_data( # noqa: C901
|
||||
cached_groupby: list[str] = []
|
||||
else:
|
||||
cached_metrics = cached_form_data_dict.get("metrics", [])
|
||||
cached_groupby = cached_form_data_dict.get("groupby", [])
|
||||
raw_groupby = cached_form_data_dict.get("groupby", [])
|
||||
# Guard against string groupby (e.g. heatmap_v2 migrated
|
||||
# from legacy heatmap where all_columns_y was a string)
|
||||
if isinstance(raw_groupby, str):
|
||||
cached_groupby = [raw_groupby]
|
||||
else:
|
||||
cached_groupby = list(raw_groupby)
|
||||
|
||||
_apply_extra_form_data(cached_form_data_dict, request.extra_form_data)
|
||||
|
||||
@@ -443,7 +449,13 @@ async def get_chart_data( # noqa: C901
|
||||
else:
|
||||
# Standard charts use "metrics" (plural) and "groupby"
|
||||
metrics = form_data.get("metrics", [])
|
||||
groupby_columns = list(form_data.get("groupby") or [])
|
||||
raw_groupby = form_data.get("groupby") or []
|
||||
# Guard against string groupby (e.g. heatmap_v2 migrated
|
||||
# from legacy heatmap where all_columns_y was a string)
|
||||
if isinstance(raw_groupby, str):
|
||||
groupby_columns = [raw_groupby]
|
||||
else:
|
||||
groupby_columns = list(raw_groupby)
|
||||
# Some chart types use "columns" instead of "groupby"
|
||||
if not groupby_columns:
|
||||
form_columns = form_data.get("columns")
|
||||
|
||||
@@ -133,12 +133,28 @@ class ASCIIPreviewStrategy(PreviewFormatStrategy):
|
||||
groupby_columns = form_data.get("groupby", [])
|
||||
metrics = form_data.get("metrics", [])
|
||||
|
||||
columns = groupby_columns.copy()
|
||||
if x_axis_config and isinstance(x_axis_config, str):
|
||||
columns.append(x_axis_config)
|
||||
elif x_axis_config and isinstance(x_axis_config, dict):
|
||||
if "column_name" in x_axis_config:
|
||||
columns.append(x_axis_config["column_name"])
|
||||
# Table charts in raw mode use all_columns or columns
|
||||
all_columns = form_data.get("all_columns", [])
|
||||
raw_columns = form_data.get("columns", [])
|
||||
if form_data.get("query_mode") == "raw" and (all_columns or raw_columns):
|
||||
columns = list(all_columns or raw_columns)
|
||||
else:
|
||||
columns = groupby_columns.copy()
|
||||
if x_axis_config and isinstance(x_axis_config, str):
|
||||
columns.append(x_axis_config)
|
||||
elif x_axis_config and isinstance(x_axis_config, dict):
|
||||
if "column_name" in x_axis_config:
|
||||
columns.append(x_axis_config["column_name"])
|
||||
|
||||
if not columns and not metrics:
|
||||
return ChartError(
|
||||
error=(
|
||||
"Cannot generate ASCII preview: chart has no columns or "
|
||||
"metrics in its configuration. This chart type may not "
|
||||
"support ASCII preview."
|
||||
),
|
||||
error_type="UnsupportedChart",
|
||||
)
|
||||
|
||||
factory = QueryContextFactory()
|
||||
query_context = factory.create(
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
MCP tool: get_chart_type_schema
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.mcp_service.chart.schemas import (
|
||||
BigNumberChartConfig,
|
||||
HandlebarsChartConfig,
|
||||
MixedTimeseriesChartConfig,
|
||||
PieChartConfig,
|
||||
PivotTableChartConfig,
|
||||
TableChartConfig,
|
||||
XYChartConfig,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Module-level TypeAdapters — one per chart type, compiled once.
|
||||
_CHART_TYPE_ADAPTERS: Dict[str, TypeAdapter[Any]] = {
|
||||
"xy": TypeAdapter(XYChartConfig),
|
||||
"table": TypeAdapter(TableChartConfig),
|
||||
"pie": TypeAdapter(PieChartConfig),
|
||||
"pivot_table": TypeAdapter(PivotTableChartConfig),
|
||||
"mixed_timeseries": TypeAdapter(MixedTimeseriesChartConfig),
|
||||
"handlebars": TypeAdapter(HandlebarsChartConfig),
|
||||
"big_number": TypeAdapter(BigNumberChartConfig),
|
||||
}
|
||||
|
||||
VALID_CHART_TYPES = sorted(_CHART_TYPE_ADAPTERS.keys())
|
||||
|
||||
# Per-type examples — lightweight inline examples for each chart type.
|
||||
_CHART_EXAMPLES: Dict[str, list[Dict[str, Any]]] = {
|
||||
"xy": [
|
||||
{
|
||||
"chart_type": "xy",
|
||||
"kind": "line",
|
||||
"x": {"name": "order_date"},
|
||||
"y": [{"name": "revenue", "aggregate": "SUM"}],
|
||||
"time_grain": "P1D",
|
||||
},
|
||||
{
|
||||
"chart_type": "xy",
|
||||
"kind": "bar",
|
||||
"x": {"name": "category"},
|
||||
"y": [{"name": "sales", "aggregate": "SUM"}],
|
||||
},
|
||||
],
|
||||
"table": [
|
||||
{
|
||||
"chart_type": "table",
|
||||
"columns": [
|
||||
{"name": "customer_name"},
|
||||
{"name": "revenue", "aggregate": "SUM"},
|
||||
],
|
||||
},
|
||||
],
|
||||
"pie": [
|
||||
{
|
||||
"chart_type": "pie",
|
||||
"dimension": {"name": "region"},
|
||||
"metric": {"name": "revenue", "aggregate": "SUM"},
|
||||
},
|
||||
],
|
||||
"pivot_table": [
|
||||
{
|
||||
"chart_type": "pivot_table",
|
||||
"rows": [{"name": "region"}],
|
||||
"metrics": [{"name": "revenue", "aggregate": "SUM"}],
|
||||
"columns": [{"name": "quarter"}],
|
||||
},
|
||||
],
|
||||
"mixed_timeseries": [
|
||||
{
|
||||
"chart_type": "mixed_timeseries",
|
||||
"x": {"name": "order_date"},
|
||||
"y": [{"name": "revenue", "aggregate": "SUM"}],
|
||||
"y_secondary": [{"name": "orders", "aggregate": "COUNT"}],
|
||||
"time_grain": "P1M",
|
||||
},
|
||||
],
|
||||
"handlebars": [
|
||||
{
|
||||
"chart_type": "handlebars",
|
||||
"query_mode": "raw",
|
||||
"columns": [{"name": "customer_name"}, {"name": "email"}],
|
||||
"handlebars_template": "{{#each data}}<p>{{customer_name}}</p>{{/each}}",
|
||||
},
|
||||
],
|
||||
"big_number": [
|
||||
{
|
||||
"chart_type": "big_number",
|
||||
"metric": {"name": "revenue", "aggregate": "SUM"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _get_chart_type_schema_impl(
|
||||
chart_type: str,
|
||||
include_examples: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Pure logic for chart type schema lookup — no auth, no decorators."""
|
||||
adapter = _CHART_TYPE_ADAPTERS.get(chart_type)
|
||||
if adapter is None:
|
||||
return {
|
||||
"error": f"Unknown chart_type: {chart_type!r}",
|
||||
"valid_chart_types": VALID_CHART_TYPES,
|
||||
"hint": (
|
||||
"Use one of the valid chart_type values listed above. "
|
||||
"Call this tool again with a valid chart_type to see "
|
||||
"its schema and examples."
|
||||
),
|
||||
}
|
||||
|
||||
schema = adapter.json_schema()
|
||||
result: Dict[str, Any] = {
|
||||
"chart_type": chart_type,
|
||||
"schema": schema,
|
||||
}
|
||||
|
||||
if include_examples:
|
||||
result["examples"] = _CHART_EXAMPLES.get(chart_type, [])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["discovery"],
|
||||
annotations=ToolAnnotations(
|
||||
title="Get chart type schema",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
def get_chart_type_schema(
|
||||
chart_type: str,
|
||||
include_examples: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Get the full JSON Schema and examples for a specific chart type.
|
||||
|
||||
Use this tool to discover the exact fields, types, and constraints
|
||||
for a chart configuration before calling generate_chart or update_chart.
|
||||
|
||||
Valid chart_type values: xy, table, pie, pivot_table,
|
||||
mixed_timeseries, handlebars, big_number.
|
||||
|
||||
Returns the JSON Schema for the requested chart type, optionally
|
||||
with working examples.
|
||||
"""
|
||||
return _get_chart_type_schema_impl(chart_type, include_examples)
|
||||
@@ -46,6 +46,9 @@ DEFAULT_CHART_COLUMNS = [
|
||||
"id",
|
||||
"slice_name",
|
||||
"viz_type",
|
||||
"description",
|
||||
"certified_by",
|
||||
"certification_details",
|
||||
"url",
|
||||
"changed_on",
|
||||
"changed_on_humanized",
|
||||
|
||||
@@ -21,6 +21,7 @@ MCP tool: update_chart
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import Context
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
@@ -37,6 +38,7 @@ from superset.mcp_service.chart.chart_utils import (
|
||||
from superset.mcp_service.chart.schemas import (
|
||||
AccessibilityMetadata,
|
||||
GenerateChartResponse,
|
||||
parse_chart_config,
|
||||
PerformanceMetadata,
|
||||
UpdateChartRequest,
|
||||
)
|
||||
@@ -46,6 +48,67 @@ from superset.utils import json
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _find_chart(identifier: int | str) -> Any | None:
|
||||
"""Find a chart by numeric ID or UUID string."""
|
||||
from superset.daos.chart import ChartDAO
|
||||
|
||||
if isinstance(identifier, int) or (
|
||||
isinstance(identifier, str) and identifier.isdigit()
|
||||
):
|
||||
chart_id = int(identifier) if isinstance(identifier, str) else identifier
|
||||
return ChartDAO.find_by_id(chart_id)
|
||||
return ChartDAO.find_by_id(identifier, id_column="uuid")
|
||||
|
||||
|
||||
def _build_update_payload(
|
||||
request: UpdateChartRequest,
|
||||
chart: Any,
|
||||
) -> dict[str, Any] | GenerateChartResponse:
|
||||
"""Build the update payload for a chart update.
|
||||
|
||||
Returns a dict payload on success, or a GenerateChartResponse error
|
||||
when neither config nor chart_name is provided.
|
||||
"""
|
||||
if request.config is not None:
|
||||
config = parse_chart_config(request.config)
|
||||
dataset_id = chart.datasource_id if chart.datasource_id else None
|
||||
new_form_data = map_config_to_form_data(config, dataset_id=dataset_id)
|
||||
new_form_data.pop("_mcp_warnings", None)
|
||||
|
||||
chart_name = (
|
||||
request.chart_name
|
||||
if request.chart_name
|
||||
else chart.slice_name or generate_chart_name(config)
|
||||
)
|
||||
|
||||
return {
|
||||
"slice_name": chart_name,
|
||||
"viz_type": new_form_data["viz_type"],
|
||||
"params": json.dumps(new_form_data),
|
||||
}
|
||||
|
||||
# Name-only update: keep existing visualization, just rename
|
||||
if not request.chart_name:
|
||||
return GenerateChartResponse.model_validate(
|
||||
{
|
||||
"chart": None,
|
||||
"error": {
|
||||
"error_type": "ValidationError",
|
||||
"message": ("Either 'config' or 'chart_name' must be provided."),
|
||||
"details": (
|
||||
"Either 'config' or 'chart_name' must be provided. "
|
||||
"Use config for visualization changes, chart_name "
|
||||
"for renaming."
|
||||
),
|
||||
},
|
||||
"success": False,
|
||||
"schema_version": "2.0",
|
||||
"api_version": "v1",
|
||||
}
|
||||
)
|
||||
return {"slice_name": request.chart_name}
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["mutate"],
|
||||
class_permission_name="Chart",
|
||||
@@ -105,29 +168,22 @@ async def update_chart(
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Find the existing chart
|
||||
from superset.daos.chart import ChartDAO
|
||||
|
||||
with event_logger.log_context(action="mcp.update_chart.chart_lookup"):
|
||||
chart = None
|
||||
if isinstance(request.identifier, int) or (
|
||||
isinstance(request.identifier, str) and request.identifier.isdigit()
|
||||
):
|
||||
chart_id = (
|
||||
int(request.identifier)
|
||||
if isinstance(request.identifier, str)
|
||||
else request.identifier
|
||||
)
|
||||
chart = ChartDAO.find_by_id(chart_id)
|
||||
else:
|
||||
# Try UUID lookup using DAO flexible method
|
||||
chart = ChartDAO.find_by_id(request.identifier, id_column="uuid")
|
||||
chart = _find_chart(request.identifier)
|
||||
|
||||
if not chart:
|
||||
return GenerateChartResponse.model_validate(
|
||||
{
|
||||
"chart": None,
|
||||
"error": f"No chart found with identifier: {request.identifier}",
|
||||
"error": {
|
||||
"error_type": "NotFound",
|
||||
"message": (
|
||||
f"No chart found with identifier: {request.identifier}"
|
||||
),
|
||||
"details": (
|
||||
f"No chart found with identifier: {request.identifier}"
|
||||
),
|
||||
},
|
||||
"success": False,
|
||||
"schema_version": "2.0",
|
||||
"api_version": "v1",
|
||||
@@ -157,35 +213,23 @@ async def update_chart(
|
||||
}
|
||||
)
|
||||
|
||||
# Map the new config to form_data format
|
||||
# Get dataset_id from existing chart for column type checking
|
||||
dataset_id = chart.datasource_id if chart.datasource_id else None
|
||||
new_form_data = map_config_to_form_data(request.config, dataset_id=dataset_id)
|
||||
new_form_data.pop("_mcp_warnings", None)
|
||||
|
||||
# Update chart using Superset's command
|
||||
# Build update payload (config update or name-only rename)
|
||||
from superset.commands.chart.update import UpdateChartCommand
|
||||
|
||||
payload_or_error = _build_update_payload(request, chart)
|
||||
if isinstance(payload_or_error, GenerateChartResponse):
|
||||
return payload_or_error
|
||||
|
||||
with event_logger.log_context(action="mcp.update_chart.db_write"):
|
||||
# Generate new chart name if provided, otherwise keep existing
|
||||
chart_name = (
|
||||
request.chart_name
|
||||
if request.chart_name
|
||||
else chart.slice_name or generate_chart_name(request.config)
|
||||
)
|
||||
|
||||
update_payload = {
|
||||
"slice_name": chart_name,
|
||||
"viz_type": new_form_data["viz_type"],
|
||||
"params": json.dumps(new_form_data),
|
||||
}
|
||||
|
||||
command = UpdateChartCommand(chart.id, update_payload)
|
||||
command = UpdateChartCommand(chart.id, payload_or_error)
|
||||
updated_chart = command.run()
|
||||
|
||||
# Parse config for analysis (may be None for name-only updates)
|
||||
config = parse_chart_config(request.config) if request.config else None
|
||||
|
||||
# Generate semantic analysis
|
||||
capabilities = analyze_chart_capabilities(updated_chart, request.config)
|
||||
semantics = analyze_chart_semantics(updated_chart, request.config)
|
||||
capabilities = analyze_chart_capabilities(updated_chart, config)
|
||||
semantics = analyze_chart_semantics(updated_chart, config)
|
||||
|
||||
# Create performance metadata
|
||||
execution_time = int((time.time() - start_time) * 1000)
|
||||
@@ -199,7 +243,7 @@ async def update_chart(
|
||||
chart_name = (
|
||||
updated_chart.slice_name
|
||||
if updated_chart and hasattr(updated_chart, "slice_name")
|
||||
else generate_chart_name(request.config)
|
||||
else (generate_chart_name(config) if config else "Updated chart")
|
||||
)
|
||||
accessibility = AccessibilityMetadata(
|
||||
color_blind_safe=True, # Would need actual analysis
|
||||
@@ -288,7 +332,11 @@ async def update_chart(
|
||||
return GenerateChartResponse.model_validate(
|
||||
{
|
||||
"chart": None,
|
||||
"error": f"Chart update failed: {str(e)}",
|
||||
"error": {
|
||||
"error_type": type(e).__name__,
|
||||
"message": f"Chart update failed: {e}",
|
||||
"details": str(e),
|
||||
},
|
||||
"performance": {
|
||||
"query_duration_ms": execution_time,
|
||||
"cache_status": "error",
|
||||
|
||||
@@ -36,13 +36,36 @@ from superset.mcp_service.chart.chart_utils import (
|
||||
)
|
||||
from superset.mcp_service.chart.schemas import (
|
||||
AccessibilityMetadata,
|
||||
parse_chart_config,
|
||||
PerformanceMetadata,
|
||||
UpdateChartPreviewRequest,
|
||||
)
|
||||
from superset.utils import json as utils_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_old_adhoc_filters(form_data_key: str) -> list[Dict[str, Any]] | None:
|
||||
"""Retrieve adhoc_filters from the previously cached form_data."""
|
||||
from superset.commands.exceptions import CommandException
|
||||
from superset.commands.explore.form_data.get import GetFormDataCommand
|
||||
from superset.commands.explore.form_data.parameters import CommandParameters
|
||||
|
||||
try:
|
||||
cmd_params = CommandParameters(key=form_data_key)
|
||||
cached_data = GetFormDataCommand(cmd_params).run()
|
||||
if cached_data:
|
||||
if isinstance(cached_data, str):
|
||||
cached_data = utils_json.loads(cached_data)
|
||||
if isinstance(cached_data, dict):
|
||||
adhoc_filters = cached_data.get("adhoc_filters")
|
||||
if adhoc_filters:
|
||||
return adhoc_filters
|
||||
except (KeyError, ValueError, TypeError, CommandException):
|
||||
logger.debug("Could not retrieve old form_data for filter preservation")
|
||||
return None
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["mutate"],
|
||||
class_permission_name="Chart",
|
||||
@@ -73,14 +96,24 @@ def update_chart_preview(
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Parse the raw config dict into a typed ChartConfig
|
||||
config = parse_chart_config(request.config)
|
||||
|
||||
with event_logger.log_context(action="mcp.update_chart_preview.form_data"):
|
||||
# Map the new config to form_data format
|
||||
# Pass dataset_id to enable column type checking
|
||||
new_form_data = map_config_to_form_data(
|
||||
request.config, dataset_id=request.dataset_id
|
||||
config, dataset_id=request.dataset_id
|
||||
)
|
||||
new_form_data.pop("_mcp_warnings", None)
|
||||
|
||||
# Preserve adhoc filters from the previous cached form_data
|
||||
# when the new config doesn't explicitly specify filters
|
||||
if getattr(config, "filters", None) is None and request.form_data_key:
|
||||
old_adhoc_filters = _get_old_adhoc_filters(request.form_data_key)
|
||||
if old_adhoc_filters:
|
||||
new_form_data["adhoc_filters"] = old_adhoc_filters
|
||||
|
||||
# Generate new explore link with updated form_data
|
||||
explore_url = generate_explore_link(request.dataset_id, new_form_data)
|
||||
|
||||
@@ -91,8 +124,8 @@ def update_chart_preview(
|
||||
|
||||
with event_logger.log_context(action="mcp.update_chart_preview.metadata"):
|
||||
# Generate semantic analysis
|
||||
capabilities = analyze_chart_capabilities(None, request.config)
|
||||
semantics = analyze_chart_semantics(None, request.config)
|
||||
capabilities = analyze_chart_capabilities(None, config)
|
||||
semantics = analyze_chart_semantics(None, config)
|
||||
|
||||
# Create performance metadata
|
||||
execution_time = int((time.time() - start_time) * 1000)
|
||||
@@ -103,7 +136,7 @@ def update_chart_preview(
|
||||
)
|
||||
|
||||
# Create accessibility metadata
|
||||
chart_name = generate_chart_name(request.config)
|
||||
chart_name = generate_chart_name(config)
|
||||
accessibility = AccessibilityMetadata(
|
||||
color_blind_safe=True, # Would need actual analysis
|
||||
alt_text=f"Updated chart preview showing {chart_name}",
|
||||
|
||||
@@ -26,6 +26,7 @@ from typing import Any, Dict, List, Tuple
|
||||
from superset.mcp_service.chart.schemas import (
|
||||
ChartConfig,
|
||||
GenerateChartRequest,
|
||||
parse_chart_config,
|
||||
)
|
||||
from superset.mcp_service.common.error_schemas import (
|
||||
ChartGenerationError,
|
||||
@@ -171,6 +172,10 @@ class ValidationPipeline:
|
||||
if request is None:
|
||||
return ValidationResult(is_valid=False, error=error)
|
||||
|
||||
# Parse the raw config dict into a typed ChartConfig for
|
||||
# downstream validators that need typed access.
|
||||
typed_config = parse_chart_config(request.config)
|
||||
|
||||
# Fetch dataset context once and reuse across validation layers
|
||||
dataset_context = ValidationPipeline._get_dataset_context(
|
||||
request.dataset_id
|
||||
@@ -178,20 +183,20 @@ class ValidationPipeline:
|
||||
|
||||
# Layer 2: Dataset validation (reuses context)
|
||||
is_valid, error = ValidationPipeline._validate_dataset(
|
||||
request.config, request.dataset_id, dataset_context
|
||||
typed_config, request.dataset_id, dataset_context
|
||||
)
|
||||
if not is_valid:
|
||||
return ValidationResult(is_valid=False, request=request, error=error)
|
||||
|
||||
# Layer 3: Runtime validation - returns warnings as metadata, not errors
|
||||
_is_valid, warnings_metadata = ValidationPipeline._validate_runtime(
|
||||
request.config, request.dataset_id
|
||||
typed_config, request.dataset_id
|
||||
)
|
||||
# Runtime validation always returns True now, warnings are informational
|
||||
|
||||
# Layer 4: Column name normalization (reuses context)
|
||||
normalized_request = ValidationPipeline._normalize_column_names(
|
||||
request, dataset_context
|
||||
request, dataset_context, typed_config=typed_config
|
||||
)
|
||||
|
||||
return ValidationResult(
|
||||
@@ -284,6 +289,7 @@ class ValidationPipeline:
|
||||
def _normalize_column_names(
|
||||
request: GenerateChartRequest,
|
||||
dataset_context: DatasetContext | None = None,
|
||||
typed_config: ChartConfig | None = None,
|
||||
) -> GenerateChartRequest:
|
||||
"""
|
||||
Normalize column names in the request to match canonical dataset names.
|
||||
@@ -297,6 +303,8 @@ class ValidationPipeline:
|
||||
request: The validated chart generation request
|
||||
dataset_context: Pre-fetched dataset context to avoid duplicate
|
||||
DB queries. If None, fetches from the database.
|
||||
typed_config: Pre-parsed typed ChartConfig. If None, parses from
|
||||
request.config dict.
|
||||
|
||||
Returns:
|
||||
A new request with normalized column names
|
||||
@@ -304,8 +312,9 @@ class ValidationPipeline:
|
||||
try:
|
||||
from .dataset_validator import DatasetValidator
|
||||
|
||||
config = typed_config or parse_chart_config(request.config)
|
||||
normalized_config = DatasetValidator.normalize_column_names(
|
||||
request.config,
|
||||
config,
|
||||
request.dataset_id,
|
||||
dataset_context=dataset_context,
|
||||
)
|
||||
|
||||
@@ -29,6 +29,8 @@ import sqlalchemy as sa
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.inspection import inspect
|
||||
|
||||
from superset.mcp_service.constants import ModelType
|
||||
|
||||
|
||||
class ColumnMetadata(BaseModel):
|
||||
"""Metadata for a selectable column."""
|
||||
@@ -52,7 +54,7 @@ class ModelSchemaInfo(BaseModel):
|
||||
- Default values for each
|
||||
"""
|
||||
|
||||
model_type: Literal["chart", "dataset", "dashboard"] = Field(
|
||||
model_type: ModelType = Field(
|
||||
..., description="The model type this schema describes"
|
||||
)
|
||||
select_columns: list[ColumnMetadata] = Field(
|
||||
@@ -82,9 +84,7 @@ class ModelSchemaInfo(BaseModel):
|
||||
class GetSchemaRequest(BaseModel):
|
||||
"""Request schema for unified get_schema tool."""
|
||||
|
||||
model_type: Literal["chart", "dataset", "dashboard"] = Field(
|
||||
..., description="Model type to get schema for"
|
||||
)
|
||||
model_type: ModelType = Field(..., description="Model type to get schema for")
|
||||
|
||||
|
||||
class GetSchemaResponse(BaseModel):
|
||||
@@ -180,6 +180,7 @@ def get_columns_from_model(
|
||||
model_cls: Type[Any],
|
||||
default_columns: list[str],
|
||||
extra_columns: dict[str, ColumnMetadata] | None = None,
|
||||
exclude_columns: set[str] | None = None,
|
||||
) -> list[ColumnMetadata]:
|
||||
"""
|
||||
Dynamically extract column metadata from a SQLAlchemy model.
|
||||
@@ -188,6 +189,7 @@ def get_columns_from_model(
|
||||
model_cls: The SQLAlchemy model class to inspect
|
||||
default_columns: List of column names that should be marked as defaults
|
||||
extra_columns: Additional columns not on the model (e.g., computed fields)
|
||||
exclude_columns: Column names to omit (e.g., sensitive fields)
|
||||
|
||||
Returns:
|
||||
List of ColumnMetadata objects for all columns
|
||||
@@ -197,6 +199,8 @@ def get_columns_from_model(
|
||||
|
||||
for col in mapper.columns:
|
||||
col_name = col.key
|
||||
if exclude_columns and col_name in exclude_columns:
|
||||
continue
|
||||
col_type = _get_sqlalchemy_type_name(col.type)
|
||||
# Get description from column doc, comment, or fallback mapping
|
||||
description = (
|
||||
@@ -234,7 +238,17 @@ def get_columns_from_model(
|
||||
# - Extra columns (computed/relationship fields not on the model)
|
||||
|
||||
# Chart configuration
|
||||
CHART_DEFAULT_COLUMNS = ["id", "slice_name", "viz_type", "url", "changed_on_humanized"]
|
||||
CHART_DEFAULT_COLUMNS = [
|
||||
"id",
|
||||
"slice_name",
|
||||
"viz_type",
|
||||
"description",
|
||||
"certified_by",
|
||||
"certification_details",
|
||||
"url",
|
||||
"changed_on",
|
||||
"changed_on_humanized",
|
||||
]
|
||||
CHART_SORTABLE_COLUMNS = [
|
||||
"id",
|
||||
"slice_name",
|
||||
@@ -302,6 +316,18 @@ CHART_EXTRA_COLUMNS: dict[str, ColumnMetadata] = {
|
||||
type="str",
|
||||
is_default=False,
|
||||
),
|
||||
"certified_by": ColumnMetadata(
|
||||
name="certified_by",
|
||||
description="Name of the person who certified this chart",
|
||||
type="str",
|
||||
is_default=True,
|
||||
),
|
||||
"certification_details": ColumnMetadata(
|
||||
name="certification_details",
|
||||
description="Certification details or reason",
|
||||
type="str",
|
||||
is_default=True,
|
||||
),
|
||||
"tags": ColumnMetadata(
|
||||
name="tags", description="Chart tags", type="list", is_default=False
|
||||
),
|
||||
@@ -311,7 +337,16 @@ CHART_EXTRA_COLUMNS: dict[str, ColumnMetadata] = {
|
||||
}
|
||||
|
||||
# Dataset configuration
|
||||
DATASET_DEFAULT_COLUMNS = ["id", "table_name", "schema", "changed_on_humanized"]
|
||||
DATASET_DEFAULT_COLUMNS = [
|
||||
"id",
|
||||
"table_name",
|
||||
"schema",
|
||||
"description",
|
||||
"certified_by",
|
||||
"certification_details",
|
||||
"changed_on",
|
||||
"changed_on_humanized",
|
||||
]
|
||||
DATASET_SORTABLE_COLUMNS = [
|
||||
"id",
|
||||
"table_name",
|
||||
@@ -363,6 +398,18 @@ DATASET_EXTRA_COLUMNS: dict[str, ColumnMetadata] = {
|
||||
type="str",
|
||||
is_default=False,
|
||||
),
|
||||
"certified_by": ColumnMetadata(
|
||||
name="certified_by",
|
||||
description="Name of the person who certified this dataset",
|
||||
type="str",
|
||||
is_default=True,
|
||||
),
|
||||
"certification_details": ColumnMetadata(
|
||||
name="certification_details",
|
||||
description="Certification details or reason",
|
||||
type="str",
|
||||
is_default=True,
|
||||
),
|
||||
"metrics": ColumnMetadata(
|
||||
name="metrics",
|
||||
description="Dataset metrics definitions",
|
||||
@@ -388,7 +435,11 @@ DASHBOARD_DEFAULT_COLUMNS = [
|
||||
"id",
|
||||
"dashboard_title",
|
||||
"slug",
|
||||
"description",
|
||||
"certified_by",
|
||||
"certification_details",
|
||||
"url",
|
||||
"changed_on",
|
||||
"changed_on_humanized",
|
||||
]
|
||||
DASHBOARD_SORTABLE_COLUMNS = [
|
||||
@@ -452,6 +503,68 @@ DASHBOARD_EXTRA_COLUMNS: dict[str, ColumnMetadata] = {
|
||||
}
|
||||
|
||||
|
||||
# Database configuration
|
||||
DATABASE_DEFAULT_COLUMNS = [
|
||||
"id",
|
||||
"database_name",
|
||||
"backend",
|
||||
"expose_in_sqllab",
|
||||
"changed_on",
|
||||
"changed_on_humanized",
|
||||
]
|
||||
DATABASE_SORTABLE_COLUMNS = [
|
||||
"id",
|
||||
"database_name",
|
||||
"changed_on",
|
||||
"created_on",
|
||||
]
|
||||
DATABASE_SEARCH_COLUMNS = ["database_name"]
|
||||
DATABASE_EXTRA_COLUMNS: dict[str, ColumnMetadata] = {
|
||||
"backend": ColumnMetadata(
|
||||
name="backend",
|
||||
description="Database backend type (e.g., postgresql, mysql)",
|
||||
type="str",
|
||||
is_default=True,
|
||||
),
|
||||
"changed_by": ColumnMetadata(
|
||||
name="changed_by",
|
||||
description="Last modifier username",
|
||||
type="str",
|
||||
is_default=False,
|
||||
),
|
||||
"changed_by_name": ColumnMetadata(
|
||||
name="changed_by_name",
|
||||
description="Last modifier display name",
|
||||
type="str",
|
||||
is_default=False,
|
||||
),
|
||||
"changed_on_humanized": ColumnMetadata(
|
||||
name="changed_on_humanized",
|
||||
description="Humanized modification time",
|
||||
type="str",
|
||||
is_default=True,
|
||||
),
|
||||
"created_by": ColumnMetadata(
|
||||
name="created_by",
|
||||
description="Creator username",
|
||||
type="str",
|
||||
is_default=False,
|
||||
),
|
||||
"created_by_name": ColumnMetadata(
|
||||
name="created_by_name",
|
||||
description="Creator display name",
|
||||
type="str",
|
||||
is_default=False,
|
||||
),
|
||||
"created_on_humanized": ColumnMetadata(
|
||||
name="created_on_humanized",
|
||||
description="Humanized creation time",
|
||||
type="str",
|
||||
is_default=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_chart_columns() -> list[ColumnMetadata]:
|
||||
"""Get column metadata for Chart model dynamically."""
|
||||
from superset.models.slice import Slice
|
||||
@@ -477,6 +590,27 @@ def get_dashboard_columns() -> list[ColumnMetadata]:
|
||||
)
|
||||
|
||||
|
||||
# Sensitive columns that should not be exposed via schema discovery
|
||||
DATABASE_EXCLUDE_COLUMNS = {
|
||||
"sqlalchemy_uri",
|
||||
"password",
|
||||
"encrypted_extra",
|
||||
"server_cert",
|
||||
}
|
||||
|
||||
|
||||
def get_database_columns() -> list[ColumnMetadata]:
|
||||
"""Get column metadata for Database model dynamically."""
|
||||
from superset.models.core import Database
|
||||
|
||||
return get_columns_from_model(
|
||||
Database,
|
||||
DATABASE_DEFAULT_COLUMNS,
|
||||
DATABASE_EXTRA_COLUMNS,
|
||||
exclude_columns=DATABASE_EXCLUDE_COLUMNS,
|
||||
)
|
||||
|
||||
|
||||
def get_all_column_names(columns: list[ColumnMetadata]) -> list[str]:
|
||||
"""Extract all column names from column metadata list."""
|
||||
return [col.name for col in columns]
|
||||
@@ -487,3 +621,4 @@ def get_all_column_names(columns: list[ColumnMetadata]) -> list[str]:
|
||||
CHART_ALL_COLUMNS: list[str] = []
|
||||
DATASET_ALL_COLUMNS: list[str] = []
|
||||
DASHBOARD_ALL_COLUMNS: list[str] = []
|
||||
DATABASE_ALL_COLUMNS: list[str] = []
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
# under the License.
|
||||
"""Constants for the MCP service."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
# Supported model types for schema discovery and MCP tools
|
||||
ModelType = Literal["chart", "dataset", "dashboard", "database"]
|
||||
|
||||
# Pagination defaults
|
||||
DEFAULT_PAGE_SIZE = 10 # Default number of items per page
|
||||
MAX_PAGE_SIZE = 100 # Maximum allowed page_size to prevent oversized responses
|
||||
|
||||
@@ -93,6 +93,10 @@ from superset.mcp_service.system.schemas import (
|
||||
TagInfo,
|
||||
UserInfo,
|
||||
)
|
||||
from superset.mcp_service.utils.sanitization import (
|
||||
_remove_dangerous_unicode,
|
||||
_strip_html_tags,
|
||||
)
|
||||
|
||||
|
||||
class DashboardError(BaseModel):
|
||||
@@ -445,9 +449,19 @@ class GenerateDashboardRequest(BaseModel):
|
||||
)
|
||||
description: str | None = Field(None, description="Description for the dashboard")
|
||||
published: bool = Field(
|
||||
default=True, description="Whether to publish the dashboard"
|
||||
default=False, description="Whether to publish the dashboard"
|
||||
)
|
||||
|
||||
@field_validator("dashboard_title")
|
||||
@classmethod
|
||||
def sanitize_dashboard_title(cls, v: str | None) -> str | None:
|
||||
"""Strip HTML tags from dashboard title to prevent XSS."""
|
||||
if v is None:
|
||||
return None
|
||||
v = _strip_html_tags(v.strip())
|
||||
v = _remove_dangerous_unicode(v)
|
||||
return v
|
||||
|
||||
|
||||
class GenerateDashboardResponse(BaseModel):
|
||||
"""Response schema for dashboard generation."""
|
||||
@@ -566,8 +580,9 @@ def serialize_dashboard_object(dashboard: Any) -> DashboardInfo:
|
||||
else None,
|
||||
chart_count=len(getattr(dashboard, "slices", [])),
|
||||
owners=[
|
||||
UserInfo.model_validate(owner, from_attributes=True)
|
||||
info
|
||||
for owner in getattr(dashboard, "owners", [])
|
||||
if (info := serialize_user_object(owner)) is not None
|
||||
]
|
||||
if getattr(dashboard, "owners", None)
|
||||
else [],
|
||||
|
||||
@@ -48,6 +48,9 @@ DEFAULT_DASHBOARD_COLUMNS = [
|
||||
"id",
|
||||
"dashboard_title",
|
||||
"slug",
|
||||
"description",
|
||||
"certified_by",
|
||||
"certification_details",
|
||||
"url",
|
||||
"changed_on",
|
||||
"changed_on_humanized",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
@@ -0,0 +1,364 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Pydantic schemas for database-related responses
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Dict, List, Literal
|
||||
|
||||
import humanize
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
field_validator,
|
||||
model_serializer,
|
||||
model_validator,
|
||||
PositiveInt,
|
||||
)
|
||||
|
||||
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
|
||||
from superset.mcp_service.common.cache_schemas import MetadataCacheControl
|
||||
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
|
||||
from superset.mcp_service.system.schemas import PaginationInfo
|
||||
from superset.mcp_service.utils.schema_utils import (
|
||||
parse_json_or_list,
|
||||
parse_json_or_model_list,
|
||||
)
|
||||
from superset.utils import json
|
||||
|
||||
|
||||
class DatabaseFilter(ColumnOperator):
|
||||
"""
|
||||
Filter object for database listing.
|
||||
col: The column to filter on. Must be one of the allowed filter fields.
|
||||
opr: The operator to use. Must be one of the supported operators.
|
||||
value: The value to filter by (type depends on col and opr).
|
||||
"""
|
||||
|
||||
col: Literal[
|
||||
"database_name",
|
||||
"expose_in_sqllab",
|
||||
"allow_file_upload",
|
||||
"created_by_fk",
|
||||
"changed_by_fk",
|
||||
] = Field(
|
||||
...,
|
||||
description="Column to filter on. Use get_schema(model_type='database') for "
|
||||
"available filter columns. Use created_by_fk with the user "
|
||||
"ID from get_instance_info's current_user to find "
|
||||
"databases created by a specific user.",
|
||||
)
|
||||
opr: ColumnOperatorEnum = Field(
|
||||
...,
|
||||
description="Operator to use. Use get_schema(model_type='database') for "
|
||||
"available operators.",
|
||||
)
|
||||
value: str | int | float | bool | List[str | int | float | bool] = Field(
|
||||
..., description="Value to filter by (type depends on col and opr)"
|
||||
)
|
||||
|
||||
|
||||
class DatabaseInfo(BaseModel):
|
||||
id: int | None = Field(None, description="Database ID")
|
||||
uuid: str | None = Field(None, description="Database UUID")
|
||||
database_name: str | None = Field(None, description="Database connection name")
|
||||
backend: str | None = Field(None, description="Database backend (e.g., postgresql)")
|
||||
expose_in_sqllab: bool | None = Field(
|
||||
None, description="Whether exposed in SQL Lab"
|
||||
)
|
||||
allow_ctas: bool | None = Field(
|
||||
None, description="Whether CREATE TABLE AS is allowed"
|
||||
)
|
||||
allow_cvas: bool | None = Field(
|
||||
None, description="Whether CREATE VIEW AS is allowed"
|
||||
)
|
||||
allow_dml: bool | None = Field(
|
||||
None, description="Whether DML statements are allowed"
|
||||
)
|
||||
allow_file_upload: bool | None = Field(
|
||||
None, description="Whether file upload is allowed"
|
||||
)
|
||||
allow_run_async: bool | None = Field(
|
||||
None, description="Whether async query execution is allowed"
|
||||
)
|
||||
cache_timeout: int | None = Field(
|
||||
None, description="Cache timeout override in seconds"
|
||||
)
|
||||
configuration_method: str | None = Field(
|
||||
None, description="Configuration method (sqlalchemy_form or dynamic_form)"
|
||||
)
|
||||
force_ctas_schema: str | None = Field(
|
||||
None, description="Schema to force for CTAS queries"
|
||||
)
|
||||
impersonate_user: bool | None = Field(
|
||||
None, description="Whether to impersonate the logged-in user"
|
||||
)
|
||||
is_managed_externally: bool | None = Field(
|
||||
None, description="Whether managed by an external system"
|
||||
)
|
||||
external_url: str | None = Field(
|
||||
None, description="URL of the external management system"
|
||||
)
|
||||
extra: Dict[str, Any | None] | None = Field(None, description="Extra configuration")
|
||||
changed_by: str | None = Field(None, description="Last modifier (username)")
|
||||
changed_on: str | datetime | None = Field(
|
||||
None, description="Last modification timestamp"
|
||||
)
|
||||
changed_on_humanized: str | None = Field(
|
||||
None, description="Humanized modification time"
|
||||
)
|
||||
created_by: str | None = Field(None, description="Database creator (username)")
|
||||
created_on: str | datetime | None = Field(None, description="Creation timestamp")
|
||||
created_on_humanized: str | None = Field(
|
||||
None, description="Humanized creation time"
|
||||
)
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
ser_json_timedelta="iso8601",
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
@model_serializer(mode="wrap", when_used="json")
|
||||
def _filter_fields_by_context(self, serializer: Any, info: Any) -> Dict[str, Any]:
|
||||
"""Filter fields based on serialization context.
|
||||
|
||||
If context contains 'select_columns', only include those fields.
|
||||
Otherwise, include all fields (default behavior).
|
||||
"""
|
||||
data = serializer(self)
|
||||
|
||||
if info.context and isinstance(info.context, dict):
|
||||
select_columns = info.context.get("select_columns")
|
||||
if select_columns:
|
||||
requested_fields = set(select_columns)
|
||||
return {k: v for k, v in data.items() if k in requested_fields}
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class DatabaseList(BaseModel):
|
||||
databases: List[DatabaseInfo]
|
||||
count: int
|
||||
total_count: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
has_previous: bool
|
||||
has_next: bool
|
||||
columns_requested: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Requested columns for the response",
|
||||
)
|
||||
columns_loaded: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Columns that were actually loaded for each database",
|
||||
)
|
||||
columns_available: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="All columns available for selection via select_columns parameter",
|
||||
)
|
||||
sortable_columns: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Columns that can be used with order_column parameter",
|
||||
)
|
||||
filters_applied: List[DatabaseFilter] = Field(
|
||||
default_factory=list,
|
||||
description="List of advanced filter dicts applied to the query.",
|
||||
)
|
||||
pagination: PaginationInfo | None = None
|
||||
timestamp: datetime | None = None
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
|
||||
class ListDatabasesRequest(MetadataCacheControl):
|
||||
"""Request schema for list_databases with clear, unambiguous types."""
|
||||
|
||||
filters: Annotated[
|
||||
List[DatabaseFilter],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="List of filter objects (column, operator, value). Each "
|
||||
"filter is an object with 'col', 'opr', and 'value' "
|
||||
"properties. Cannot be used together with 'search'.",
|
||||
),
|
||||
]
|
||||
select_columns: Annotated[
|
||||
List[str],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="List of columns to select. Defaults to common columns if not "
|
||||
"specified.",
|
||||
),
|
||||
]
|
||||
search: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description="Text search string to match against database fields. Cannot "
|
||||
"be used together with 'filters'.",
|
||||
),
|
||||
]
|
||||
order_column: Annotated[
|
||||
str | None, Field(default=None, description="Column to order results by")
|
||||
]
|
||||
order_direction: Annotated[
|
||||
Literal["asc", "desc"],
|
||||
Field(
|
||||
default="desc", description="Direction to order results ('asc' or 'desc')"
|
||||
),
|
||||
]
|
||||
page: Annotated[
|
||||
PositiveInt,
|
||||
Field(default=1, description="Page number for pagination (1-based)"),
|
||||
]
|
||||
page_size: Annotated[
|
||||
int,
|
||||
Field(
|
||||
default=DEFAULT_PAGE_SIZE,
|
||||
gt=0,
|
||||
le=MAX_PAGE_SIZE,
|
||||
description=f"Number of items per page (max {MAX_PAGE_SIZE})",
|
||||
),
|
||||
]
|
||||
|
||||
@field_validator("filters", mode="before")
|
||||
@classmethod
|
||||
def parse_filters(cls, v: Any) -> List[DatabaseFilter]:
|
||||
"""Accept both JSON string and list of objects."""
|
||||
return parse_json_or_model_list(v, DatabaseFilter, "filters")
|
||||
|
||||
@field_validator("select_columns", mode="before")
|
||||
@classmethod
|
||||
def parse_columns(cls, v: Any) -> List[str]:
|
||||
"""Accept JSON array, list, or comma-separated string."""
|
||||
return parse_json_or_list(v, "select_columns")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_search_and_filters(self) -> "ListDatabasesRequest":
|
||||
"""Prevent using both search and filters simultaneously to avoid query
|
||||
conflicts."""
|
||||
if self.search and self.filters:
|
||||
raise ValueError(
|
||||
"Cannot use both 'search' and 'filters' parameters simultaneously. "
|
||||
"Use either 'search' for text-based searching across multiple fields, "
|
||||
"or 'filters' for precise column-based filtering, but not both."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class DatabaseError(BaseModel):
|
||||
error: str = Field(..., description="Error message")
|
||||
error_type: str = Field(..., description="Type of error")
|
||||
timestamp: str | datetime | None = Field(None, description="Error timestamp")
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
@classmethod
|
||||
def create(cls, error: str, error_type: str) -> "DatabaseError":
|
||||
"""Create a standardized DatabaseError with timestamp."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return cls(
|
||||
error=error, error_type=error_type, timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
class GetDatabaseInfoRequest(MetadataCacheControl):
|
||||
"""Request schema for get_database_info with support for ID or UUID."""
|
||||
|
||||
identifier: Annotated[
|
||||
int | str,
|
||||
Field(description="Database identifier - can be numeric ID or UUID string"),
|
||||
]
|
||||
|
||||
|
||||
def _parse_json_field(obj: Any, field_name: str) -> Dict[str, Any] | None:
|
||||
"""Parse a field that may be stored as a JSON string into a dict."""
|
||||
value = getattr(obj, field_name, None)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _humanize_timestamp(dt: datetime | None) -> str | None:
|
||||
"""Convert a datetime to a humanized string like '2 hours ago'."""
|
||||
if dt is None:
|
||||
return None
|
||||
now = datetime.now(dt.tzinfo) if dt.tzinfo else datetime.now()
|
||||
return humanize.naturaltime(now - dt)
|
||||
|
||||
|
||||
def _get_backend(database: Any) -> str | None:
|
||||
"""Safely get backend from a Database object or row proxy.
|
||||
|
||||
backend is a @property that decrypts sqlalchemy_uri, which fails on
|
||||
row proxies returned by column-only DAO list queries. Fall back to None
|
||||
when the property raises.
|
||||
"""
|
||||
try:
|
||||
return database.backend
|
||||
except (AttributeError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def serialize_database_object(database: Any) -> DatabaseInfo | None:
|
||||
if not database:
|
||||
return None
|
||||
|
||||
return DatabaseInfo(
|
||||
id=getattr(database, "id", None),
|
||||
uuid=str(getattr(database, "uuid", ""))
|
||||
if getattr(database, "uuid", None)
|
||||
else None,
|
||||
database_name=getattr(database, "database_name", None),
|
||||
backend=_get_backend(database),
|
||||
expose_in_sqllab=getattr(database, "expose_in_sqllab", None),
|
||||
allow_ctas=getattr(database, "allow_ctas", None),
|
||||
allow_cvas=getattr(database, "allow_cvas", None),
|
||||
allow_dml=getattr(database, "allow_dml", None),
|
||||
allow_file_upload=getattr(database, "allow_file_upload", None),
|
||||
allow_run_async=getattr(database, "allow_run_async", None),
|
||||
cache_timeout=getattr(database, "cache_timeout", None),
|
||||
configuration_method=getattr(database, "configuration_method", None),
|
||||
force_ctas_schema=getattr(database, "force_ctas_schema", None),
|
||||
impersonate_user=getattr(database, "impersonate_user", None),
|
||||
is_managed_externally=getattr(database, "is_managed_externally", None),
|
||||
external_url=getattr(database, "external_url", None),
|
||||
extra=_parse_json_field(database, "extra"),
|
||||
changed_by=getattr(database, "changed_by_name", None)
|
||||
or (
|
||||
str(database.changed_by) if getattr(database, "changed_by", None) else None
|
||||
),
|
||||
changed_on=getattr(database, "changed_on", None),
|
||||
changed_on_humanized=_humanize_timestamp(getattr(database, "changed_on", None)),
|
||||
created_by=getattr(database, "created_by_name", None)
|
||||
or (
|
||||
str(database.created_by) if getattr(database, "created_by", None) else None
|
||||
),
|
||||
created_on=getattr(database, "created_on", None),
|
||||
created_on_humanized=_humanize_timestamp(getattr(database, "created_on", None)),
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from .get_database_info import get_database_info
|
||||
from .list_databases import list_databases
|
||||
|
||||
__all__ = [
|
||||
"list_databases",
|
||||
"get_database_info",
|
||||
]
|
||||
@@ -0,0 +1,137 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Get database info FastMCP tool
|
||||
|
||||
This module contains the FastMCP tool for getting detailed information
|
||||
about a specific database connection.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.database.schemas import (
|
||||
DatabaseError,
|
||||
DatabaseInfo,
|
||||
GetDatabaseInfoRequest,
|
||||
serialize_database_object,
|
||||
)
|
||||
from superset.mcp_service.mcp_core import ModelGetInfoCore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["discovery"],
|
||||
class_permission_name="Database",
|
||||
annotations=ToolAnnotations(
|
||||
title="Get database info",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def get_database_info(
|
||||
request: GetDatabaseInfoRequest, ctx: Context
|
||||
) -> DatabaseInfo | DatabaseError:
|
||||
"""Get database connection metadata by ID or UUID.
|
||||
|
||||
Returns database configuration including backend type and capabilities
|
||||
(allow_ctas, allow_dml, expose_in_sqllab, etc.).
|
||||
|
||||
IMPORTANT FOR LLM CLIENTS:
|
||||
- Use numeric ID (e.g., 123) or UUID string (e.g., "a1b2c3d4-...")
|
||||
- To find a database ID, use the list_databases tool first
|
||||
|
||||
Example usage:
|
||||
```json
|
||||
{
|
||||
"identifier": 1
|
||||
}
|
||||
```
|
||||
|
||||
Or with UUID:
|
||||
```json
|
||||
{
|
||||
"identifier": "a1b2c3d4-5678-90ab-cdef-1234567890ab"
|
||||
}
|
||||
```
|
||||
"""
|
||||
await ctx.info(
|
||||
"Retrieving database information: identifier=%s" % (request.identifier,)
|
||||
)
|
||||
await ctx.debug(
|
||||
"Metadata cache settings: use_cache=%s refresh_metadata=%s force_refresh=%s"
|
||||
% (
|
||||
request.use_cache,
|
||||
request.refresh_metadata,
|
||||
request.force_refresh,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
from superset.daos.database import DatabaseDAO
|
||||
|
||||
with event_logger.log_context(action="mcp.get_database_info.lookup"):
|
||||
get_tool = ModelGetInfoCore(
|
||||
dao_class=DatabaseDAO,
|
||||
output_schema=DatabaseInfo,
|
||||
error_schema=DatabaseError,
|
||||
serializer=serialize_database_object,
|
||||
supports_slug=False,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
result = get_tool.run_tool(request.identifier)
|
||||
|
||||
if isinstance(result, DatabaseInfo):
|
||||
await ctx.info(
|
||||
"Database information retrieved successfully: "
|
||||
"database_id=%s, database_name=%s, backend=%s"
|
||||
% (
|
||||
result.id,
|
||||
result.database_name,
|
||||
result.backend,
|
||||
)
|
||||
)
|
||||
else:
|
||||
await ctx.warning(
|
||||
"Database retrieval failed: error_type=%s, error=%s"
|
||||
% (result.error_type, result.error)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"Database information retrieval failed: identifier=%s, error=%s, "
|
||||
"error_type=%s"
|
||||
% (
|
||||
request.identifier,
|
||||
str(e),
|
||||
type(e).__name__,
|
||||
)
|
||||
)
|
||||
return DatabaseError(
|
||||
error=f"Failed to get database info: {str(e)}",
|
||||
error_type="InternalError",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
List databases FastMCP tool (Advanced with metadata cache control)
|
||||
|
||||
This module contains the FastMCP tool for listing databases using
|
||||
advanced filtering with clear, unambiguous request schema and metadata cache control.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset.models.core import Database
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.database.schemas import (
|
||||
DatabaseFilter,
|
||||
DatabaseInfo,
|
||||
DatabaseList,
|
||||
ListDatabasesRequest,
|
||||
serialize_database_object,
|
||||
)
|
||||
from superset.mcp_service.mcp_core import ModelListCore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["core"],
|
||||
class_permission_name="Database",
|
||||
annotations=ToolAnnotations(
|
||||
title="List databases",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def list_databases(request: ListDatabasesRequest, ctx: Context) -> DatabaseList:
|
||||
"""List database connections with filtering and search.
|
||||
|
||||
Returns database metadata including name, backend type, and permissions.
|
||||
|
||||
Sortable columns for order_column: id, database_name, changed_on,
|
||||
created_on
|
||||
"""
|
||||
await ctx.info(
|
||||
"Listing databases: page=%s, page_size=%s, search=%s"
|
||||
% (
|
||||
request.page,
|
||||
request.page_size,
|
||||
request.search,
|
||||
)
|
||||
)
|
||||
await ctx.debug(
|
||||
"Database listing parameters: filters=%s, order_column=%s, "
|
||||
"order_direction=%s, select_columns=%s"
|
||||
% (
|
||||
request.filters,
|
||||
request.order_column,
|
||||
request.order_direction,
|
||||
request.select_columns,
|
||||
)
|
||||
)
|
||||
await ctx.debug(
|
||||
"Metadata cache settings: use_cache=%s, refresh_metadata=%s, force_refresh=%s"
|
||||
% (
|
||||
request.use_cache,
|
||||
request.refresh_metadata,
|
||||
request.force_refresh,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
from superset.daos.database import DatabaseDAO
|
||||
from superset.mcp_service.common.schema_discovery import (
|
||||
DATABASE_DEFAULT_COLUMNS,
|
||||
DATABASE_SORTABLE_COLUMNS,
|
||||
get_all_column_names,
|
||||
get_database_columns,
|
||||
)
|
||||
|
||||
# Get all column names dynamically from the model
|
||||
all_columns = get_all_column_names(get_database_columns())
|
||||
|
||||
def _serialize_database(
|
||||
obj: "Database | None", cols: list[str] | None
|
||||
) -> DatabaseInfo | None:
|
||||
"""Serialize database (filtering via model_serializer)."""
|
||||
return serialize_database_object(obj)
|
||||
|
||||
# Create tool with standard serialization
|
||||
list_tool = ModelListCore(
|
||||
dao_class=DatabaseDAO,
|
||||
output_schema=DatabaseInfo,
|
||||
item_serializer=_serialize_database,
|
||||
filter_type=DatabaseFilter,
|
||||
default_columns=DATABASE_DEFAULT_COLUMNS,
|
||||
search_columns=["database_name"],
|
||||
list_field_name="databases",
|
||||
output_list_schema=DatabaseList,
|
||||
all_columns=all_columns,
|
||||
sortable_columns=DATABASE_SORTABLE_COLUMNS,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
with event_logger.log_context(action="mcp.list_databases.query"):
|
||||
result = list_tool.run_tool(
|
||||
filters=request.filters,
|
||||
search=request.search,
|
||||
select_columns=request.select_columns,
|
||||
order_column=request.order_column,
|
||||
order_direction=request.order_direction,
|
||||
page=max(request.page - 1, 0),
|
||||
page_size=request.page_size,
|
||||
)
|
||||
|
||||
await ctx.info(
|
||||
"Databases listed successfully: count=%s, total_count=%s, total_pages=%s"
|
||||
% (
|
||||
len(result.databases) if hasattr(result, "databases") else 0,
|
||||
getattr(result, "total_count", None),
|
||||
getattr(result, "total_pages", None),
|
||||
)
|
||||
)
|
||||
|
||||
# Apply field filtering via serialization context
|
||||
columns_to_filter = result.columns_requested
|
||||
await ctx.debug(
|
||||
"Applying field filtering via serialization context: columns=%s"
|
||||
% (columns_to_filter,)
|
||||
)
|
||||
with event_logger.log_context(action="mcp.list_databases.serialization"):
|
||||
return result.model_dump(
|
||||
mode="json",
|
||||
context={"select_columns": columns_to_filter},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"Database listing failed: page=%s, page_size=%s, error=%s, error_type=%s"
|
||||
% (
|
||||
request.page,
|
||||
request.page_size,
|
||||
str(e),
|
||||
type(e).__name__,
|
||||
)
|
||||
)
|
||||
raise
|
||||
@@ -102,6 +102,12 @@ class DatasetInfo(BaseModel):
|
||||
schema_name: str | None = Field(None, description="Schema name", alias="schema")
|
||||
database_name: str | None = Field(None, description="Database name")
|
||||
description: str | None = Field(None, description="Dataset description")
|
||||
certified_by: str | None = Field(
|
||||
None, description="Name of the person or team who certified this dataset"
|
||||
)
|
||||
certification_details: str | None = Field(
|
||||
None, description="Certification details or reason"
|
||||
)
|
||||
changed_by: str | None = Field(None, description="Last modifier (username)")
|
||||
changed_on: str | datetime | None = Field(
|
||||
None, description="Last modification timestamp"
|
||||
@@ -324,6 +330,9 @@ def _humanize_timestamp(dt: datetime | None) -> str | None:
|
||||
def serialize_dataset_object(dataset: Any) -> DatasetInfo | None:
|
||||
if not dataset:
|
||||
return None
|
||||
|
||||
from superset.mcp_service.utils.url_utils import get_superset_base_url
|
||||
|
||||
params = getattr(dataset, "params", None)
|
||||
if isinstance(params, str):
|
||||
try:
|
||||
@@ -360,6 +369,8 @@ def serialize_dataset_object(dataset: Any) -> DatasetInfo | None:
|
||||
if getattr(dataset, "database", None)
|
||||
else None,
|
||||
description=getattr(dataset, "description", None),
|
||||
certified_by=getattr(dataset, "certified_by", None),
|
||||
certification_details=getattr(dataset, "certification_details", None),
|
||||
changed_by=getattr(dataset, "changed_by_name", None)
|
||||
or (str(dataset.changed_by) if getattr(dataset, "changed_by", None) else None),
|
||||
changed_on=getattr(dataset, "changed_on", None),
|
||||
@@ -387,7 +398,12 @@ def serialize_dataset_object(dataset: Any) -> DatasetInfo | None:
|
||||
if getattr(dataset, "uuid", None)
|
||||
else None,
|
||||
schema_perm=getattr(dataset, "schema_perm", None),
|
||||
url=getattr(dataset, "url", None),
|
||||
url=(
|
||||
f"{get_superset_base_url()}/tablemodelview/edit/"
|
||||
f"{getattr(dataset, 'id', None)}"
|
||||
if getattr(dataset, "id", None)
|
||||
else None
|
||||
),
|
||||
sql=getattr(dataset, "sql", None),
|
||||
main_dttm_col=getattr(dataset, "main_dttm_col", None),
|
||||
offset=getattr(dataset, "offset", None),
|
||||
|
||||
@@ -48,6 +48,9 @@ DEFAULT_DATASET_COLUMNS = [
|
||||
"id",
|
||||
"table_name",
|
||||
"schema",
|
||||
"description",
|
||||
"certified_by",
|
||||
"certification_details",
|
||||
"changed_on",
|
||||
"changed_on_humanized",
|
||||
]
|
||||
|
||||
@@ -35,6 +35,7 @@ from superset.mcp_service.chart.chart_utils import (
|
||||
)
|
||||
from superset.mcp_service.chart.schemas import (
|
||||
GenerateExploreLinkRequest,
|
||||
parse_chart_config,
|
||||
)
|
||||
|
||||
|
||||
@@ -89,7 +90,7 @@ async def generate_explore_link(
|
||||
"""
|
||||
await ctx.info(
|
||||
"Generating explore link for dataset_id=%s, chart_type=%s"
|
||||
% (request.dataset_id, request.config.chart_type)
|
||||
% (request.dataset_id, request.config.get("chart_type", "unknown"))
|
||||
)
|
||||
await ctx.debug(
|
||||
"Configuration details: use_cache=%s, force_refresh=%s, cache_form_data=%s"
|
||||
@@ -97,6 +98,9 @@ async def generate_explore_link(
|
||||
)
|
||||
|
||||
try:
|
||||
# Parse the raw config dict into a typed ChartConfig
|
||||
config = parse_chart_config(request.config)
|
||||
|
||||
await ctx.report_progress(1, 4, "Validating dataset exists")
|
||||
with event_logger.log_context(action="mcp.generate_explore_link.dataset_check"):
|
||||
from superset.daos.dataset import DatasetDAO
|
||||
@@ -138,10 +142,10 @@ async def generate_explore_link(
|
||||
)
|
||||
|
||||
normalized_config = DatasetValidator.normalize_column_names(
|
||||
request.config, request.dataset_id
|
||||
config, request.dataset_id
|
||||
)
|
||||
except (ImportError, AttributeError, KeyError, ValueError, TypeError):
|
||||
normalized_config = request.config
|
||||
normalized_config = config
|
||||
|
||||
# Map config to form_data using shared utilities
|
||||
form_data = map_config_to_form_data(
|
||||
@@ -197,7 +201,12 @@ async def generate_explore_link(
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"Explore link generation failed for dataset_id=%s, chart_type=%s: %s: %s"
|
||||
% (request.dataset_id, request.config.chart_type, type(e).__name__, str(e))
|
||||
% (
|
||||
request.dataset_id,
|
||||
request.config.get("chart_type", "unknown"),
|
||||
type(e).__name__,
|
||||
str(e),
|
||||
)
|
||||
)
|
||||
return {
|
||||
"url": "",
|
||||
|
||||
@@ -25,6 +25,7 @@ from typing import Any, Callable, Dict, Generic, List, Literal, Type, TypeVar
|
||||
from pydantic import BaseModel
|
||||
|
||||
from superset.daos.base import BaseDAO
|
||||
from superset.mcp_service.constants import ModelType
|
||||
from superset.mcp_service.utils import _is_uuid
|
||||
|
||||
# Type variables for generic model tools
|
||||
@@ -160,11 +161,23 @@ class ModelListCore(BaseCore, Generic[L]):
|
||||
select_columns = parse_json_or_list(
|
||||
select_columns, param_name="select_columns"
|
||||
)
|
||||
columns_to_load = select_columns
|
||||
columns_to_load = list(select_columns)
|
||||
columns_requested = select_columns
|
||||
else:
|
||||
columns_to_load = self.default_columns
|
||||
columns_to_load = list(self.default_columns)
|
||||
columns_requested = self.default_columns
|
||||
|
||||
# Ensure computed columns have their dependencies loaded.
|
||||
# Humanized timestamps are derived from their raw counterparts —
|
||||
# if the raw column isn't loaded, the serializer produces null.
|
||||
computed_deps: dict[str, str] = {
|
||||
"changed_on_humanized": "changed_on",
|
||||
"created_on_humanized": "created_on",
|
||||
}
|
||||
for computed, dependency in computed_deps.items():
|
||||
if computed in columns_to_load and dependency not in columns_to_load:
|
||||
columns_to_load.append(dependency)
|
||||
|
||||
# Query the DAO
|
||||
items: List[Any]
|
||||
items, total_count = self.dao_class.list(
|
||||
@@ -521,7 +534,7 @@ class ModelGetSchemaCore(BaseCore, Generic[S]):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_type: Literal["chart", "dataset", "dashboard"],
|
||||
model_type: ModelType,
|
||||
dao_class: Type[BaseDAO[Any]],
|
||||
output_schema: Type[S],
|
||||
select_columns: List[Any],
|
||||
@@ -530,13 +543,14 @@ class ModelGetSchemaCore(BaseCore, Generic[S]):
|
||||
search_columns: List[str],
|
||||
default_sort: str = "changed_on",
|
||||
default_sort_direction: Literal["asc", "desc"] = "desc",
|
||||
exclude_filter_columns: set[str] | None = None,
|
||||
logger: logging.Logger | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the schema discovery core.
|
||||
|
||||
Args:
|
||||
model_type: The type of model (chart, dataset, dashboard)
|
||||
model_type: The type of model (chart, dataset, dashboard, database)
|
||||
dao_class: The DAO class to query for filter columns
|
||||
output_schema: Pydantic schema for the response (e.g., ModelSchemaInfo)
|
||||
select_columns: Column metadata (List[ColumnMetadata] or similar)
|
||||
@@ -545,6 +559,8 @@ class ModelGetSchemaCore(BaseCore, Generic[S]):
|
||||
search_columns: Column names used for text search
|
||||
default_sort: Default sort column
|
||||
default_sort_direction: Default sort direction
|
||||
exclude_filter_columns: Column names to omit from filter discovery
|
||||
(e.g., sensitive fields like passwords or connection URIs)
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
super().__init__(logger)
|
||||
@@ -557,6 +573,7 @@ class ModelGetSchemaCore(BaseCore, Generic[S]):
|
||||
self.search_columns = search_columns
|
||||
self.default_sort = default_sort
|
||||
self.default_sort_direction = default_sort_direction
|
||||
self.exclude_filter_columns = exclude_filter_columns or set()
|
||||
|
||||
def _get_filter_columns(self) -> Dict[str, List[str]]:
|
||||
"""Get filterable columns and operators from the DAO."""
|
||||
@@ -567,16 +584,25 @@ class ModelGetSchemaCore(BaseCore, Generic[S]):
|
||||
return {}
|
||||
# Convert to dict safely - handle both dict and dict-like objects
|
||||
if isinstance(filterable, dict):
|
||||
return dict(filterable)
|
||||
# Try to convert mapping-like objects
|
||||
try:
|
||||
return dict(filterable)
|
||||
except (TypeError, ValueError):
|
||||
self._log_warning(
|
||||
f"Unexpected filter columns type for {self.model_type}: "
|
||||
f"{type(filterable)}"
|
||||
)
|
||||
return {}
|
||||
result = dict(filterable)
|
||||
else:
|
||||
# Try to convert mapping-like objects
|
||||
try:
|
||||
result = dict(filterable)
|
||||
except (TypeError, ValueError):
|
||||
self._log_warning(
|
||||
f"Unexpected filter columns type for {self.model_type}: "
|
||||
f"{type(filterable)}"
|
||||
)
|
||||
return {}
|
||||
# Remove excluded columns (e.g., sensitive fields)
|
||||
if self.exclude_filter_columns:
|
||||
result = {
|
||||
k: v
|
||||
for k, v in result.items()
|
||||
if k not in self.exclude_filter_columns
|
||||
}
|
||||
return result
|
||||
except Exception as e:
|
||||
self._log_warning(
|
||||
f"Failed to get filter columns for {self.model_type}: {e}"
|
||||
|
||||
@@ -936,6 +936,72 @@ class ResponseSizeGuardMiddleware(Middleware):
|
||||
excluded_tools = [excluded_tools]
|
||||
self.excluded_tools = set(excluded_tools or [])
|
||||
|
||||
def _try_truncate_info_response(
|
||||
self,
|
||||
tool_name: str,
|
||||
response: Any,
|
||||
estimated_tokens: int,
|
||||
) -> Any | None:
|
||||
"""Attempt to dynamically truncate an info tool response to fit the limit.
|
||||
|
||||
Returns the truncated response if successful, None otherwise.
|
||||
"""
|
||||
from superset.mcp_service.utils.token_utils import (
|
||||
estimate_response_tokens,
|
||||
truncate_oversized_response,
|
||||
)
|
||||
|
||||
try:
|
||||
truncated, was_truncated, notes = truncate_oversized_response(
|
||||
response, self.token_limit
|
||||
)
|
||||
except (MemoryError, RecursionError) as trunc_error:
|
||||
logger.warning(
|
||||
"Truncation failed for %s due to %s: %s",
|
||||
tool_name,
|
||||
type(trunc_error).__name__,
|
||||
trunc_error,
|
||||
)
|
||||
return None
|
||||
|
||||
if not was_truncated:
|
||||
return None
|
||||
|
||||
truncated_tokens = estimate_response_tokens(truncated)
|
||||
if truncated_tokens > self.token_limit:
|
||||
return None
|
||||
|
||||
logger.warning(
|
||||
"Response for %s truncated from ~%d to ~%d tokens (limit: %d). Fields: %s",
|
||||
tool_name,
|
||||
estimated_tokens,
|
||||
truncated_tokens,
|
||||
self.token_limit,
|
||||
"; ".join(notes),
|
||||
)
|
||||
|
||||
try:
|
||||
user_id = get_user_id()
|
||||
event_logger.log(
|
||||
user_id=user_id,
|
||||
action="mcp_response_truncated",
|
||||
curated_payload={
|
||||
"tool": tool_name,
|
||||
"original_tokens": estimated_tokens,
|
||||
"truncated_tokens": truncated_tokens,
|
||||
"token_limit": self.token_limit,
|
||||
"truncation_notes": notes,
|
||||
},
|
||||
)
|
||||
except Exception as log_error: # noqa: BLE001
|
||||
logger.warning("Failed to log truncation event: %s", log_error)
|
||||
|
||||
if isinstance(truncated, dict):
|
||||
truncated["_response_truncated"] = True
|
||||
truncated["_truncation_notes"] = notes
|
||||
|
||||
return truncated
|
||||
|
||||
async def on_call_tool(
|
||||
self,
|
||||
context: MiddlewareContext,
|
||||
@@ -984,9 +1050,18 @@ class ResponseSizeGuardMiddleware(Middleware):
|
||||
|
||||
# Block if over limit
|
||||
if estimated_tokens > self.token_limit:
|
||||
# Extract params for smart suggestions
|
||||
params = getattr(context.message, "params", {}) or {}
|
||||
|
||||
# For info tools, try dynamic truncation before blocking
|
||||
from superset.mcp_service.utils.token_utils import INFO_TOOLS
|
||||
|
||||
if tool_name in INFO_TOOLS:
|
||||
truncated = self._try_truncate_info_response(
|
||||
tool_name, response, estimated_tokens
|
||||
)
|
||||
if truncated is not None:
|
||||
return truncated
|
||||
|
||||
# Log the blocked response
|
||||
logger.error(
|
||||
"Response blocked for %s: ~%d tokens exceeds limit of %d",
|
||||
@@ -1011,9 +1086,6 @@ class ResponseSizeGuardMiddleware(Middleware):
|
||||
except Exception as log_error: # noqa: BLE001
|
||||
logger.warning("Failed to log size exceeded event: %s", log_error)
|
||||
|
||||
# Generate helpful error message with suggestions
|
||||
# Avoid passing the full `response` (which may be huge) into the formatter
|
||||
# to prevent large-memory operations during error formatting.
|
||||
error_message = format_size_limit_error(
|
||||
tool_name=tool_name,
|
||||
params=params,
|
||||
|
||||
@@ -27,7 +27,7 @@ from urllib.parse import urlencode
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.extensions import db, event_logger
|
||||
from superset.mcp_service.sql_lab.schemas import (
|
||||
OpenSqlLabRequest,
|
||||
SqlLabResponse,
|
||||
@@ -118,6 +118,15 @@ def open_sql_lab_with_context(
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
try:
|
||||
db.session.rollback() # pylint: disable=consider-using-transaction
|
||||
except Exception: # noqa: BLE001
|
||||
# Broad catch: the DB connection itself may be broken (e.g.,
|
||||
# SSL drop), so even rollback can fail with non-SQLAlchemy
|
||||
# errors. This is a cleanup path — swallow and log.
|
||||
logger.warning(
|
||||
"Database rollback failed during error handling", exc_info=True
|
||||
)
|
||||
logger.error("Error generating SQL Lab URL: %s", e)
|
||||
return SqlLabResponse(
|
||||
url="",
|
||||
|
||||
@@ -23,9 +23,10 @@ InstanceInfoCore for flexible, extensible metrics calculation.
|
||||
import logging
|
||||
|
||||
from fastmcp import Context
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.extensions import db, event_logger
|
||||
from superset.mcp_service.mcp_core import InstanceInfoCore
|
||||
from superset.mcp_service.system.schemas import (
|
||||
GetSupersetInstanceInfoRequest,
|
||||
@@ -92,38 +93,75 @@ def get_instance_info(
|
||||
Returns counts, activity metrics, and database types.
|
||||
"""
|
||||
try:
|
||||
# Import DAOs at runtime to avoid circular imports
|
||||
from flask import g
|
||||
return _run_instance_info()
|
||||
|
||||
from superset.daos.chart import ChartDAO
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
from superset.daos.database import DatabaseDAO
|
||||
from superset.daos.dataset import DatasetDAO
|
||||
from superset.daos.tag import TagDAO
|
||||
from superset.daos.user import UserDAO
|
||||
except OperationalError as e:
|
||||
logger.warning(
|
||||
"Database connection error in get_instance_info, "
|
||||
"resetting session and retrying: %s",
|
||||
e,
|
||||
)
|
||||
try:
|
||||
db.session.rollback() # pylint: disable=consider-using-transaction
|
||||
except Exception: # noqa: BLE001
|
||||
# Broad catch: the DB connection itself may be broken (e.g.,
|
||||
# SSL drop), so even rollback can fail with non-SQLAlchemy
|
||||
# errors. This is a cleanup path — swallow and log.
|
||||
logger.warning(
|
||||
"Rollback failed during get_instance_info connection reset",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
db.session.remove() # pylint: disable=consider-using-transaction
|
||||
except Exception: # noqa: BLE001
|
||||
# Same as above — cleanup must not prevent the retry.
|
||||
logger.warning(
|
||||
"Session remove failed during get_instance_info connection reset",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Configure DAO classes at runtime
|
||||
_instance_info_core.dao_classes = {
|
||||
"dashboards": DashboardDAO,
|
||||
"charts": ChartDAO,
|
||||
"datasets": DatasetDAO,
|
||||
"databases": DatabaseDAO,
|
||||
"users": UserDAO,
|
||||
"tags": TagDAO,
|
||||
}
|
||||
|
||||
# Run the configurable core
|
||||
with event_logger.log_context(action="mcp.get_instance_info.metrics"):
|
||||
result = _instance_info_core.run_tool()
|
||||
|
||||
# Attach the authenticated user's identity to the response
|
||||
user = getattr(g, "user", None)
|
||||
if user is not None:
|
||||
result.current_user = serialize_user_object(user)
|
||||
|
||||
return result
|
||||
try:
|
||||
result = _run_instance_info()
|
||||
logger.info("get_instance_info retry succeeded after connection reset")
|
||||
return result
|
||||
except OperationalError as retry_error:
|
||||
logger.error(
|
||||
"get_instance_info retry failed after connection reset: %s",
|
||||
retry_error,
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Unexpected error in instance info: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
def _run_instance_info() -> InstanceInfo:
|
||||
"""Execute the instance info core logic."""
|
||||
from flask import g
|
||||
|
||||
from superset.daos.chart import ChartDAO
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
from superset.daos.database import DatabaseDAO
|
||||
from superset.daos.dataset import DatasetDAO
|
||||
from superset.daos.tag import TagDAO
|
||||
from superset.daos.user import UserDAO
|
||||
|
||||
_instance_info_core.dao_classes = {
|
||||
"dashboards": DashboardDAO,
|
||||
"charts": ChartDAO,
|
||||
"datasets": DatasetDAO,
|
||||
"databases": DatabaseDAO,
|
||||
"users": UserDAO,
|
||||
"tags": TagDAO,
|
||||
}
|
||||
|
||||
with event_logger.log_context(action="mcp.get_instance_info.metrics"):
|
||||
result = _instance_info_core.run_tool()
|
||||
|
||||
if (user := getattr(g, "user", None)) is not None:
|
||||
result.current_user = serialize_user_object(user)
|
||||
|
||||
return result
|
||||
|
||||
@@ -24,7 +24,7 @@ Column metadata is extracted dynamically from SQLAlchemy models.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Callable, Literal
|
||||
from typing import Callable
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
@@ -37,16 +37,21 @@ from superset.mcp_service.common.schema_discovery import (
|
||||
DASHBOARD_DEFAULT_COLUMNS,
|
||||
DASHBOARD_SEARCH_COLUMNS,
|
||||
DASHBOARD_SORTABLE_COLUMNS,
|
||||
DATABASE_DEFAULT_COLUMNS,
|
||||
DATABASE_SEARCH_COLUMNS,
|
||||
DATABASE_SORTABLE_COLUMNS,
|
||||
DATASET_DEFAULT_COLUMNS,
|
||||
DATASET_SEARCH_COLUMNS,
|
||||
DATASET_SORTABLE_COLUMNS,
|
||||
get_chart_columns,
|
||||
get_dashboard_columns,
|
||||
get_database_columns,
|
||||
get_dataset_columns,
|
||||
GetSchemaRequest,
|
||||
GetSchemaResponse,
|
||||
ModelSchemaInfo,
|
||||
)
|
||||
from superset.mcp_service.constants import ModelType
|
||||
from superset.mcp_service.mcp_core import ModelGetSchemaCore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -109,14 +114,36 @@ def _get_dashboard_schema_core() -> ModelGetSchemaCore[ModelSchemaInfo]:
|
||||
)
|
||||
|
||||
|
||||
def _get_database_schema_core() -> ModelGetSchemaCore[ModelSchemaInfo]:
|
||||
"""Create database schema core with dynamically extracted columns."""
|
||||
# Lazy import to avoid circular dependency at module load time
|
||||
from superset.daos.database import DatabaseDAO
|
||||
from superset.mcp_service.common.schema_discovery import DATABASE_EXCLUDE_COLUMNS
|
||||
|
||||
return ModelGetSchemaCore(
|
||||
model_type="database",
|
||||
dao_class=DatabaseDAO,
|
||||
output_schema=ModelSchemaInfo,
|
||||
select_columns=get_database_columns(),
|
||||
sortable_columns=DATABASE_SORTABLE_COLUMNS,
|
||||
default_columns=DATABASE_DEFAULT_COLUMNS,
|
||||
search_columns=DATABASE_SEARCH_COLUMNS,
|
||||
default_sort="changed_on",
|
||||
default_sort_direction="desc",
|
||||
exclude_filter_columns=DATABASE_EXCLUDE_COLUMNS,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
|
||||
# Map model types to their core factory functions
|
||||
_SCHEMA_CORE_FACTORIES: dict[
|
||||
Literal["chart", "dataset", "dashboard"],
|
||||
ModelType,
|
||||
Callable[[], ModelGetSchemaCore[ModelSchemaInfo]],
|
||||
] = {
|
||||
"chart": _get_chart_schema_core,
|
||||
"dataset": _get_dataset_schema_core,
|
||||
"dashboard": _get_dashboard_schema_core,
|
||||
"database": _get_database_schema_core,
|
||||
}
|
||||
|
||||
|
||||
@@ -143,7 +170,7 @@ async def get_schema(request: GetSchemaRequest, ctx: Context) -> GetSchemaRespon
|
||||
Column metadata is extracted dynamically from SQLAlchemy models.
|
||||
|
||||
Args:
|
||||
model_type: One of "chart", "dataset", or "dashboard"
|
||||
model_type: One of "chart", "dataset", "dashboard", or "database"
|
||||
|
||||
Returns:
|
||||
Comprehensive schema information for the requested model type
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user