Compare commits

...
Author SHA1 Message Date
Beto Dealmeida 754807b87d WIP 2024-11-25 16:43:20 -05:00
Beto Dealmeida 782f94fe8d Another fix 2024-11-24 18:53:14 -05:00
Beto Dealmeida 8e0c00a82e Move integration tests to unit tests 2024-11-24 18:44:51 -05:00
Beto Dealmeida c0c8802de9 Small fix 2024-11-24 18:03:20 -05:00
Beto Dealmeida cd3209a600 chore (SIP-117): remove more sqlparse 2024-11-22 12:04:19 -05:00
Kamil GabryjelskiandGitHub f8adaf66c1 chore: Refactor dashboard header to func component (#31029) 2024-11-22 14:10:46 +01:00
Beto DealmeidaandGitHub 832fed1db5 fix(dataset): use sqlglot for DML check (#31024) 2024-11-22 07:21:05 -05:00
ccce9abf57 chore(🦾): bump python cattrs 23.2.3 -> 24.1.2 (#30998)
Co-authored-by: GitHub Action <action@github.com>
2024-11-21 15:15:15 -08:00
7d60a88048 docs: Update doc about CSV upload (#30867)
Co-authored-by: Evan Rusackas <evan@preset.io>
2024-11-21 16:15:08 -07:00
LindenandGitHub 8905508d8f fix(imports): import query_context for imports with charts (#30887) 2024-11-21 16:14:45 -07:00
Giampaolo CapelliandGitHub 0b647b2dcc docs: Embedded sdk (#30972) 2024-11-21 16:14:18 -07:00
David HothamandGitHub f58b5295a9 chore: publish wheels (#30981) 2024-11-21 12:18:27 -08:00
JUST.in DO ITandGitHub 9e5b568cc9 fix(explore): verified props is not updated (#31008) 2024-11-21 11:05:43 -08:00
GeidoandGitHub 90572be95a fix(Dashboard): Retain colors when color scheme not set (#30646) 2024-11-21 19:58:32 +02:00
3c326598a8 chore(🦾): bump python flask-babel subpackage(s) (#31000)
Co-authored-by: GitHub Action <action@github.com>
2024-11-21 09:06:58 -08:00
ae04959175 chore(🦾): bump python cffi 1.16.0 -> 1.17.1 (#31002)
Co-authored-by: GitHub Action <action@github.com>
2024-11-21 09:05:28 -08:00
1e224398e1 chore(🦾): bump python numexpr 2.10.0 -> 2.10.1 (#31006)
Co-authored-by: GitHub Action <action@github.com>
2024-11-21 09:04:43 -08:00
Beto DealmeidaandGitHub fa7b66424c chore: add unit tests for is_mutating() (#31021) 2024-11-21 11:08:53 -05:00
110 changed files with 3466 additions and 1656 deletions
+3 -2
View File
@@ -437,7 +437,7 @@ cd ${SUPERSET_RELEASE_RC}
python3 -m venv venv
source venv/bin/activate
pip install -r requirements/base.txt
pip install twine
pip install build twine
```
Create the distribution
@@ -455,7 +455,7 @@ cd ../
./scripts/translations/generate_po_files.sh
# build the python distribution
python setup.py sdist
python -m build
```
Publish to PyPI
@@ -466,6 +466,7 @@ an account first if you don't have one, and reference your username
while requesting access to push packages.
```bash
twine upload dist/apache_superset-${SUPERSET_VERSION}-py3-none-any.whl
twine upload dist/apache-superset-${SUPERSET_VERSION}.tar.gz
```
+9 -8
View File
@@ -27,33 +27,34 @@ following information about each flight is given:
You may need to enable the functionality to upload a CSV or Excel file to your database. The following section
explains how to enable this functionality for the examples database.
In the top menu, select **Data ‣ Databases**. Find the **examples** database in the list and
In the top menu, select **Settings ‣ Data ‣ Database Connections**. Find the **examples** database in the list and
select the **Edit** button.
<img src={useBaseUrl("/img/tutorial/edit-record.png" )} />
In the resulting modal window, switch to the **Extra** tab and
tick the checkbox for **Allow Data Upload**. End by clicking the **Save** button.
In the resulting modal window, switch to the **Advanced** tab and open **Security** section.
Then, tick the checkbox for **Allow file uploads to database**. End by clicking the **Finish** button.
<img src={useBaseUrl("/img/tutorial/add-data-upload.png" )} />
<img src={useBaseUrl("/img/tutorial/allow-file-uploads.png" )} />
### Loading CSV Data
Download the CSV dataset to your computer from
[GitHub](https://raw.githubusercontent.com/apache-superset/examples-data/master/tutorial_flights.csv).
In the Superset menu, select **Data ‣ Upload a CSV**.
In the top menu, select **Settings ‣ Data ‣ Database Connections**. Then, **Upload file to database ‣ Upload CSV**.
<img src={useBaseUrl("/img/tutorial/upload_a_csv.png" )} />
Then, enter the **Table Name** as _tutorial_flights_ and select the CSV file from your computer.
Then, select select the CSV file from your computer, select **Database** and **Schema**, and enter the **Table Name**
as _tutorial_flights_.
<img src={useBaseUrl("/img/tutorial/csv_to_database_configuration.png" )} />
Next enter the text _Travel Date_ into the **Parse Dates** field.
Next enter the text _Travel Date_ into the **File settings ‣ Columns to be parsed as dates** field.
<img src={useBaseUrl("/img/tutorial/parse_dates_column.png" )} />
Leaving all the other options in their default settings, select **Save** at the bottom of the page.
Leaving all the other options in their default settings, select **Upload** at the bottom of the page.
### Table Visualization
Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 70 KiB

+13 -11
View File
@@ -17,12 +17,12 @@ apsw==3.46.0.0
# via shillelagh
async-timeout==4.0.3
# via redis
attrs==23.2.0
attrs==24.2.0
# via
# cattrs
# jsonschema
# requests-cache
babel==2.15.0
babel==2.16.0
# via flask-babel
backoff==2.2.1
# via apache-superset
@@ -30,7 +30,7 @@ bcrypt==4.1.3
# via paramiko
billiard==4.2.0
# via celery
blinker==1.8.2
blinker==1.9.0
# via flask
bottleneck==1.3.8
# via pandas
@@ -42,13 +42,13 @@ cachelib==0.9.0
# flask-session
cachetools==5.3.3
# via google-auth
cattrs==23.2.3
cattrs==24.1.2
# via requests-cache
celery==5.4.0
# via apache-superset
certifi==2024.2.2
# via requests
cffi==1.16.0
cffi==1.17.1
# via
# cryptography
# pynacl
@@ -148,7 +148,9 @@ geopy==2.4.1
google-auth==2.29.0
# via shillelagh
greenlet==3.0.3
# via shillelagh
# via
# shillelagh
# sqlalchemy
gunicorn==22.0.0
# via apache-superset
hashids==1.3.1
@@ -195,7 +197,7 @@ markdown==3.6
# via apache-superset
markdown-it-py==3.0.0
# via rich
markupsafe==2.1.5
markupsafe==3.0.2
# via
# jinja2
# mako
@@ -217,7 +219,7 @@ nh3==0.2.17
# via apache-superset
numba==0.59.1
# via pandas
numexpr==2.10.0
numexpr==2.10.1
# via
# -r requirements/base.in
# pandas
@@ -299,7 +301,7 @@ python-dotenv==1.0.1
# via apache-superset
python-geohash==0.8.5
# via apache-superset
pytz==2024.1
pytz==2024.2
# via
# croniter
# flask-babel
@@ -358,7 +360,7 @@ sshtunnel==0.4.0
# via apache-superset
tabulate==0.8.10
# via apache-superset
typing-extensions==4.12.0
typing-extensions==4.12.2
# via
# alembic
# apache-superset
@@ -385,7 +387,7 @@ vine==5.1.0
# kombu
wcwidth==0.2.13
# via prompt-toolkit
werkzeug==3.0.6
werkzeug==3.1.3
# via
# -r requirements/base.in
# flask
+29
View File
@@ -27,6 +27,11 @@ using your app's authentication.
Embedding is done by inserting an iframe, containing a Superset page, into the host application.
## Prerequisites
* Activate the feature flag `EMBEDDED_SUPERSET`
* Set a strong password in configuration variable `GUEST_TOKEN_JWT_SECRET` (see configuration file config.py). Be aware that its default value must be changed in production.
## Embedding a Dashboard
Using npm:
@@ -108,6 +113,30 @@ Example `POST /security/guest_token` payload:
]
}
```
Alternatively, a guest token can be created directly in your app with a json like the following, and then signed
with the secret set in configuration variable `GUEST_TOKEN_JWT_SECRET` (see configuration file config.py)
```
{
"user": {
"username": "embedded@embedded.fr",
"first_name": "embedded",
"last_name": "embedded"
},
"resources": [
{
"type": "dashboard",
"id": "d73e7841-9342-4afd-8e29-b4a416a2498c"
}
],
"rls_rules": [],
"iat": 1730883214,
"exp": 1732956814,
"aud": "superset",
"type": "guest"
}
```
### Sandbox iframe
The Embedded SDK creates an iframe with [sandbox](https://developer.mozilla.org/es/docs/Web/HTML/Element/iframe#sandbox) mode by default
@@ -16,10 +16,19 @@
* specific language governing permissions and limitations
* under the License.
*/
import { SAMPLE_DASHBOARD_1, TABBED_DASHBOARD } from 'cypress/utils/urls';
import {
SAMPLE_DASHBOARD_1,
SUPPORTED_CHARTS_DASHBOARD,
TABBED_DASHBOARD,
} from 'cypress/utils/urls';
import { drag, resize, waitForChartLoad } from 'cypress/utils';
import * as ace from 'brace';
import { interceptGet, interceptUpdate, openTab } from './utils';
import {
interceptExploreUpdate,
interceptGet,
interceptUpdate,
openTab,
} from './utils';
import {
interceptExploreJson,
interceptFiltering as interceptCharts,
@@ -42,15 +51,37 @@ function openProperties() {
cy.getBySel('header-actions-menu')
.contains('Edit properties')
.click({ force: true });
cy.wait(500);
cy.get('.ant-modal-body').should('be.visible');
});
}
function openExploreProperties() {
cy.getBySel('actions-trigger').click({ force: true });
cy.get('.ant-dropdown-menu')
.contains('Edit chart properties')
.click({ force: true });
cy.get('.ant-modal-body').should('be.visible');
}
function assertMetadata(text: string) {
const regex = new RegExp(text);
cy.get('#json_metadata')
.should('be.visible')
.then(() => {
const metadata = cy.$$('#json_metadata')[0];
// cypress can read this locally, but not in ci
// so we have to use the ace module directly to fetch the value
expect(ace.edit(metadata).getValue()).to.match(regex);
});
}
function openAdvancedProperties() {
cy.get('.ant-modal-body')
.contains('Advanced')
.should('be.visible')
.click({ force: true });
cy.get('#json_metadata').should('be.visible');
}
function dragComponent(
@@ -83,20 +114,36 @@ function visitEdit(sampleDashboard = SAMPLE_DASHBOARD_1) {
cy.visit(sampleDashboard);
cy.wait('@get');
editDashboard();
cy.get('.grid-container').should('exist');
cy.wait('@filtering');
cy.wait(500);
}
function resetTabbedDashboard(go = false) {
function visit(sampleDashboard = SAMPLE_DASHBOARD_1) {
interceptCharts();
interceptGet();
if (sampleDashboard === SAMPLE_DASHBOARD_1) {
cy.createSampleDashboards([0]);
}
cy.visit(sampleDashboard);
cy.wait('@get');
cy.get('.grid-container').should('exist');
cy.wait(500);
}
function resetDashboardColors(dashboard = 'tabbed_dash') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cy.getDashboard('tabbed_dash').then((r: Record<string, any>) => {
cy.getDashboard(dashboard).then((r: Record<string, any>) => {
const jsonMetadata = r?.json_metadata || '{}';
const metadata = JSON.parse(jsonMetadata);
const resetMetadata = JSON.stringify({
...metadata,
color_scheme: '',
label_colors: {},
shared_label_colors: {},
shared_label_colors: [],
map_label_colors: {},
});
cy.updateDashboard(r.id, {
certification_details: r.certification_details,
@@ -106,25 +153,35 @@ function resetTabbedDashboard(go = false) {
json_metadata: resetMetadata,
owners: r.owners,
slug: r.slug,
}).then(() => {
if (go) {
visitEdit(TABBED_DASHBOARD);
}
});
});
}
function visitResetTabbedDashboard() {
resetTabbedDashboard(true);
function selectColorScheme(
color: string,
target = 'dashboard-edit-properties-form',
) {
cy.get(`[data-test="${target}"] input[aria-label="Select color scheme"]`)
.first()
.then($input => {
cy.wrap($input).click({ force: true });
cy.wrap($input).type(color.slice(0, 5), { force: true });
});
cy.getBySel(color).click({ force: true });
}
function selectColorScheme(color: string) {
cy.get(
'[data-test="dashboard-edit-properties-form"] [aria-label="Select color scheme"]',
)
.first()
.click();
cy.getBySel(color).click({ force: true });
function saveAndGo(dashboard = 'Tabbed Dashboard') {
interceptExploreUpdate();
cy.getBySel('query-save-button').click();
cy.getBySel('save-modal-body').then($modal => {
cy.wrap($modal)
.find("div[aria-label='Select a dashboard'] .ant-select-selection-item")
.should('have.text', dashboard);
cy.getBySel('save-overwrite-radio').should('not.be.disabled');
cy.getBySel('save-overwrite-radio').click();
cy.get('#btn_modal_save_goto_dash').click();
cy.wait('@chartUpdate');
});
}
function applyChanges() {
@@ -137,37 +194,37 @@ function saveChanges() {
cy.wait('@update');
}
function assertMetadata(text: string) {
const regex = new RegExp(text);
cy.get('#json_metadata')
.should('be.visible')
.then(() => {
const metadata = cy.$$('#json_metadata')[0];
// cypress can read this locally, but not in ci
// so we have to use the ace module directly to fetch the value
expect(ace.edit(metadata).getValue()).to.match(regex);
});
}
function clearMetadata() {
cy.get('#json_metadata').then($jsonmetadata => {
cy.wrap($jsonmetadata).find('.ace_content').click();
cy.wrap($jsonmetadata).find('.ace_content').click({ force: true });
cy.wrap($jsonmetadata)
.find('.ace_text-input')
.type('{selectall} {backspace}', { force: true });
.then($ace => {
cy.wrap($ace).focus();
cy.wrap($ace).should('have.focus');
cy.wrap($ace).type('{selectall}', { force: true });
cy.wrap($ace).type('{backspace}', { force: true });
});
});
}
function writeMetadata(metadata: string) {
cy.get('#json_metadata').then($jsonmetadata =>
cy
.wrap($jsonmetadata)
cy.get('#json_metadata').then($jsonmetadata => {
cy.wrap($jsonmetadata).find('.ace_content').click({ force: true });
cy.wrap($jsonmetadata)
.find('.ace_text-input')
.type(metadata, { parseSpecialCharSequences: false, force: true }),
);
.then($ace => {
cy.wrap($ace).focus();
cy.wrap($ace).should('have.focus');
cy.wrap($ace).type(metadata, {
parseSpecialCharSequences: false,
force: true,
});
});
});
}
function openExplore(chartName: string) {
function openExploreWithDashboardContext(chartName: string) {
interceptExploreJson();
interceptGet();
@@ -181,23 +238,351 @@ function openExplore(chartName: string) {
.should('contain', 'Edit chart')
.click();
cy.wait('@getJson');
cy.get('.chart-container').should('exist');
}
function saveExploreColorScheme(
chart = 'Top 10 California Names Timeseries',
colorScheme = 'supersetColors',
) {
interceptExploreUpdate();
openExploreWithDashboardContext(chart);
openTab(0, 1, 'control-tabs');
selectColorScheme(colorScheme, 'control-item');
cy.getBySel('query-save-button').click();
cy.getBySel('save-overwrite-radio').click();
cy.getBySel('btn-modal-save').click();
cy.wait('@chartUpdate');
}
describe('Dashboard edit', () => {
describe('Color consistency', () => {
beforeEach(() => {
visitResetTabbedDashboard();
resetDashboardColors();
});
after(() => {
resetTabbedDashboard();
it('should not allow to change color scheme of a chart when dashboard has one', () => {
visitEdit(TABBED_DASHBOARD);
openProperties();
selectColorScheme('blueToGreen');
applyChanges();
saveChanges();
// open nested tab
openTab(1, 1);
waitForChartLoad({
name: 'Top 10 California Names Timeseries',
viz: 'line',
});
openExploreWithDashboardContext('Top 10 California Names Timeseries');
// label Anthony
cy.get('[data-test="chart-container"] .line .nv-legend-symbol')
.first()
.should('have.css', 'fill', 'rgb(50, 0, 167)');
openTab(0, 1, 'control-tabs');
cy.get('[aria-label="Select color scheme"]').should('be.disabled');
});
it('should not allow to change color scheme of a chart when dashboard has no scheme but chart has shared labels', () => {
visit(TABBED_DASHBOARD);
// open nested tab
openTab(1, 1);
waitForChartLoad({
name: 'Top 10 California Names Timeseries',
viz: 'line',
});
// open second top tab to catch shared labels
openTab(0, 1);
waitForChartLoad({
name: 'Trends',
viz: 'line',
});
openTab(0, 0);
openExploreWithDashboardContext('Top 10 California Names Timeseries');
// label Anthony
cy.get('[data-test="chart-container"] .line .nv-legend-symbol')
.first()
.should('have.css', 'fill', 'rgb(31, 168, 201)');
openTab(0, 1, 'control-tabs');
cy.get('[aria-label="Select color scheme"]').should('be.disabled');
});
it('should allow to change color scheme of a chart when dashboard has no scheme but only custom label colors', () => {
visitEdit(TABBED_DASHBOARD);
openProperties();
openAdvancedProperties();
clearMetadata();
writeMetadata('{"color_scheme":"","label_colors":{"Anthony":"red"}}');
applyChanges();
saveChanges();
// open nested tab
openTab(1, 1);
waitForChartLoad({
name: 'Top 10 California Names Timeseries',
viz: 'line',
});
// label Anthony
cy.get(
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(255, 0, 0)');
openExploreWithDashboardContext('Top 10 California Names Timeseries');
// label Anthony
cy.get('[data-test="chart-container"] .line .nv-legend-symbol')
.first()
.should('have.css', 'fill', 'rgb(255, 0, 0)');
openTab(0, 1, 'control-tabs');
selectColorScheme('blueToGreen', 'control-item');
// label Anthony
cy.get('[data-test="chart-container"] .line .nv-legend-symbol')
.first()
.should('have.css', 'fill', 'rgb(255, 0, 0)');
// label Christopher
cy.get('[data-test="chart-container"] .line .nv-legend-symbol')
.eq(1)
.should('have.css', 'fill', 'rgb(50, 0, 167)');
// label Daniel
cy.get('[data-test="chart-container"] .line .nv-legend-symbol')
.eq(2)
.should('have.css', 'fill', 'rgb(0, 76, 218)');
// label David
cy.get('[data-test="chart-container"] .line .nv-legend-symbol')
.eq(3)
.should('have.css', 'fill', 'rgb(0, 116, 241)');
});
it('should allow to change color scheme of a chart when dashboard has no scheme and show the change', () => {
visit(TABBED_DASHBOARD);
// open nested tab
openTab(1, 1);
waitForChartLoad({
name: 'Top 10 California Names Timeseries',
viz: 'line',
});
// label Anthony
cy.get(
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(31, 168, 201)');
openExploreWithDashboardContext('Top 10 California Names Timeseries');
// label Anthony
cy.get('[data-test="chart-container"] .line .nv-legend-symbol')
.first()
.should('have.css', 'fill', 'rgb(31, 168, 201)');
openTab(0, 1, 'control-tabs');
selectColorScheme('blueToGreen', 'control-item');
// label Anthony
cy.get('[data-test="chart-container"] .line .nv-legend-symbol')
.first()
.should('have.css', 'fill', 'rgb(50, 0, 167)');
saveAndGo();
// label Anthony
cy.get('[data-test="chart-container"] .line .nv-legend-symbol')
.first()
.should('have.css', 'fill', 'rgb(50, 0, 167)');
// reset original scheme
saveExploreColorScheme();
});
it('should allow to change color scheme of a chart when dashboard has no scheme but custom label colors and show the change', () => {
visitEdit(TABBED_DASHBOARD);
openProperties();
openAdvancedProperties();
clearMetadata();
writeMetadata('{"color_scheme":"","label_colors":{"Anthony":"red"}}');
applyChanges();
saveChanges();
// open nested tab
openTab(1, 1);
waitForChartLoad({
name: 'Top 10 California Names Timeseries',
viz: 'line',
});
// label Anthony
cy.get(
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(255, 0, 0)');
openExploreWithDashboardContext('Top 10 California Names Timeseries');
// label Anthony
cy.get('[data-test="chart-container"] .line .nv-legend-symbol')
.first()
.should('have.css', 'fill', 'rgb(255, 0, 0)');
openTab(0, 1, 'control-tabs');
selectColorScheme('blueToGreen', 'control-item');
// label Anthony
cy.get('[data-test="chart-container"] .line .nv-legend-symbol')
.first()
.should('have.css', 'fill', 'rgb(255, 0, 0)');
// label Christopher
cy.get('[data-test="chart-container"] .line .nv-legend-symbol')
.eq(1)
.should('have.css', 'fill', 'rgb(50, 0, 167)');
saveAndGo();
// label Anthony
cy.get('[data-test="chart-container"] .line .nv-legend-symbol')
.first()
.should('have.css', 'fill', 'rgb(255, 0, 0)');
// label Christopher
cy.get('[data-test="chart-container"] .line .nv-legend-symbol')
.eq(1)
.should('have.css', 'fill', 'rgb(50, 0, 167)');
// reset original scheme
saveExploreColorScheme();
});
it('should not change colors on refreshes with no color scheme set', () => {
visit(TABBED_DASHBOARD);
// open nested tab
openTab(1, 1);
waitForChartLoad({
name: 'Top 10 California Names Timeseries',
viz: 'line',
});
// label Anthony
cy.get(
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(31, 168, 201)');
// open 2nd main tab
openTab(0, 1);
waitForChartLoad({ name: 'Trends', viz: 'line' });
// label Andrew
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.eq(1)
.should('have.css', 'fill', 'rgb(69, 78, 124)');
visit(TABBED_DASHBOARD);
// open nested tab
openTab(1, 1);
waitForChartLoad({
name: 'Top 10 California Names Timeseries',
viz: 'line',
});
// label Anthony
cy.get(
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(31, 168, 201)');
// open 2nd main tab
openTab(0, 1);
waitForChartLoad({ name: 'Trends', viz: 'line' });
// label Andrew
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.eq(1)
.should('have.css', 'fill', 'rgb(69, 78, 124)');
});
it('should not change colors on refreshes with color scheme set', () => {
visitEdit(TABBED_DASHBOARD);
openProperties();
selectColorScheme('blueToGreen');
applyChanges();
saveChanges();
// open nested tab
openTab(1, 1);
waitForChartLoad({
name: 'Top 10 California Names Timeseries',
viz: 'line',
});
// label Anthony
cy.get(
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(50, 0, 167)');
// open 2nd main tab
openTab(0, 1);
waitForChartLoad({ name: 'Trends', viz: 'line' });
// label Andrew
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.eq(1)
.should('have.css', 'fill', 'rgb(0, 76, 218)');
visit(TABBED_DASHBOARD);
// open nested tab
openTab(1, 1);
waitForChartLoad({
name: 'Top 10 California Names Timeseries',
viz: 'line',
});
// label Anthony
cy.get(
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(50, 0, 167)');
// open 2nd main tab
openTab(0, 1);
waitForChartLoad({ name: 'Trends', viz: 'line' });
// label Andrew
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.eq(1)
.should('have.css', 'fill', 'rgb(0, 76, 218)');
});
it('should respect chart color scheme when none is set for the dashboard', () => {
openProperties();
cy.get('[aria-label="Select color scheme"]').should('have.value', '');
applyChanges();
saveChanges();
visit(TABBED_DASHBOARD);
// open nested tab
openTab(1, 1);
@@ -214,7 +599,8 @@ describe('Dashboard edit', () => {
.should('have.css', 'fill', 'rgb(31, 168, 201)');
});
it('should apply same color to same labels with color scheme set', () => {
it('should apply same color to same labels with color scheme set on refresh', () => {
visitEdit(TABBED_DASHBOARD);
openProperties();
selectColorScheme('blueToGreen');
applyChanges();
@@ -238,17 +624,63 @@ describe('Dashboard edit', () => {
openTab(0, 1);
waitForChartLoad({ name: 'Trends', viz: 'line' });
// label Anthony
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.eq(2)
.should('have.css', 'fill', 'rgb(50, 0, 167)');
visit(TABBED_DASHBOARD);
// open nested tab
openTab(1, 1);
waitForChartLoad({
name: 'Top 10 California Names Timeseries',
viz: 'line',
});
// label Anthony
cy.get(
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(50, 0, 167)');
// open 2nd main tab
openTab(0, 1);
waitForChartLoad({ name: 'Trends', viz: 'line' });
// label Anthony
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.eq(2)
.should('have.css', 'fill', 'rgb(50, 0, 167)');
});
it('should apply same color to same labels with no color scheme set', () => {
openProperties();
cy.get('[aria-label="Select color scheme"]').should('have.value', '');
applyChanges();
saveChanges();
it('should apply same color to same labels with no color scheme set on refresh', () => {
visit(TABBED_DASHBOARD);
// open nested tab
openTab(1, 1);
waitForChartLoad({
name: 'Top 10 California Names Timeseries',
viz: 'line',
});
// label Anthony
cy.get(
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(31, 168, 201)');
// open 2nd main tab
openTab(0, 1);
waitForChartLoad({ name: 'Trends', viz: 'line' });
// label Anthony
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.eq(2)
.should('have.css', 'fill', 'rgb(31, 168, 201)');
visit(TABBED_DASHBOARD);
// open nested tab
openTab(1, 1);
@@ -275,6 +707,7 @@ describe('Dashboard edit', () => {
});
it('custom label colors should take the precedence in nested tabs', () => {
visitEdit(TABBED_DASHBOARD);
openProperties();
openAdvancedProperties();
clearMetadata();
@@ -305,6 +738,7 @@ describe('Dashboard edit', () => {
});
it('label colors should take the precedence for rendered charts in nested tabs', () => {
visitEdit(TABBED_DASHBOARD);
// open the tab first time and let chart load
openTab(1, 1);
waitForChartLoad({
@@ -333,6 +767,7 @@ describe('Dashboard edit', () => {
});
it('should re-apply original color after removing custom label color with color scheme set', () => {
visitEdit(TABBED_DASHBOARD);
openProperties();
openAdvancedProperties();
clearMetadata();
@@ -375,6 +810,7 @@ describe('Dashboard edit', () => {
});
it('should re-apply original color after removing custom label color with no color scheme set', () => {
visitEdit(TABBED_DASHBOARD);
// open nested tab
openTab(1, 1);
waitForChartLoad({
@@ -438,6 +874,7 @@ describe('Dashboard edit', () => {
});
it('should show the same colors in Explore', () => {
visitEdit(TABBED_DASHBOARD);
openProperties();
openAdvancedProperties();
clearMetadata();
@@ -461,7 +898,7 @@ describe('Dashboard edit', () => {
.first()
.should('have.css', 'fill', 'rgb(255, 0, 0)');
openExplore('Top 10 California Names Timeseries');
openExploreWithDashboardContext('Top 10 California Names Timeseries');
// label Anthony
cy.get('[data-test="chart-container"] .line .nv-legend-symbol')
@@ -469,7 +906,8 @@ describe('Dashboard edit', () => {
.should('have.css', 'fill', 'rgb(255, 0, 0)');
});
it.skip('should change color scheme multiple times', () => {
it('should change color scheme multiple times', () => {
visitEdit(TABBED_DASHBOARD);
openProperties();
selectColorScheme('blueToGreen');
applyChanges();
@@ -487,7 +925,7 @@ describe('Dashboard edit', () => {
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(234, 11, 140)');
.should('have.css', 'fill', 'rgb(50, 0, 167)');
// open 2nd main tab
openTab(0, 1);
@@ -496,7 +934,7 @@ describe('Dashboard edit', () => {
// label Anthony
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.eq(2)
.should('have.css', 'fill', 'rgb(234, 11, 140)');
.should('have.css', 'fill', 'rgb(50, 0, 167)');
editDashboard();
openProperties();
@@ -507,7 +945,7 @@ describe('Dashboard edit', () => {
// label Anthony
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.eq(2)
.should('have.css', 'fill', 'rgb(41, 105, 107)');
.should('have.css', 'fill', 'rgb(0, 128, 246)');
// open main tab and nested tab
openTab(0, 0);
@@ -518,10 +956,11 @@ describe('Dashboard edit', () => {
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(41, 105, 107)');
.should('have.css', 'fill', 'rgb(0, 128, 246)');
});
it.skip('should apply the color scheme across main tabs', () => {
it('should apply the color scheme across main tabs', () => {
visitEdit(TABBED_DASHBOARD);
openProperties();
selectColorScheme('blueToGreen');
applyChanges();
@@ -533,10 +972,11 @@ describe('Dashboard edit', () => {
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.first()
.should('have.css', 'fill', 'rgb(234, 11, 140)');
.should('have.css', 'fill', 'rgb(50, 0, 167)');
});
it.skip('should apply the color scheme across main tabs for rendered charts', () => {
it('should apply the color scheme across main tabs for rendered charts', () => {
visitEdit(TABBED_DASHBOARD);
waitForChartLoad({ name: 'Treemap', viz: 'treemap_v2' });
openProperties();
selectColorScheme('blueToGreen');
@@ -549,7 +989,7 @@ describe('Dashboard edit', () => {
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.first()
.should('have.css', 'fill', 'rgb(41, 105, 107)');
.should('have.css', 'fill', 'rgb(50, 0, 167)');
// change scheme now that charts are rendered across the main tabs
editDashboard();
@@ -560,10 +1000,11 @@ describe('Dashboard edit', () => {
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.first()
.should('have.css', 'fill', 'rgb(234, 11, 140)');
.should('have.css', 'fill', 'rgb(0, 128, 246)');
});
it.skip('should apply the color scheme in nested tabs', () => {
it('should apply the color scheme in nested tabs', () => {
visitEdit(TABBED_DASHBOARD);
openProperties();
selectColorScheme('blueToGreen');
applyChanges();
@@ -579,17 +1020,18 @@ describe('Dashboard edit', () => {
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(234, 11, 140)');
.should('have.css', 'fill', 'rgb(50, 0, 167)');
// open another nested tab
openTab(2, 1);
waitForChartLoad({ name: 'Growth Rate', viz: 'line' });
cy.get('[data-test-chart-name="Growth Rate"] .line .nv-legend-symbol')
.first()
.should('have.css', 'fill', 'rgb(234, 11, 140)');
.should('have.css', 'fill', 'rgb(50, 0, 167)');
});
it.skip('should apply a valid color scheme for rendered charts in nested tabs', () => {
it('should apply a valid color scheme for rendered charts in nested tabs', () => {
visitEdit(TABBED_DASHBOARD);
// open the tab first time and let chart load
openTab(1, 1);
waitForChartLoad({
@@ -611,7 +1053,7 @@ describe('Dashboard edit', () => {
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(234, 11, 140)');
.should('have.css', 'fill', 'rgb(50, 0, 167)');
});
});
@@ -623,9 +1065,10 @@ describe('Dashboard edit', () => {
beforeEach(() => {
cy.createSampleDashboards([0]);
openProperties();
selectColorScheme('supersetColors');
});
it.skip('should accept a valid color scheme', () => {
it('should accept a valid color scheme', () => {
openAdvancedProperties();
clearMetadata();
writeMetadata('{"color_scheme":"lyftColors"}');
@@ -636,21 +1079,21 @@ describe('Dashboard edit', () => {
applyChanges();
});
it.skip('should overwrite the color scheme when advanced is closed', () => {
it('should overwrite the color scheme when advanced is closed', () => {
selectColorScheme('blueToGreen');
openAdvancedProperties();
assertMetadata('blueToGreen');
applyChanges();
});
it.skip('should overwrite the color scheme when advanced is open', () => {
it('should overwrite the color scheme when advanced is open', () => {
openAdvancedProperties();
selectColorScheme('modernSunset');
assertMetadata('modernSunset');
applyChanges();
});
it.skip('should not accept an invalid color scheme', () => {
it('should not accept an invalid color scheme', () => {
openAdvancedProperties();
clearMetadata();
// allow console error
@@ -716,7 +1159,7 @@ describe('Dashboard edit', () => {
visitEdit();
});
it.skip('should add charts', () => {
it('should add charts', () => {
cy.get('[role="checkbox"]').click();
dragComponent();
cy.getBySel('dashboard-component-chart-holder').should('have.length', 1);
@@ -765,7 +1208,7 @@ describe('Dashboard edit', () => {
visitEdit();
});
it.skip('should save', () => {
it('should save', () => {
cy.get('[role="checkbox"]').click();
dragComponent();
cy.getBySel('header-save-button').should('be.enabled');
@@ -91,7 +91,7 @@ export function prepareDashboardFilters(
refresh_frequency: 0,
color_scheme: '',
label_colors: {},
shared_label_colors: {},
shared_label_colors: [],
color_scheme_domain: [],
cross_filters_enabled: false,
positions: {
@@ -125,7 +125,7 @@ export const valueNativeFilterOptions = [
];
export function interceptGet() {
cy.intercept('/api/v1/dashboard/*').as('get');
cy.intercept('GET', '/api/v1/dashboard/*').as('get');
}
export function interceptFiltering() {
@@ -144,6 +144,10 @@ export function interceptUpdate() {
cy.intercept('PUT', `/api/v1/dashboard/*`).as('update');
}
export function interceptExploreUpdate() {
cy.intercept('PUT', `/api/v1/chart/*`).as('chartUpdate');
}
export function interceptPost() {
cy.intercept('POST', `/api/v1/dashboard/`).as('post');
}
@@ -524,13 +528,17 @@ export function addCountryNameFilter() {
);
}
export function openTab(tabComponentIndex: number, tabIndex: number) {
return cy
.getBySel('dashboard-component-tabs')
export function openTab(
tabComponentIndex: number,
tabIndex: number,
target = 'dashboard-component-tabs',
) {
cy.getBySel(target)
.eq(tabComponentIndex)
.find('[role="tab"]')
.eq(tabIndex)
.click();
cy.wait(500);
}
export const openTopLevelTab = (tabName: string) => {
@@ -50,6 +50,7 @@ import {
import {
formatSelectOptions,
displayTimeRelatedControls,
getColorControlsProps,
D3_FORMAT_OPTIONS,
D3_FORMAT_DOCS,
D3_TIME_FORMAT_OPTIONS,
@@ -142,9 +143,7 @@ const linear_color_scheme: SharedControlConfig<'ColorSchemeControl'> = {
renderTrigger: true,
schemes: () => sequentialSchemeRegistry.getMap(),
isLinear: true,
mapStateToProps: state => ({
dashboardId: state?.form_data?.dashboardId,
}),
mapStateToProps: state => getColorControlsProps(state),
};
const granularity: SharedControlConfig<'SelectControl'> = {
@@ -333,9 +332,7 @@ const color_scheme: SharedControlConfig<'ColorSchemeControl'> = {
choices: () => categoricalSchemeRegistry.keys().map(s => [s, s]),
description: t('The color scheme for rendering chart'),
schemes: () => categoricalSchemeRegistry.getMap(),
mapStateToProps: state => ({
dashboardId: state?.form_data?.dashboardId,
}),
mapStateToProps: state => getColorControlsProps(state),
};
const time_shift_color: SharedControlConfig<'CheckboxControl'> = {
@@ -87,6 +87,9 @@ export interface Dataset {
}
export interface ControlPanelState {
slice: {
slice_id: number;
};
form_data: QueryFormData;
datasource: Dataset | QueryResponse | null;
controls: ControlStateMapping;
@@ -0,0 +1,32 @@
/**
* 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.
*/
export const getColorControlsProps = (state: Record<string, any>) => {
const dashboardId = state?.form_data?.dashboardId;
return {
chartId: state?.slice?.slice_id,
dashboardId,
hasDashboardColorScheme:
!!dashboardId && !!state?.form_data?.dashboard_color_scheme,
hasCustomLabelsColor:
Object.keys(state?.form_data?.label_colors || {}).length > 0,
colorNamespace: state?.form_data?.color_namespace,
mapLabelsColors: state?.form_data?.map_label_colors || {},
sharedLabelsColors: state?.form_data?.shared_label_colors || [],
};
};
@@ -27,3 +27,4 @@ export * from './defineSavedMetrics';
export * from './getStandardizedControls';
export * from './getTemporalColumns';
export { default as displayTimeRelatedControls } from './displayTimeRelatedControls';
export * from './colorControls';
@@ -0,0 +1,112 @@
/**
* 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 { getColorControlsProps } from '../../src';
describe('getColorControlsProps', () => {
it('should return default values when state is empty', () => {
const state = {};
const result = getColorControlsProps(state);
expect(result).toEqual({
chartId: undefined,
dashboardId: undefined,
hasDashboardColorScheme: false,
hasCustomLabelsColor: false,
colorNamespace: undefined,
mapLabelsColors: {},
sharedLabelsColors: [],
});
});
it('should return correct values when state has form_data with dashboardId and color scheme', () => {
const state = {
form_data: {
dashboardId: 123,
dashboard_color_scheme: 'blueScheme',
label_colors: {},
},
slice: { slice_id: 456 },
};
const result = getColorControlsProps(state);
expect(result).toEqual({
chartId: 456,
dashboardId: 123,
hasDashboardColorScheme: true,
hasCustomLabelsColor: false,
colorNamespace: undefined,
mapLabelsColors: {},
sharedLabelsColors: [],
});
});
it('should detect custom label colors correctly', () => {
const state = {
form_data: {
dashboardId: 123,
label_colors: { label1: '#000000' },
},
slice: { slice_id: 456 },
};
const result = getColorControlsProps(state);
expect(result).toEqual({
chartId: 456,
dashboardId: 123,
hasDashboardColorScheme: false,
hasCustomLabelsColor: true,
colorNamespace: undefined,
mapLabelsColors: {},
sharedLabelsColors: [],
});
});
it('should return shared label colors when available', () => {
const state = {
form_data: {
shared_label_colors: ['#FF5733', '#33FF57'],
},
};
const result = getColorControlsProps(state);
expect(result).toEqual({
chartId: undefined,
dashboardId: undefined,
hasDashboardColorScheme: false,
hasCustomLabelsColor: false,
sharedLabelsColors: ['#FF5733', '#33FF57'],
colorNamespace: undefined,
mapLabelsColors: {},
});
});
it('should handle missing form_data and slice properties', () => {
const state = {
form_data: {
dashboardId: 789,
},
};
const result = getColorControlsProps(state);
expect(result).toEqual({
chartId: undefined,
dashboardId: 789,
hasDashboardColorScheme: false,
hasCustomLabelsColor: false,
colorNamespace: undefined,
mapLabelsColors: {},
sharedLabelsColors: [],
});
});
});
@@ -17,6 +17,7 @@
* under the License.
*/
import { cloneDeep } from 'lodash';
import CategoricalColorScale from './CategoricalColorScale';
import { ColorsLookup } from './types';
import getCategoricalSchemeRegistry from './CategoricalSchemeRegistrySingleton';
@@ -37,10 +38,21 @@ export default class CategoricalColorNamespace {
this.forcedItems = {};
}
getScale(schemeId?: string) {
const id = schemeId ?? getCategoricalSchemeRegistry().getDefaultKey() ?? '';
/**
* A new CategoricalColorScale instance is created for each chart.
*
* @param colorScheme - the color scheme to use
* @returns a new instance of a color scale
*/
getScale(colorScheme?: string) {
const id =
colorScheme ?? getCategoricalSchemeRegistry().getDefaultKey() ?? '';
const scheme = getCategoricalSchemeRegistry().get(id);
return new CategoricalColorScale(scheme?.colors ?? [], this.forcedItems);
return new CategoricalColorScale(
scheme?.colors ?? [],
this.forcedItems,
colorScheme,
);
}
/**
@@ -59,6 +71,17 @@ export default class CategoricalColorNamespace {
resetColors() {
this.forcedItems = {};
}
resetColorsForLabels(labels: string[] = []) {
const updatedForcedItems = cloneDeep(this.forcedItems);
labels.forEach(label => {
if (updatedForcedItems.hasOwnProperty(label)) {
delete updatedForcedItems[label];
}
});
this.forcedItems = { ...updatedForcedItems };
}
}
const namespaces: {
@@ -80,16 +103,19 @@ export function getNamespace(name: string = DEFAULT_NAMESPACE) {
export function getColor(
value?: string,
schemeId?: string,
colorScheme?: string,
namespace?: string,
) {
return getNamespace(namespace).getScale(schemeId).getColor(value);
return getNamespace(namespace).getScale(colorScheme).getColor(value);
}
/*
Returns a new scale instance within the same namespace.
Especially useful when a chart is booting for the first time
@param scheme - the applied color scheme
@param namespace - the namespace
*/
export function getScale(scheme?: string, namespace?: string) {
return getNamespace(namespace).getScale(scheme);
export function getScale(colorScheme?: string, namespace?: string) {
return getNamespace(namespace).getScale(colorScheme);
}
@@ -21,14 +21,16 @@ import { scaleOrdinal, ScaleOrdinal } from 'd3-scale';
import { ExtensibleFunction } from '../models';
import { ColorsInitLookup, ColorsLookup } from './types';
import stringifyAndTrim from './stringifyAndTrim';
import getLabelsColorMap from './LabelsColorMapSingleton';
import getLabelsColorMap, {
LabelsColorMapSource,
} from './LabelsColorMapSingleton';
import { getAnalogousColors } from './utils';
import { FeatureFlag, isFeatureEnabled } from '../utils';
// Use type augmentation to correct the fact that
// an instance of CategoricalScale is also a function
interface CategoricalColorScale {
(x: { toString(): string }, y?: number, w?: string): string;
(x: { toString(): string }, y?: number): string;
}
class CategoricalColorScale extends ExtensibleFunction {
@@ -50,11 +52,16 @@ class CategoricalColorScale extends ExtensibleFunction {
* Constructor
* @param {*} colors an array of colors
* @param {*} forcedColors optional parameter that comes from parent
* (usually CategoricalColorNamespace)
* @param {*} appliedColorScheme the color scheme applied to the chart
*
*/
constructor(colors: string[], forcedColors: ColorsInitLookup = {}) {
super((value: string, sliceId?: number, colorScheme?: string) =>
this.getColor(value, sliceId, colorScheme),
constructor(
colors: string[],
forcedColors: ColorsInitLookup = {},
appliedColorScheme?: string,
) {
super((value: string, sliceId?: number) =>
this.getColor(value, sliceId, appliedColorScheme),
);
// holds original color scheme colors
this.originColors = colors;
@@ -107,15 +114,28 @@ class CategoricalColorScale extends ExtensibleFunction {
*
* @param value the value of a label to get the color for
* @param sliceId the ID of the current chart
* @param colorScheme the original color scheme of the chart
* @param appliedColorScheme the color scheme applied to the chart
* @returns the color or the next available color
*/
getColor(value?: string, sliceId?: number, colorScheme?: string): string {
getColor(
value?: string,
sliceId?: number,
appliedColorScheme?: string,
): string {
const cleanedValue = stringifyAndTrim(value);
// priority: forced color (i.e. custom label colors) > shared color > scale color
// priority: forced color (aka custom label colors) > shared color > scale color
const forcedColor = this.forcedColors?.[cleanedValue];
const isExistingLabel = this.chartLabelsColorMap.has(cleanedValue);
let color = forcedColor || this.scale(cleanedValue);
const { source } = this.labelsColorMapInstance;
const currentColorMap =
source === LabelsColorMapSource.Dashboard
? this.labelsColorMapInstance.getColorMap()
: this.chartLabelsColorMap;
const isExistingLabel = currentColorMap.has(cleanedValue);
let color =
forcedColor ||
(isExistingLabel
? (currentColorMap.get(cleanedValue) as string)
: this.scale(cleanedValue));
// a forced color will always be used independently of the usage count
if (!forcedColor && !isExistingLabel) {
@@ -128,7 +148,7 @@ class CategoricalColorScale extends ExtensibleFunction {
this.isColorUsed(color)
) {
// fallback to least used color
color = this.getNextAvailableColor(color);
color = this.getNextAvailableColor(cleanedValue, color);
}
}
@@ -141,7 +161,7 @@ class CategoricalColorScale extends ExtensibleFunction {
cleanedValue,
color,
sliceId,
colorScheme,
appliedColorScheme,
);
}
return color;
@@ -164,48 +184,76 @@ class CategoricalColorScale extends ExtensibleFunction {
* @param color the color to check
* @returns the count of the color usage in this slice
*/
getColorUsageCount(currentColor: string): number {
let count = 0;
this.chartLabelsColorMap.forEach(color => {
if (color === currentColor) {
count += 1;
}
});
return count;
getColorUsageCount(color: string): number {
return Array.from(this.chartLabelsColorMap.values()).filter(
value => value === color,
).length;
}
/**
* Lower chances of color collision by returning the least used color
* Checks across colors of current slice within LabelsColorMapSingleton
* Lower chances of color collision by returning the least used color.
* Checks across colors of current slice within chartLabelsColorMap.
*
* @param currentLabel the current label
* @param currentColor the current color
* @returns the least used color that is not the excluded color
* @returns the least used color that is not the current color
*/
getNextAvailableColor(currentColor: string) {
const colorUsageArray = this.colors.map(color => ({
color,
count: this.getColorUsageCount(color),
}));
const currentColorCount = this.getColorUsageCount(currentColor);
const otherColors = colorUsageArray.filter(
colorEntry => colorEntry.color !== currentColor,
);
// all other colors are used as much or more than currentColor
const hasNoneAvailable = otherColors.every(
colorEntry => colorEntry.count >= currentColorCount,
getNextAvailableColor(currentLabel: string, currentColor: string): string {
// Precompute color usage counts for all colors
const colorUsageCounts = new Map(
this.colors.map(color => [color, this.getColorUsageCount(color)]),
);
// fallback to currentColor color
if (!otherColors.length || hasNoneAvailable) {
return currentColor;
// Get an ordered array of labels from the map
const orderedLabels = Array.from(this.chartLabelsColorMap.keys());
const currentLabelIndex = orderedLabels.indexOf(currentLabel);
// Helper to infer "previous" and "next" labels based on index
const getAdjacentLabelsColors = (): string[] => {
const previousLabel =
currentLabelIndex > 0 ? orderedLabels[currentLabelIndex - 1] : null;
const nextLabel =
currentLabelIndex < orderedLabels.length - 1
? orderedLabels[currentLabelIndex + 1]
: null;
const previousColor = previousLabel
? this.chartLabelsColorMap.get(previousLabel)
: null;
const nextColor = nextLabel
? this.chartLabelsColorMap.get(nextLabel)
: null;
return [previousColor, nextColor].filter(color => color) as string[];
};
const adjacentColors = getAdjacentLabelsColors();
// Determine adjusted score (usage count + penalties)
const calculateScore = (color: string): number => {
/* istanbul ignore next */
const usageCount = colorUsageCounts.get(color) || 0;
const adjacencyPenalty = adjacentColors.includes(color) ? 100 : 0;
return usageCount + adjacencyPenalty;
};
// If there is any color that has never been used, prioritize it
const unusedColor = this.colors.find(
color => (colorUsageCounts.get(color) || 0) === 0,
);
if (unusedColor) {
return unusedColor;
}
// Finding the least used color
const leastUsedColor = otherColors.reduce((min, entry) =>
entry.count < min.count ? entry : min,
).color;
// If all colors are used, calculate scores and choose the best one
const otherColors = this.colors.filter(color => color !== currentColor);
return leastUsedColor;
// Find the color with the minimum score, defaulting to currentColor
return otherColors.reduce((bestColor, color) => {
const bestScore = calculateScore(bestColor);
const currentScore = calculateScore(color);
return currentScore < bestScore ? color : bestColor;
}, currentColor);
}
/**
@@ -18,6 +18,7 @@
*/
import { makeSingleton } from '../utils';
import CategoricalColorNamespace from './CategoricalColorNamespace';
export enum LabelsColorMapSource {
Dashboard,
@@ -25,7 +26,10 @@ export enum LabelsColorMapSource {
}
export class LabelsColorMap {
chartsLabelsMap: Map<number, { labels: string[]; scheme?: string }>;
chartsLabelsMap: Map<
number,
{ labels: string[]; scheme?: string; ownScheme?: string }
>;
colorMap: Map<string, string>;
@@ -38,17 +42,38 @@ export class LabelsColorMap {
this.source = LabelsColorMapSource.Dashboard;
}
updateColorMap(categoricalNamespace: any, colorScheme?: string) {
const newColorMap = new Map();
this.colorMap.clear();
/**
* Wipes out the color map and updates it with the new color scheme.
*
* @param categoricalNamespace - the namespace to use for color mapping
* @param colorScheme - color scheme
*/
updateColorMap(
categoricalNamespace: CategoricalColorNamespace,
colorScheme?: string,
merge = false,
) {
const newColorMap = this.colorMap;
if (!merge) {
newColorMap.clear();
}
this.chartsLabelsMap.forEach((chartConfig, sliceId) => {
const { labels, scheme: originalChartColorScheme } = chartConfig;
const currentColorScheme = colorScheme || originalChartColorScheme;
const colorScale = categoricalNamespace.getScale(currentColorScheme);
const { labels, ownScheme } = chartConfig;
const appliedColorScheme = colorScheme || ownScheme;
const colorScale = categoricalNamespace.getScale(appliedColorScheme);
labels.forEach(label => {
const newColor = colorScale.getColor(label, sliceId);
newColorMap.set(label, newColor);
// if merge, apply the scheme only to new labels in the map
if (!merge || !this.colorMap.has(label)) {
const newColor = colorScale.getColor(
label,
sliceId,
appliedColorScheme,
);
newColorMap.set(label, newColor);
}
});
});
this.colorMap = newColorMap;
@@ -58,29 +83,63 @@ export class LabelsColorMap {
return this.colorMap;
}
/**
*
* Called individually by each plugin via getColor fn.
*
* @param label - the label name
* @param color - the color
* @param sliceId - the chart id
* @param colorScheme - the color scheme
*
*/
addSlice(
label: string,
color: string,
sliceId: number,
colorScheme?: string,
) {
if (this.source !== LabelsColorMapSource.Dashboard) return;
const chartConfig = this.chartsLabelsMap.get(sliceId) || {
labels: [],
scheme: '',
scheme: undefined,
ownScheme: undefined,
};
const { labels } = chartConfig;
if (!labels.includes(label)) {
labels.push(label);
this.chartsLabelsMap.set(sliceId, {
labels,
scheme: colorScheme,
ownScheme: chartConfig.ownScheme,
});
}
this.colorMap.set(label, color);
if (this.source === LabelsColorMapSource.Dashboard) {
this.colorMap.set(label, color);
}
}
/**
* Used to make sure all slices respect their original scheme.
*
* @param sliceId - the chart id
* @param ownScheme - the color scheme
*/
setOwnColorScheme(sliceId: number, ownScheme: string) {
const chartConfig = this.chartsLabelsMap.get(sliceId);
if (chartConfig) {
this.chartsLabelsMap.set(sliceId, {
...chartConfig,
ownScheme,
});
}
}
/**
* Remove a slice from the color map.
*
* @param sliceId - the chart
*/
removeSlice(sliceId: number) {
if (this.source !== LabelsColorMapSource.Dashboard) return;
@@ -96,10 +155,20 @@ export class LabelsColorMap {
this.colorMap = newColorMap;
}
/**
* Clear the shared labels color map.
*/
clear() {
this.chartsLabelsMap.clear();
this.colorMap.clear();
}
/**
* Clears all maps
*/
reset() {
this.clear();
this.chartsLabelsMap.clear();
}
}
const getInstance = makeSingleton(LabelsColorMap);
@@ -161,4 +161,63 @@ describe('CategoricalColorNamespace', () => {
expect(color).toBe(color2);
});
});
describe('statis resetColorsForLabels(labels)', () => {
it('removes specified labels from forcedItems', () => {
const namespace = getNamespace('test-reset-individual');
namespace.setColor('label1', 'red');
namespace.setColor('label2', 'blue');
namespace.resetColorsForLabels(['label1']);
expect(namespace.forcedItems).toMatchObject({ label2: 'blue' });
});
it('does not modify forcedItems if no labels are provided', () => {
const namespace = getNamespace('test-reset-individual');
namespace.setColor('label1', 'red');
namespace.setColor('label2', 'blue');
namespace.resetColorsForLabels();
expect(namespace.forcedItems).toMatchObject({
label1: 'red',
label2: 'blue',
});
});
it('does nothing if the label is not in forcedItems', () => {
const namespace = getNamespace('test-reset-individual');
namespace.setColor('label1', 'red');
namespace.resetColorsForLabels(['label2']); // label2 doesn't exist
expect(namespace.forcedItems).toMatchObject({ label1: 'red' });
});
it('removes all labels when all are provided', () => {
const namespace = getNamespace('test-reset-individual');
namespace.setColor('label1', 'red');
namespace.setColor('label2', 'blue');
namespace.resetColorsForLabels(['label1', 'label2']);
expect(namespace.forcedItems).toMatchObject({});
});
it('creates a deep copy of forcedItems before modifying', () => {
const namespace = getNamespace('test-reset-individual');
namespace.setColor('label1', 'red');
const originalForcedItems = namespace.forcedItems;
namespace.resetColorsForLabels(['label1']);
expect(originalForcedItems).not.toBe(namespace.forcedItems);
});
it('removes the label if it exists in updatedForcedItems', () => {
const namespace = getNamespace('test-reset-individual');
namespace.setColor('label1', 'red');
namespace.resetColorsForLabels(['label1']);
expect(namespace.forcedItems).toEqual({});
});
it('does nothing for a label not in updatedForcedItems', () => {
const namespace = getNamespace('test-reset-individual');
namespace.setColor('label1', 'red');
namespace.resetColorsForLabels(['label2']); // label2 doesn't exist
expect(namespace.forcedItems).toEqual({ label1: 'red' });
});
});
});
@@ -18,7 +18,11 @@
*/
import { ScaleOrdinal } from 'd3-scale';
import { CategoricalColorScale, FeatureFlag } from '@superset-ui/core';
import {
CategoricalColorScale,
FeatureFlag,
LabelsColorMapSource,
} from '@superset-ui/core';
describe('CategoricalColorScale', () => {
beforeEach(() => {
@@ -43,7 +47,6 @@ describe('CategoricalColorScale', () => {
expect(scale).toBeInstanceOf(CategoricalColorScale);
expect(scale.forcedColors).toBe(forcedColors);
});
it('can refer to colors based on their index', () => {
const forcedColors = { pig: 1, horse: 5 };
const scale = new CategoricalColorScale(
@@ -67,7 +70,7 @@ describe('CategoricalColorScale', () => {
>;
let getNextAvailableColorSpy: jest.SpyInstance<
string,
[currentColor: string]
[currentLabel: string, currentColor: string]
>;
beforeEach(() => {
@@ -83,6 +86,36 @@ describe('CategoricalColorScale', () => {
jest.restoreAllMocks();
});
it('uses labelsColorMapInstance color map when source is Dashboard, otherwise uses chartLabelsColorMap', () => {
const sliceId = 123;
const colorScheme = 'preset';
// Mock chartLabelsColorMap and labelsColorMapInstance's getColorMap
const chartColorMap = new Map([['testValueChart', 'chartColor']]);
const dashboardColorMap = new Map([['testValueDash', 'dashboardColor']]);
scale.chartLabelsColorMap = chartColorMap;
jest
.spyOn(scale.labelsColorMapInstance, 'getColorMap')
.mockReturnValue(dashboardColorMap);
// Test when source is Dashboard
scale.labelsColorMapInstance.source = LabelsColorMapSource.Dashboard;
const colorFromDashboard = scale.getColor(
'testValueDash',
sliceId,
colorScheme,
);
expect(colorFromDashboard).toBe('dashboardColor');
// Test when source is not Dashboard
scale.labelsColorMapInstance.source = LabelsColorMapSource.Explore;
const colorFromChart = scale.getColor(
'testValueChart',
sliceId,
colorScheme,
);
expect(colorFromChart).toBe('chartColor');
});
it('returns same color for same value', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green'], {
pig: 'red',
@@ -177,7 +210,10 @@ describe('CategoricalColorScale', () => {
scale.getColor('testValue3');
scale.getColor('testValue4');
expect(getNextAvailableColorSpy).toHaveBeenCalledWith('blue');
expect(getNextAvailableColorSpy).toHaveBeenCalledWith(
'testValue4',
'blue',
);
getNextAvailableColorSpy.mockClear();
@@ -289,23 +325,25 @@ describe('CategoricalColorScale', () => {
});
});
describe('.getNextAvailableColor(currentColor)', () => {
describe('.getNextAvailableColor(currentLabel, currentColor)', () => {
it('returns the current color if it is the least used or equally used among colors', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
scale.getColor('cat');
scale.getColor('dog');
// Since 'green' hasn't been used, it's considered the least used.
expect(scale.getNextAvailableColor('blue')).toBe('green');
expect(scale.getNextAvailableColor('fish', 'blue')).toBe('green');
});
it('handles cases where all colors are equally used and returns the current color', () => {
it('returns the least used color among all', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
scale.getColor('cat'); // blue
scale.getColor('dog'); // red
scale.getColor('fish'); // green
// All colors used once, so the function should return the current color
expect(scale.getNextAvailableColor('red')).toBe('red');
scale.getColor('puppy'); // blue
scale.getColor('teddy'); // red
// All colors used, so the function should return least used
expect(scale.getNextAvailableColor('darling', 'red')).toBe('green');
});
it('returns the least used color accurately even when some colors are used more frequently', () => {
@@ -324,7 +362,57 @@ describe('CategoricalColorScale', () => {
scale.getColor('pony'); // green
// Yellow is the least used color, so it should be returned.
expect(scale.getNextAvailableColor('blue')).toBe('yellow');
expect(scale.getNextAvailableColor('pony', 'blue')).toBe('yellow');
});
it('does not return adjacent colors if a non-adjacent color is equally used', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
scale.chartLabelsColorMap.set('label1', 'red'); // Adjacent
scale.chartLabelsColorMap.set('label2', 'blue'); // currentLabel
scale.chartLabelsColorMap.set('label3', 'green'); // Adjacent
// Green and blue are equally used, but green is adjacent and penalized.
expect(scale.getNextAvailableColor('label2', 'blue')).toBe('blue');
});
it('prioritizes a color that has never been used, even if there are adjacent colors', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
scale.getColor('cat'); // blue
scale.getColor('dog'); // red
scale.chartLabelsColorMap.set('label1', 'red');
scale.chartLabelsColorMap.set('label2', 'blue'); // currentLabel
// Green has never been used, so it is prioritized.
expect(scale.getNextAvailableColor('label2', 'blue')).toBe('green');
});
it('returns the least used or unused color when there are no adjacent labels', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
scale.getColor('cat'); // blue
scale.getColor('dog'); // red
// No adjacent labels are defined in chartLabelsColorMap.
expect(scale.getNextAvailableColor('label2', 'green')).toBe('green');
});
it('handles colors that have never been used (fallback to usage count 0)', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
// Do not use "green" at all
scale.getColor('cat'); // blue
scale.getColor('dog'); // red
// "green" has never been used, so usageCount for "green" should fallback to 0
expect(scale.getNextAvailableColor('label2', 'red')).toBe('green');
});
it('handles a color with an explicit usage count of 0', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
// Mock or override getColorUsageCount to return 0 for "blue"
jest.spyOn(scale, 'getColorUsageCount').mockImplementation(color => {
if (color === 'blue') return 0; // Explicitly return 0 for "blue"
return 1; // Return 1 for other colors
});
// "blue" should still be a valid option with a usage count of 0
expect(scale.getNextAvailableColor('label1', 'red')).toBe('blue');
});
});
@@ -53,7 +53,7 @@ describe('LabelsColorMap', () => {
beforeEach(() => {
getLabelsColorMap().source = LabelsColorMapSource.Dashboard;
getLabelsColorMap().clear();
getLabelsColorMap().reset();
});
it('has default value out-of-the-box', () => {
@@ -92,11 +92,17 @@ describe('LabelsColorMap', () => {
expect(Object.fromEntries(colorMap)).toEqual({ b: 'green' });
});
it('should do nothing when source is not dashboard', () => {
it('should set a new color only when source is dashboard', () => {
const labelsColorMap = getLabelsColorMap();
labelsColorMap.source = LabelsColorMapSource.Explore;
labelsColorMap.addSlice('a', 'red', 1);
expect(Object.fromEntries(labelsColorMap.chartsLabelsMap)).toEqual({});
const colorMap = labelsColorMap.getColorMap();
expect(Object.fromEntries(colorMap)).toEqual({});
labelsColorMap.source = LabelsColorMapSource.Dashboard;
labelsColorMap.addSlice('a', 'red', 1);
const colorMap2 = labelsColorMap.getColorMap();
expect(Object.fromEntries(colorMap2)).toEqual({ a: 'red' });
});
});
@@ -126,7 +132,7 @@ describe('LabelsColorMap', () => {
});
});
describe('.updateColorMap(namespace, scheme)', () => {
describe('.updateColorMap(namespace, scheme, merge)', () => {
let categoricalNamespace: any;
let mockedNamespace: any;
let labelsColorMap: any;
@@ -141,18 +147,24 @@ describe('LabelsColorMap', () => {
};
});
it('should clear color map when not merge', () => {
labelsColorMap.addSlice('a', 'red', 1);
labelsColorMap.updateColorMap(mockedNamespace, 'testColors2', false);
expect(labelsColorMap.colorMap).toEqual(new Map([['a', 'mockColor']]));
});
it('should not clear color map when merge', () => {
labelsColorMap.addSlice('a', 'red', 1);
labelsColorMap.updateColorMap(mockedNamespace, 'testColors2', true);
expect(labelsColorMap.colorMap).not.toEqual(new Map());
});
it('should use provided color scheme', () => {
labelsColorMap.addSlice('a', 'red', 1);
labelsColorMap.updateColorMap(mockedNamespace, 'testColors2');
expect(mockedNamespace.getScale).toHaveBeenCalledWith('testColors2');
});
it('should fallback to original chart color scheme if no color scheme is provided', () => {
labelsColorMap.addSlice('a', 'red', 1, 'originalScheme');
labelsColorMap.updateColorMap(mockedNamespace);
expect(mockedNamespace.getScale).toHaveBeenCalledWith('originalScheme');
});
it('should fallback to undefined if no color scheme is provided', () => {
labelsColorMap.addSlice('a', 'red', 1);
labelsColorMap.addSlice('b', 'blue', 2);
@@ -181,6 +193,23 @@ describe('LabelsColorMap', () => {
});
});
it('should update only new labels in the color map when merge', () => {
labelsColorMap.colorMap = new Map();
labelsColorMap.addSlice('a', 'yellow', 1);
labelsColorMap.addSlice('b', 'green', 1);
labelsColorMap.addSlice('c', 'purple', 1);
labelsColorMap.updateColorMap(categoricalNamespace, 'testColors2', true);
const mergedColorMap = labelsColorMap.getColorMap();
expect(Object.fromEntries(mergedColorMap)).toEqual({
a: 'yellow',
b: 'green',
c: 'purple',
});
});
it('should use recycle colors', () => {
window.featureFlags = {
[FeatureFlag.UseAnalagousColors]: false,
@@ -231,4 +260,47 @@ describe('LabelsColorMap', () => {
expect(Object.fromEntries(colorMap)).toEqual({});
});
});
describe('setOwnColorScheme(sliceId, ownScheme)', () => {
it('should update the scheme in the config', () => {
const labelsColorMap = getLabelsColorMap();
labelsColorMap.source = LabelsColorMapSource.Explore;
const sliceId = 1;
const initialConfig = { labels: ['initial config'] };
labelsColorMap.chartsLabelsMap = new Map();
labelsColorMap.chartsLabelsMap.set(sliceId, initialConfig);
labelsColorMap.setOwnColorScheme(sliceId, 'newScheme');
expect(labelsColorMap.chartsLabelsMap.get(sliceId)).toEqual({
...initialConfig,
ownScheme: 'newScheme',
});
});
it('should update ownScheme when source is not Explore', () => {
const labelsColorMap = getLabelsColorMap();
labelsColorMap.source = LabelsColorMapSource.Dashboard;
const sliceId = 1;
const initialConfig = { labels: ['initial config'] };
labelsColorMap.chartsLabelsMap = new Map();
labelsColorMap.chartsLabelsMap.set(sliceId, initialConfig);
labelsColorMap.setOwnColorScheme(sliceId, 'newScheme');
expect(labelsColorMap.chartsLabelsMap.get(sliceId)).toEqual({
...initialConfig,
ownScheme: 'newScheme',
});
});
it('should do nothing when chart config does not exist', () => {
const labelsColorMap = getLabelsColorMap();
labelsColorMap.source = LabelsColorMapSource.Explore;
const sliceId = 1;
labelsColorMap.setOwnColorScheme(sliceId, 'newScheme');
expect(labelsColorMap.chartsLabelsMap.get(sliceId)).toEqual(undefined);
});
});
});
@@ -93,7 +93,7 @@ function Chord(element, props) {
.append('path')
.attr('id', (d, i) => `group${i}`)
.attr('d', arc)
.style('fill', (d, i) => colorFn(nodes[i], sliceId, colorScheme));
.style('fill', (d, i) => colorFn(nodes[i], sliceId));
// Add a text label.
const groupText = group.append('text').attr('x', 6).attr('dy', 15);
@@ -121,7 +121,7 @@ function Chord(element, props) {
.on('mouseover', d => {
chord.classed('fade', p => p !== d);
})
.style('fill', d => colorFn(nodes[d.source.index], sliceId, colorScheme))
.style('fill', d => colorFn(nodes[d.source.index], sliceId))
.attr('d', path);
// Add an elaborate mouseover title for each chord.
@@ -37,6 +37,7 @@ const propTypes = {
width: PropTypes.number,
height: PropTypes.number,
country: PropTypes.string,
colorScheme: PropTypes.string,
linearColorScheme: PropTypes.string,
mapBaseUrl: PropTypes.string,
numberFormat: PropTypes.string,
@@ -73,12 +73,11 @@ class CustomHistogram extends PureComponent {
showLegend,
sliceId,
} = this.props;
const colorFn = CategoricalColorNamespace.getScale(colorScheme);
const keys = data.map(d => d.key);
const colorScale = scaleOrdinal({
domain: keys,
range: keys.map(x => colorFn(x, sliceId, colorScheme)),
range: keys.map(x => colorFn(x, sliceId)),
});
return (
@@ -384,7 +384,7 @@ function Icicle(element, props) {
// Apply color scheme
g.selectAll('rect').style('fill', d => {
d.color = colorFn(d.name, sliceId, colorScheme);
d.color = colorFn(d.name, sliceId);
return d.color;
});
@@ -46,6 +46,7 @@ const propTypes = {
numberFormat: PropTypes.string,
useRichTooltip: PropTypes.bool,
useAreaProportions: PropTypes.bool,
colorScheme: PropTypes.string,
};
function copyArc(d) {
@@ -120,14 +121,14 @@ function Rose(element, props) {
.map(v => ({
key: v.name,
value: v.value,
color: colorFn(v.name, sliceId, colorScheme),
color: colorFn(v.name, sliceId),
highlight: v.id === d.arcId,
}))
: [
{
key: d.name,
value: d.val,
color: colorFn(d.name, sliceId, colorScheme),
color: colorFn(d.name, sliceId),
},
];
@@ -138,7 +139,7 @@ function Rose(element, props) {
};
}
legend.width(width).color(d => colorFn(d.key, sliceId, colorScheme));
legend.width(width).color(d => colorFn(d.key, sliceId));
legendWrap.datum(legendData(datum)).call(legend);
tooltip.headerFormatter(timeFormat).valueFormatter(format);
@@ -385,7 +386,7 @@ function Rose(element, props) {
const arcs = ae
.append('path')
.attr('class', 'arc')
.attr('fill', d => colorFn(d.name, sliceId, colorScheme))
.attr('fill', d => colorFn(d.name, sliceId))
.attr('d', arc);
function mousemove() {
@@ -83,7 +83,7 @@ function computeGraph(links) {
function SankeyLoop(element, props) {
const { data, width, height, colorScheme, sliceId } = props;
const color = CategoricalColorNamespace.getScale(colorScheme);
const colorFn = CategoricalColorNamespace.getScale(colorScheme);
const margin = { ...defaultMargin, ...props.margin };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
@@ -107,7 +107,7 @@ function SankeyLoop(element, props) {
value / sValue,
)})`,
)
.linkColor(d => color(d.source.name, sliceId));
.linkColor(d => colorFn(d.source.name, sliceId));
const div = select(element);
div.selectAll('*').remove();
@@ -67,7 +67,6 @@ function Sankey(element, props) {
.attr('height', innerHeight + margin.top + margin.bottom)
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
const colorFn = CategoricalColorNamespace.getScale(colorScheme);
const sankey = d3Sankey()
@@ -219,7 +218,7 @@ function Sankey(element, props) {
.attr('width', sankey.nodeWidth())
.style('fill', d => {
const name = d.name || 'N/A';
d.color = colorFn(name, sliceId, colorScheme);
d.color = colorFn(name, sliceId);
return d.color;
})
@@ -43,6 +43,7 @@ const propTypes = {
showBubbles: PropTypes.bool,
linearColorScheme: PropTypes.string,
color: PropTypes.string,
colorScheme: PropTypes.string,
setDataMask: PropTypes.func,
onContextMenu: PropTypes.func,
emitCrossFilters: PropTypes.bool,
@@ -85,24 +86,24 @@ function WorldMap(element, props) {
.range([1, maxBubbleSize]);
let processedData;
let colorScale;
let colorFn;
if (colorBy === ColorBy.Country) {
colorScale = CategoricalColorNamespace.getScale(colorScheme);
colorFn = CategoricalColorNamespace.getScale(colorScheme);
processedData = filteredData.map(d => ({
...d,
radius: radiusScale(Math.sqrt(d.m2)),
fillColor: colorScale(d.name, sliceId),
fillColor: colorFn(d.name, sliceId),
}));
} else {
colorScale = getSequentialSchemeRegistry()
colorFn = getSequentialSchemeRegistry()
.get(linearColorScheme)
.createLinearScale(d3Extent(filteredData, d => d.m1));
processedData = filteredData.map(d => ({
...d,
radius: radiusScale(Math.sqrt(d.m2)),
fillColor: colorScale(d.m1),
fillColor: colorFn(d.m1),
}));
}
@@ -52,16 +52,14 @@ const { getScale } = CategoricalColorNamespace;
function getCategories(fd: QueryFormData, data: JsonObject[]) {
const c = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
const fixedColor = [c.r, c.g, c.b, 255 * c.a];
const colorFn = getScale(fd.color_scheme);
const appliedScheme = fd.color_scheme;
const colorFn = getScale(appliedScheme);
const categories = {};
data.forEach(d => {
if (d.cat_color != null && !categories.hasOwnProperty(d.cat_color)) {
let color;
if (fd.dimension) {
color = hexToRGB(
colorFn(d.cat_color, fd.sliceId, fd.color_scheme),
c.a * 255,
);
color = hexToRGB(colorFn(d.cat_color, fd.sliceId), c.a * 255);
} else {
color = fixedColor;
}
@@ -132,15 +130,13 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
const addColor = useCallback((data: JsonObject[], fd: QueryFormData) => {
const c = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
const colorFn = getScale(fd.color_scheme);
const appliedScheme = fd.color_scheme;
const colorFn = getScale(appliedScheme);
return data.map(d => {
let color;
if (fd.dimension) {
color = hexToRGB(
colorFn(d.cat_color, fd.sliceId, fd.color_scheme),
c.a * 255,
);
color = hexToRGB(colorFn(d.cat_color, fd.sliceId), c.a * 255);
return { ...d, color };
}
@@ -55,7 +55,8 @@ export function getLayer(
setTooltip: (tooltip: TooltipProps['tooltip']) => void,
) {
const fd = formData;
const colorScale = CategoricalColorNamespace.getScale(fd.color_scheme);
const appliedScheme = fd.color_scheme;
const colorScale = CategoricalColorNamespace.getScale(appliedScheme);
const colorRange = colorScale
.range()
.map(color => hexToRGB(color)) as Color[];
@@ -54,7 +54,8 @@ export function getLayer(
setTooltip: (tooltip: TooltipProps['tooltip']) => void,
) {
const fd = formData;
const colorScale = CategoricalColorNamespace.getScale(fd.color_scheme);
const appliedScheme = fd.color_scheme;
const colorScale = CategoricalColorNamespace.getScale(appliedScheme);
const colorRange = colorScale
.range()
.map(color => hexToRGB(color)) as Color[];
@@ -658,9 +658,7 @@ function nvd3Vis(element, props) {
} else if (vizType !== 'bullet') {
const colorFn = getScale(colorScheme);
chart.color(
d =>
d.color ||
colorFn(cleanColorInput(d[colorKey]), sliceId, colorScheme),
d => d.color || colorFn(cleanColorInput(d[colorKey]), sliceId),
);
}
@@ -109,9 +109,9 @@ export default function transformProps(
datum[`${metric}__outliers`],
],
itemStyle: {
color: colorFn(groupbyLabel, sliceId, colorScheme),
color: colorFn(groupbyLabel, sliceId),
opacity: isFiltered ? OpacityEnum.SemiTransparent : 0.6,
borderColor: colorFn(groupbyLabel, sliceId, colorScheme),
borderColor: colorFn(groupbyLabel, sliceId),
},
};
});
@@ -150,7 +150,7 @@ export default function transformProps(
},
},
itemStyle: {
color: colorFn(groupbyLabel, sliceId, colorScheme),
color: colorFn(groupbyLabel, sliceId),
opacity: isFiltered
? OpacityEnum.SemiTransparent
: OpacityEnum.NonTransparent,
@@ -108,8 +108,8 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) {
legendOrientation,
legendMargin,
legendType,
sliceId,
}: EchartsBubbleFormData = { ...DEFAULT_FORM_DATA, ...formData };
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
const legends = new Set<string>();
@@ -138,7 +138,10 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) {
],
],
type: 'scatter',
itemStyle: { color: colorFn(name), opacity },
itemStyle: {
color: colorFn(name, sliceId),
opacity,
},
});
legends.add(name);
});
@@ -145,7 +145,6 @@ export default function transformProps(
}, {});
const { setDataMask = () => {}, onContextMenu } = hooks;
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
const numberFormatter = getValueFormatter(
metric,
@@ -175,7 +174,7 @@ export default function transformProps(
value,
name,
itemStyle: {
color: colorFn(name, sliceId, colorScheme),
color: colorFn(name, sliceId),
opacity: isFiltered
? OpacityEnum.SemiTransparent
: OpacityEnum.NonTransparent,
@@ -166,6 +166,7 @@ export default function transformProps(
const name = groupbyLabels
.map(column => `${verboseMap[column] || column}: ${data_point[column]}`)
.join(', ');
const colorLabel = groupbyLabels.map(col => data_point[col] as string);
columnsLabelMap.set(
name,
groupbyLabels.map(col => data_point[col] as string),
@@ -174,7 +175,7 @@ export default function transformProps(
value: data_point[metricLabel] as number,
name,
itemStyle: {
color: colorFn(index, sliceId, colorScheme),
color: colorFn(colorLabel, sliceId),
},
title: {
offsetCenter: [
@@ -202,7 +203,7 @@ export default function transformProps(
item = {
...item,
itemStyle: {
color: colorFn(index, sliceId, colorScheme),
color: colorFn(index, sliceId),
opacity: OpacityEnum.SemiTransparent,
},
detail: {
@@ -297,14 +297,15 @@ export default function transformProps(
});
const categoryList = [...categories];
const series: GraphSeriesOption[] = [
{
zoom: DEFAULT_GRAPH_SERIES_OPTION.zoom,
type: 'graph',
categories: categoryList.map(c => ({
name: c,
itemStyle: { color: colorFn(c, sliceId, colorScheme) },
itemStyle: {
color: colorFn(c, sliceId),
},
})),
layout,
force: {
@@ -191,7 +191,6 @@ export default function transformProps(
}, {});
const { setDataMask = () => {}, onContextMenu } = hooks;
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
const numberFormatter = getValueFormatter(
metric,
@@ -223,7 +222,7 @@ export default function transformProps(
value,
name,
itemStyle: {
color: colorFn(name, sliceId, colorScheme),
color: colorFn(name, sliceId),
opacity: isFiltered
? OpacityEnum.SemiTransparent
: OpacityEnum.NonTransparent,
@@ -109,7 +109,6 @@ export default function transformProps(
...formData,
};
const { setDataMask = () => {}, onContextMenu } = hooks;
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
const numberFormatter = getNumberFormatter(numberFormat);
const formatter = (params: CallbackDataParams) =>
@@ -182,7 +181,7 @@ export default function transformProps(
value: metricLabels.map(metricLabel => datum[metricLabel]),
name: joinedName,
itemStyle: {
color: colorFn(joinedName, sliceId, colorScheme),
color: colorFn(joinedName, sliceId),
opacity: isFiltered
? OpacityEnum.Transparent
: OpacityEnum.NonTransparent,
@@ -41,7 +41,7 @@ export default function transformProps(
const refs: Refs = {};
const { formData, height, hooks, queriesData, width } = chartProps;
const { onLegendStateChanged } = hooks;
const { colorScheme, metric, source, target } = formData;
const { colorScheme, metric, source, target, sliceId } = formData;
const { data } = queriesData[0];
const colorFn = CategoricalColorNamespace.getScale(colorScheme);
const metricLabel = getMetricLabel(metric);
@@ -68,7 +68,7 @@ export default function transformProps(
).map(name => ({
name,
itemStyle: {
color: colorFn(name),
color: colorFn(name, sliceId),
},
}));
@@ -202,7 +202,6 @@ export default function transformProps(
}
return { ...acc, [entry[0]]: entry[1] };
}, {});
const colorScale = CategoricalColorNamespace.getScale(colorScheme as string);
const rebasedData = rebaseForecastDatum(data, verboseMap);
let xAxisLabel = getXAxisLabel(chartProps.rawFormData) as string;
@@ -176,18 +176,18 @@ export default function transformProps(
let item: TreemapSeriesNodeItemOption = {
name,
value,
colorSaturation: COLOR_SATURATION,
itemStyle: {
borderColor: BORDER_COLOR,
color: colorFn(name, sliceId),
borderWidth: BORDER_WIDTH,
gapWidth: GAP_WIDTH,
},
};
if (treeNode.children?.length) {
item = {
...item,
children: traverse(treeNode.children, newPath),
colorSaturation: COLOR_SATURATION,
itemStyle: {
borderColor: BORDER_COLOR,
color: colorFn(name, sliceId, colorScheme),
borderWidth: BORDER_WIDTH,
gapWidth: GAP_WIDTH,
},
};
} else {
const joinedName = newPath.join(',');
@@ -217,7 +217,7 @@ export default function transformProps(
colorSaturation: COLOR_SATURATION,
itemStyle: {
borderColor: BORDER_COLOR,
color: colorFn(`${metricLabel}`, sliceId, colorScheme),
color: colorFn(`${metricLabel}`, sliceId),
borderWidth: BORDER_WIDTH,
gapWidth: GAP_WIDTH,
},
@@ -29,7 +29,7 @@ import {
SupersetThemeProps,
withTheme,
seed,
CategoricalColorScale,
CategoricalColorNamespace,
} from '@superset-ui/core';
import { isEqual } from 'lodash';
@@ -230,7 +230,7 @@ class WordCloud extends PureComponent<FullWordCloudProps, WordCloudState> {
encoder.channels.color.setDomainFromDataset(words);
const { getValueFromDatum } = encoder.channels.color;
const colorFn = encoder.channels.color.scale as CategoricalColorScale;
const colorFn = CategoricalColorNamespace.getScale(colorScheme);
const viewBoxWidth = width * scaleFactor;
const viewBoxHeight = height * scaleFactor;
@@ -250,11 +250,7 @@ class WordCloud extends PureComponent<FullWordCloudProps, WordCloudState> {
fontSize={`${w.size}px`}
fontWeight={w.weight}
fontFamily={w.font}
fill={colorFn(
getValueFromDatum(w) as string,
sliceId,
colorScheme,
)}
fill={colorFn(getValueFromDatum(w) as string, sliceId)}
textAnchor="middle"
transform={`translate(${w.x}, ${w.y}) rotate(${w.rotate})`}
>
+1 -4
View File
@@ -26,10 +26,7 @@ export const getDashboardFormData = (overrides: JsonObject = {}) => ({
girl: '#FF69B4',
boy: '#ADD8E6',
},
shared_label_colors: {
boy: '#ADD8E6',
girl: '#FF69B4',
},
shared_label_colors: ['boy', 'girl'],
color_scheme: 'd3Category20b',
extra_filters: [
{
+1 -1
View File
@@ -113,6 +113,6 @@ export const overwriteConfirmMetadata = {
slug: null,
owners: [],
json_metadata:
'{"timed_refresh_immune_slices":[],"expanded_slices":{},"refresh_frequency":0,"default_filters":"{}","color_scheme":"supersetColors","label_colors":{"0":"#FCC700","1":"#A868B7","15":"#3CCCCB","30":"#A38F79","45":"#8FD3E4","age":"#1FA8C9","Yes,":"#1FA8C9","Female":"#454E7C","Prefer":"#5AC189","No,":"#FF7F44","Male":"#666666","Prefer not to say":"#E04355","Ph.D.":"#FCC700","associate\'s degree":"#A868B7","bachelor\'s degree":"#3CCCCB","high school diploma or equivalent (GED)":"#A38F79","master\'s degree (non-professional)":"#8FD3E4","no high school (secondary school)":"#A1A6BD","professional degree (MBA, MD, JD, etc.)":"#ACE1C4","some college credit, no degree":"#FEC0A1","some high school":"#B2B2B2","trade, technical, or vocational training":"#EFA1AA","No, not an ethnic minority":"#1FA8C9","Yes, an ethnic minority":"#454E7C","<NULL>":"#5AC189","Yes":"#FF7F44","No":"#666666","last_yr_income":"#E04355","More":"#A1A6BD","Less":"#ACE1C4","I":"#FEC0A1","expected_earn":"#B2B2B2","Yes: Willing To":"#EFA1AA","No: Not Willing to":"#FDE380","No Answer":"#D3B3DA","In an Office (with Other Developers)":"#9EE5E5","No Preference":"#D1C6BC","From Home":"#1FA8C9"},"color_scheme_domain":["#1FA8C9","#454E7C","#5AC189","#FF7F44","#666666","#E04355","#FCC700","#A868B7","#3CCCCB","#A38F79","#8FD3E4","#A1A6BD","#ACE1C4","#FEC0A1","#B2B2B2","#EFA1AA","#FDE380","#D3B3DA","#9EE5E5","#D1C6BC"],"shared_label_colors":{"Male":"#5ac19e","Female":"#1f86c9","<NULL>":"#5AC189","Prefer not to say":"#47457c","No Answer":"#e05043","Yes, an ethnic minority":"#666666","No, not an ethnic minority":"#ffa444","age":"#1FA8C9"},"cross_filters_enabled":false,"filter_scopes":{},"chart_configuration":{},"positions":{}}',
'{"timed_refresh_immune_slices":[],"expanded_slices":{},"refresh_frequency":0,"default_filters":"{}","color_scheme":"supersetColors","label_colors":{"0":"#FCC700","1":"#A868B7","15":"#3CCCCB","30":"#A38F79","45":"#8FD3E4","age":"#1FA8C9","Yes,":"#1FA8C9","Female":"#454E7C","Prefer":"#5AC189","No,":"#FF7F44","Male":"#666666","Prefer not to say":"#E04355","Ph.D.":"#FCC700","associate\'s degree":"#A868B7","bachelor\'s degree":"#3CCCCB","high school diploma or equivalent (GED)":"#A38F79","master\'s degree (non-professional)":"#8FD3E4","no high school (secondary school)":"#A1A6BD","professional degree (MBA, MD, JD, etc.)":"#ACE1C4","some college credit, no degree":"#FEC0A1","some high school":"#B2B2B2","trade, technical, or vocational training":"#EFA1AA","No, not an ethnic minority":"#1FA8C9","Yes, an ethnic minority":"#454E7C","<NULL>":"#5AC189","Yes":"#FF7F44","No":"#666666","last_yr_income":"#E04355","More":"#A1A6BD","Less":"#ACE1C4","I":"#FEC0A1","expected_earn":"#B2B2B2","Yes: Willing To":"#EFA1AA","No: Not Willing to":"#FDE380","No Answer":"#D3B3DA","In an Office (with Other Developers)":"#9EE5E5","No Preference":"#D1C6BC","From Home":"#1FA8C9"},"color_scheme_domain":["#1FA8C9","#454E7C","#5AC189","#FF7F44","#666666","#E04355","#FCC700","#A868B7","#3CCCCB","#A38F79","#8FD3E4","#A1A6BD","#ACE1C4","#FEC0A1","#B2B2B2","#EFA1AA","#FDE380","#D3B3DA","#9EE5E5","#D1C6BC"],"shared_label_colors":["Male", "Female","<NULL>","Prefer not to say","No Answer","Yes, an ethnic minority","No, not an ethnic minority","age"],"cross_filters_enabled":false,"filter_scopes":{},"chart_configuration":{},"positions":{}}',
},
};
@@ -55,6 +55,7 @@ import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
import { safeStringify } from 'src/utils/safeStringify';
import { logEvent } from 'src/logger/actions';
import { LOG_ACTIONS_CONFIRM_OVERWRITE_DASHBOARD_METADATA } from 'src/logger/LogUtils';
import { isEqual } from 'lodash';
import { UPDATE_COMPONENTS_PARENTS_LIST } from './dashboardLayout';
import {
saveChartConfiguration,
@@ -68,9 +69,10 @@ import getOverwriteItems from '../util/getOverwriteItems';
import {
applyColors,
isLabelsColorMapSynced,
getLabelsColorMapEntries,
getColorSchemeDomain,
getColorNamespace,
getLabelsColorMapEntries,
getFreshSharedLabels,
} from '../../utils/colorScheme';
export const SET_UNSAVED_CHANGES = 'SET_UNSAVED_CHANGES';
@@ -224,6 +226,41 @@ export function saveDashboardFinished() {
return { type: SAVE_DASHBOARD_FINISHED };
}
export const SET_DASHBOARD_LABELS_COLORMAP_SYNCABLE =
'SET_DASHBOARD_LABELS_COLORMAP_SYNCABLE';
export const SET_DASHBOARD_LABELS_COLORMAP_SYNCED =
'SET_DASHBOARD_LABELS_COLORMAP_SYNCED';
export const SET_DASHBOARD_SHARED_LABELS_COLORS_SYNCABLE =
'SET_DASHBOARD_SHARED_LABELS_COLORS_SYNCABLE';
export const SET_DASHBOARD_SHARED_LABELS_COLORS_SYNCED =
'SET_DASHBOARD_SHARED_LABELS_COLORS_SYNCED';
export function setDashboardLabelsColorMapSync() {
return { type: SET_DASHBOARD_LABELS_COLORMAP_SYNCABLE };
}
export function setDashboardLabelsColorMapSynced() {
return { type: SET_DASHBOARD_LABELS_COLORMAP_SYNCED };
}
export function setDashboardSharedLabelsColorsSync() {
return { type: SET_DASHBOARD_SHARED_LABELS_COLORS_SYNCABLE };
}
export function setDashboardSharedLabelsColorsSynced() {
return { type: SET_DASHBOARD_SHARED_LABELS_COLORS_SYNCED };
}
export const setDashboardMetadata = updatedMetadata => async dispatch => {
dispatch(
dashboardInfoChanged({
metadata: {
...updatedMetadata,
},
}),
);
};
export function saveDashboardRequest(data, id, saveType) {
return (dispatch, getState) => {
dispatch({ type: UPDATE_COMPONENTS_PARENTS_LIST });
@@ -254,6 +291,9 @@ export function saveDashboardRequest(data, id, saveType) {
const hasId = item => item.id !== undefined;
const metadataCrossFiltersEnabled = data.metadata?.cross_filters_enabled;
const colorScheme = data.metadata?.color_scheme;
const customLabelsColor = data.metadata?.label_colors || {};
const sharedLabelsColor = data.metadata?.shared_label_colors || [];
// making sure the data is what the backend expects
const cleanedData = {
...data,
@@ -270,11 +310,14 @@ export function saveDashboardRequest(data, id, saveType) {
metadata: {
...data.metadata,
color_namespace: getColorNamespace(data.metadata?.color_namespace),
color_scheme: data.metadata?.color_scheme || '',
color_scheme_domain: data.metadata?.color_scheme_domain || [],
color_scheme: colorScheme || '',
color_scheme_domain: colorScheme
? getColorSchemeDomain(colorScheme)
: [],
expanded_slices: data.metadata?.expanded_slices || {},
label_colors: data.metadata?.label_colors || {},
shared_label_colors: data.metadata?.shared_label_colors || {},
label_colors: customLabelsColor,
shared_label_colors: getFreshSharedLabels(sharedLabelsColor),
map_label_colors: getLabelsColorMapEntries(customLabelsColor),
refresh_frequency: data.metadata?.refresh_frequency || 0,
timed_refresh_immune_slices:
data.metadata?.timed_refresh_immune_slices || [],
@@ -324,11 +367,7 @@ export function saveDashboardRequest(data, id, saveType) {
// syncing with the backend transformations of the metadata
if (updatedDashboard.json_metadata) {
const metadata = JSON.parse(updatedDashboard.json_metadata);
dispatch(
dashboardInfoChanged({
metadata,
}),
);
dispatch(setDashboardMetadata(metadata));
if (metadata.chart_configuration) {
dispatch({
type: SAVE_CHART_CONFIG_COMPLETE,
@@ -677,68 +716,261 @@ export function setDatasetsStatus(status) {
};
}
const updateDashboardMetadata = async (id, metadata, dispatch) => {
await SupersetClient.put({
const storeDashboardMetadata = async (id, metadata) =>
SupersetClient.put({
endpoint: `/api/v1/dashboard/${id}`,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ json_metadata: JSON.stringify(metadata) }),
});
dispatch(dashboardInfoChanged({ metadata }));
};
export const updateDashboardLabelsColor = () => async (dispatch, getState) => {
/**
*
* Persists the label colors maps in the dashboard metadata.
* It runs when outdated color info are detected in stored metadata.
*
* @returns void
*/
export const persistDashboardLabelsColor = () => async (dispatch, getState) => {
const {
dashboardInfo: { id, metadata },
dashboardState: { labelsColorMapMustSync, sharedLabelsColorsMustSync },
} = getState();
const categoricalSchemes = getCategoricalSchemeRegistry();
const colorScheme = metadata?.color_scheme;
const colorSchemeRegistry = categoricalSchemes.get(
metadata?.color_scheme,
true,
);
const defaultScheme = categoricalSchemes.defaultKey;
const fallbackScheme = defaultScheme?.toString() || 'supersetColors';
const colorSchemeDomain = metadata?.color_scheme_domain || [];
if (labelsColorMapMustSync || sharedLabelsColorsMustSync) {
storeDashboardMetadata(id, metadata);
dispatch(setDashboardLabelsColorMapSynced());
dispatch(setDashboardSharedLabelsColorsSynced());
}
};
/**
* Checks the stored dashboard metadata for inconsistencies.
* Update the current metadata with validated color information.
* It runs only on Dashboard page load.
*
* @param {*} metadata - the stored dashboard metadata
* @returns void
*/
export const applyDashboardLabelsColorOnLoad = metadata => async dispatch => {
try {
const updatedMetadata = { ...metadata };
let updatedScheme = metadata?.color_scheme;
const customLabelsColor = metadata.label_colors || {};
const sharedLabelsColor = metadata.shared_label_colors || [];
let hasChanged = false;
// backward compatibility of shared_label_colors
const sharedLabels = metadata.shared_label_colors || [];
if (!Array.isArray(sharedLabels) && Object.keys(sharedLabels).length > 0) {
hasChanged = true;
updatedMetadata.shared_label_colors = getFreshSharedLabels(
Object.keys(sharedLabelsColor),
);
}
// backward compatibility of map_label_colors
const hasMapLabelColors =
Object.keys(metadata.map_label_colors || {}).length > 0;
let updatedScheme = metadata.color_scheme;
const categoricalSchemes = getCategoricalSchemeRegistry();
const colorSchemeRegistry = categoricalSchemes.get(updatedScheme, true);
const hasInvalidColorScheme = !!updatedScheme && !colorSchemeRegistry;
// color scheme might not exist any longer
if (hasInvalidColorScheme) {
const defaultScheme = categoricalSchemes.defaultKey;
const fallbackScheme = defaultScheme?.toString() || 'supersetColors';
hasChanged = true;
// Color scheme does not exist anymore, fallback to default
if (colorScheme && !colorSchemeRegistry) {
updatedScheme = fallbackScheme;
updatedMetadata.color_scheme = updatedScheme;
updatedMetadata.color_scheme_domain = getColorSchemeDomain(colorScheme);
dispatch(setColorScheme(updatedScheme));
// must re-apply colors from fresh labels color map
applyColors(updatedMetadata, true);
}
// stored labels color map and applied might differ
const isMapSynced = isLabelsColorMapSynced(metadata);
if (!isMapSynced) {
// re-apply a fresh labels color map
applyColors(updatedMetadata, true);
// pull and store the just applied labels color map
updatedMetadata.shared_label_colors = getLabelsColorMapEntries();
}
// the stored color domain registry and fresh might differ at this point
const freshColorSchemeDomain = getColorSchemeDomain(colorScheme);
const isRegistrySynced =
colorSchemeDomain.toString() !== freshColorSchemeDomain.toString();
if (colorScheme && !isRegistrySynced) {
const freshColorSchemeDomain = updatedScheme
? getColorSchemeDomain(updatedScheme)
: [];
const currentColorSchemeDomain = metadata.color_scheme_domain || [];
if (!isEqual(freshColorSchemeDomain, currentColorSchemeDomain)) {
hasChanged = true;
updatedMetadata.color_scheme_domain = freshColorSchemeDomain;
}
if (
(colorScheme && (!colorSchemeRegistry || !isRegistrySynced)) ||
!isMapSynced
) {
await updateDashboardMetadata(id, updatedMetadata, dispatch);
// if color scheme is invalid or map is missing, apply a fresh color map
// if valid, apply the stored map to keep consistency across refreshes
const shouldGoFresh = !hasMapLabelColors || hasInvalidColorScheme;
applyColors(updatedMetadata, shouldGoFresh);
if (shouldGoFresh) {
// a fresh color map has been applied
// needs to be stored for consistency
hasChanged = true;
updatedMetadata.map_label_colors =
getLabelsColorMapEntries(customLabelsColor);
}
} catch (error) {
console.error('Failed to update dashboard color settings:', error);
if (hasChanged) {
dispatch(setDashboardMetadata(updatedMetadata));
dispatch(setDashboardLabelsColorMapSync());
}
} catch (e) {
console.error('Failed to update dashboard color on load:', e);
}
};
/**
*
* Ensure that the stored color map matches fresh map.
*
* @param {*} metadata - the dashboard metadata
* @returns void
*/
export const ensureSyncedLabelsColorMap = metadata => (dispatch, getState) => {
const {
dashboardState: { labelsColorMapMustSync },
} = getState();
const updatedMetadata = { ...metadata };
const customLabelsColor = metadata.label_colors || {};
const isMapSynced = isLabelsColorMapSynced(metadata);
const mustSync = !isMapSynced;
if (mustSync) {
const freshestColorMapEntries = getLabelsColorMapEntries(customLabelsColor);
updatedMetadata.map_label_colors = freshestColorMapEntries;
dispatch(setDashboardMetadata(updatedMetadata));
}
if (mustSync && !labelsColorMapMustSync) {
// prepare to persist the just applied labels color map
dispatch(setDashboardLabelsColorMapSync());
}
if (!mustSync && labelsColorMapMustSync) {
dispatch(setDashboardLabelsColorMapSynced());
}
};
/**
*
* Ensure that the stored shared labels colors match current.
*
* @param {*} metadata - the dashboard metadata
* @returns void
*/
export const ensureSyncedSharedLabelsColors =
metadata => (dispatch, getState) => {
// using a timeout to let the rendered charts finish processing labels
setTimeout(() => {
const {
dashboardState: { sharedLabelsColorsMustSync },
} = getState();
const updatedMetadata = { ...metadata };
const sharedLabelsColors = metadata.shared_label_colors || [];
const freshLabelsColors = getFreshSharedLabels(sharedLabelsColors);
const isSharedLabelsColorsSynced = isEqual(
sharedLabelsColors,
freshLabelsColors,
);
const mustSync = !isSharedLabelsColorsSynced;
if (mustSync) {
updatedMetadata.shared_label_colors = freshLabelsColors;
dispatch(setDashboardMetadata(updatedMetadata));
}
if (mustSync && !sharedLabelsColorsMustSync) {
// prepare to persist the shared labels colors
dispatch(setDashboardSharedLabelsColorsSync());
}
if (!mustSync && sharedLabelsColorsMustSync) {
dispatch(setDashboardSharedLabelsColorsSynced());
}
}, 500);
};
/**
*
* Updates the color map with new labels and colors as they appear.
*
* @param {*} renderedChartIds - the charts that have finished rendering
* @returns void
*/
export const updateDashboardLabelsColor =
renderedChartIds => (dispatch, getState) => {
try {
const {
dashboardInfo: { metadata },
charts,
} = getState();
const colorScheme = metadata.color_scheme;
const labelsColorMapInstance = getLabelsColorMap();
const fullLabelsColors = metadata.map_label_colors || {};
const sharedLabelsColors = metadata.shared_label_colors || [];
const customLabelsColors = metadata.label_colors || {};
const updatedMetadata = { ...metadata };
// for dashboards with no color scheme, the charts should always use their individual schemes
// this logic looks for unique labels (not shared across multiple charts) of each rendered chart
// it applies a new color to those unique labels when the applied scheme is not up to date
// while leaving shared label colors and custom label colors intact for color consistency
const shouldReset = [];
if (renderedChartIds.length > 0) {
const sharedLabelsSet = new Set(sharedLabelsColors);
renderedChartIds.forEach(id => {
const chart = charts[id];
const formData = chart.form_data || chart.latestQueryFormData;
// ensure charts have their original color scheme always available
labelsColorMapInstance.setOwnColorScheme(
formData.slice_id,
formData.color_scheme,
);
// if dashboard has a scheme, charts should ignore individual schemes
// thus following logic is inapplicable if a dashboard color scheme exists
if (colorScheme) return;
const chartColorScheme = formData.color_scheme;
const currentChartConfig = labelsColorMapInstance.chartsLabelsMap.get(
formData.slice_id,
);
const currentChartLabels = currentChartConfig?.labels || [];
const uniqueChartLabels = currentChartLabels.filter(
l =>
!sharedLabelsSet.has(l) && !customLabelsColors.hasOwnProperty(l),
);
// Map unique labels to colors
const uniqueChartLabelsColor = new Set(
uniqueChartLabels.map(l => fullLabelsColors[l]).filter(Boolean),
);
const expectedColorsForChartScheme = new Set(
getColorSchemeDomain(chartColorScheme),
);
// Check if any unique label color is not in the expected colors set
const shouldResetColors = [...uniqueChartLabelsColor].some(
color => !expectedColorsForChartScheme.has(color),
);
// Only push uniqueChartLabels if they require resetting
if (shouldResetColors) shouldReset.push(...uniqueChartLabels);
});
}
// an existing map is available, use mrge option
// to only apply colors to newly found labels
const shouldGoFresh = shouldReset.length > 0 ? shouldReset : false;
const shouldMerge = !shouldGoFresh;
// re-apply the color map first to get fresh maps accordingly
applyColors(updatedMetadata, shouldGoFresh, shouldMerge);
// new data may have appeared in the map (data changes)
// or new slices may have appeared while changing tabs
dispatch(ensureSyncedLabelsColorMap(updatedMetadata));
dispatch(ensureSyncedSharedLabelsColors(updatedMetadata));
} catch (e) {
console.error('Failed to update colors for new charts and labels:', e);
}
};
@@ -36,6 +36,8 @@ import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
fetchMock.get('glob:*/csstemplateasyncmodelview/api/read', {});
fetchMock.put('glob:*/api/v1/dashboard/*', {});
jest.mock('src/dashboard/actions/dashboardState', () => ({
...jest.requireActual('src/dashboard/actions/dashboardState'),
fetchFaveStar: jest.fn(),
@@ -34,7 +34,7 @@ import { Global } from '@emotion/react';
import { useDispatch, useSelector } from 'react-redux';
import ErrorBoundary from 'src/components/ErrorBoundary';
import BuilderComponentPane from 'src/dashboard/components/BuilderComponentPane';
import DashboardHeader from 'src/dashboard/containers/DashboardHeader';
import DashboardHeader from 'src/dashboard/components/Header';
import Icons from 'src/components/Icons';
import IconButton from 'src/dashboard/components/IconButton';
import { Droppable } from 'src/dashboard/components/dnd/DragDroppable';
@@ -18,7 +18,7 @@
*/
// ParentSize uses resize observer so the dashboard will update size
// when its container size changes, due to e.g., builder side panel opening
import { FC, useEffect, useMemo, useRef } from 'react';
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
Filter,
@@ -43,12 +43,12 @@ import {
import { getChartIdsInFilterScope } from 'src/dashboard/util/getChartIdsInFilterScope';
import findTabIndexByComponentId from 'src/dashboard/util/findTabIndexByComponentId';
import { setInScopeStatusOfFilters } from 'src/dashboard/actions/nativeFilters';
import { updateDashboardLabelsColor } from 'src/dashboard/actions/dashboardState';
import {
applyColors,
getColorNamespace,
resetColors,
} from 'src/utils/colorScheme';
applyDashboardLabelsColorOnLoad,
updateDashboardLabelsColor,
persistDashboardLabelsColor,
} from 'src/dashboard/actions/dashboardState';
import { getColorNamespace, resetColors } from 'src/utils/colorScheme';
import { NATIVE_FILTER_DIVIDER_PREFIX } from '../nativeFilters/FiltersConfigModal/utils';
import { findTabsWithChartsInScope } from '../nativeFilters/utils';
import { getRootLevelTabsComponent } from './utils';
@@ -88,6 +88,14 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
const chartIds = useSelector<RootState, number[]>(state =>
Object.values(state.charts).map(chart => chart.id),
);
const renderedChartIds = useSelector<RootState, number[]>(state =>
Object.values(state.charts)
.filter(chart => chart.chartStatus === 'rendered')
.map(chart => chart.id),
);
const [dashboardLabelsColorInitiated, setDashboardLabelsColorInitiated] =
useState(false);
const prevRenderedChartIds = useRef<number[]>([]);
const prevTabIndexRef = useRef();
const tabIndex = useMemo(() => {
@@ -140,28 +148,65 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
const activeKey = min === 0 ? DASHBOARD_GRID_ID : min.toString();
const TOP_OF_PAGE_RANGE = 220;
const onBeforeUnload = useCallback(() => {
dispatch(persistDashboardLabelsColor());
resetColors(getColorNamespace(dashboardInfo?.metadata?.color_namespace));
prevRenderedChartIds.current = [];
}, [dashboardInfo?.metadata?.color_namespace, dispatch]);
useEffect(() => {
// verify freshness of color map on tab change
// and when loading for first time
setTimeout(() => {
dispatch(updateDashboardLabelsColor());
}, 500);
}, [directPathToChild, dispatch]);
// verify freshness of color map
// when charts render to catch new labels
const numRenderedCharts = renderedChartIds.length;
if (
dashboardLabelsColorInitiated &&
dashboardInfo?.id &&
numRenderedCharts > 0 &&
prevRenderedChartIds.current.length < numRenderedCharts
) {
const newRenderedChartIds = renderedChartIds.filter(
id => !prevRenderedChartIds.current.includes(id),
);
prevRenderedChartIds.current = renderedChartIds;
dispatch(updateDashboardLabelsColor(newRenderedChartIds));
}
}, [
dashboardInfo?.id,
renderedChartIds,
dispatch,
dashboardLabelsColorInitiated,
]);
useEffect(() => {
const labelsColorMap = getLabelsColorMap();
const colorNamespace = getColorNamespace(
dashboardInfo?.metadata?.color_namespace,
);
labelsColorMap.source = LabelsColorMapSource.Dashboard;
// apply labels color as dictated by stored metadata
applyColors(dashboardInfo.metadata);
if (dashboardInfo?.id && !dashboardLabelsColorInitiated) {
// apply labels color as dictated by stored metadata (if any)
setDashboardLabelsColorInitiated(true);
dispatch(applyDashboardLabelsColorOnLoad(dashboardInfo.metadata));
}
return () => {
resetColors(getColorNamespace(colorNamespace));
onBeforeUnload();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardInfo.id, dispatch]);
}, [dashboardInfo?.id, dispatch]);
useEffect(() => {
// 'beforeunload' event interferes with Cypress data cleanup process.
// This code prevents 'beforeunload' from triggering in Cypress tests,
// as it is not required for end-to-end testing scenarios.
if (!(window as any).Cypress) {
window.addEventListener('beforeunload', onBeforeUnload);
}
return () => {
window.removeEventListener('beforeunload', onBeforeUnload);
};
}, [onBeforeUnload]);
return (
<div className="grid-container" data-test="grid-container">
@@ -16,19 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import * as redux from 'redux';
import { render, screen, fireEvent } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import { getExtensionsRegistry } from '@superset-ui/core';
import { getExtensionsRegistry, JsonObject } from '@superset-ui/core';
import setupExtensions from 'src/setup/setupExtensions';
import getOwnerName from 'src/utils/getOwnerName';
import { HeaderProps } from './types';
import Header from '.';
import { DASHBOARD_HEADER_ID } from '../../util/constants';
const createProps = () => ({
addSuccessToast: jest.fn(),
addDangerToast: jest.fn(),
addWarningToast: jest.fn(),
const initialState = {
dashboardInfo: {
id: 1,
dash_edit_perm: false,
@@ -69,74 +67,69 @@ const createProps = () => ({
userId: 1,
username: 'admin',
},
reports: {},
dashboardTitle: 'Dashboard Title',
dashboardState: {
sliceIds: [],
expandedSlices: {},
refreshFrequency: 0,
shouldPersistRefreshFrequency: false,
css: '',
isStarred: false,
isPublished: false,
hasUnsavedChanges: false,
maxUndoHistoryExceeded: false,
editMode: false,
lastModifiedTime: 0,
},
charts: {},
layout: {},
expandedSlices: {},
css: '',
customCss: '',
isStarred: false,
isLoading: false,
lastModifiedTime: 0,
refreshFrequency: 0,
shouldPersistRefreshFrequency: false,
onSave: jest.fn(),
onChange: jest.fn(),
fetchFaveStar: jest.fn(),
fetchCharts: jest.fn(),
onRefresh: jest.fn(),
saveFaveStar: jest.fn(),
savePublished: jest.fn(),
isPublished: false,
updateDashboardTitle: jest.fn(),
editMode: false,
setEditMode: jest.fn(),
showBuilderPane: jest.fn(),
updateCss: jest.fn(),
setColorScheme: jest.fn(),
setUnsavedChanges: jest.fn(),
logEvent: jest.fn(),
setRefreshFrequency: jest.fn(),
hasUnsavedChanges: false,
maxUndoHistoryExceeded: false,
onUndo: jest.fn(),
onRedo: jest.fn(),
undoLength: 0,
redoLength: 0,
setMaxUndoHistoryExceeded: jest.fn(),
maxUndoHistoryToast: jest.fn(),
dashboardInfoChanged: jest.fn(),
dashboardTitleChanged: jest.fn(),
showMenuDropdown: true,
});
const props = createProps();
const editableProps = {
...props,
editMode: true,
dashboardLayout: {
present: {
[DASHBOARD_HEADER_ID]: {
meta: {
text: 'Dashboard Title',
},
},
},
past: [],
future: [],
},
};
const editableState = {
dashboardState: {
...initialState.dashboardState,
editMode: true,
},
dashboardInfo: {
...props.dashboardInfo,
...initialState.dashboardInfo,
dash_edit_perm: true,
dash_save_perm: true,
},
};
const undoProps = {
...editableProps,
undoLength: 1,
const undoState = {
...editableState,
dashboardLayout: {
...initialState.dashboardLayout,
past: [{}],
},
};
const redoProps = {
...editableProps,
redoLength: 1,
const redoState = {
...editableState,
dashboardLayout: {
...initialState.dashboardLayout,
future: [{}],
},
};
fetchMock.get('glob:*/csstemplateasyncmodelview/api/read', {});
function setup(props: HeaderProps, initialState = {}) {
function setup(overrideState: JsonObject = {}) {
return render(
<div className="dashboard">
<Header {...props} />
<Header />
</div>,
{ useRedux: true, initialState },
{ useRedux: true, initialState: { ...initialState, ...overrideState } },
);
}
@@ -146,173 +139,223 @@ async function openActionsDropdown() {
expect(await screen.findByTestId('header-actions-menu')).toBeInTheDocument();
}
const addSuccessToast = jest.fn();
const addDangerToast = jest.fn();
const addWarningToast = jest.fn();
const onUndo = jest.fn();
const onRedo = jest.fn();
const setEditMode = jest.fn();
const setUnsavedChanges = jest.fn();
const fetchFaveStar = jest.fn();
const saveFaveStar = jest.fn();
const savePublished = jest.fn();
const fetchCharts = jest.fn();
const updateDashboardTitle = jest.fn();
const updateCss = jest.fn();
const onChange = jest.fn();
const onSave = jest.fn();
const setMaxUndoHistoryExceeded = jest.fn();
const maxUndoHistoryToast = jest.fn();
const logEvent = jest.fn();
const setRefreshFrequency = jest.fn();
const onRefresh = jest.fn();
const dashboardInfoChanged = jest.fn();
const dashboardTitleChanged = jest.fn();
beforeAll(() => {
jest.spyOn(redux, 'bindActionCreators').mockImplementation(() => ({
addSuccessToast,
addDangerToast,
addWarningToast,
onUndo,
onRedo,
setEditMode,
setUnsavedChanges,
fetchFaveStar,
saveFaveStar,
savePublished,
fetchCharts,
updateDashboardTitle,
updateCss,
onChange,
onSave,
setMaxUndoHistoryExceeded,
maxUndoHistoryToast,
logEvent,
setRefreshFrequency,
onRefresh,
dashboardInfoChanged,
dashboardTitleChanged,
}));
});
beforeEach(() => {
jest.clearAllMocks();
});
test('should render', () => {
const mockedProps = createProps();
const { container } = setup(mockedProps);
const { container } = setup();
expect(container).toBeInTheDocument();
});
test('should render the title', () => {
const mockedProps = createProps();
setup(mockedProps);
setup();
expect(screen.getByTestId('editable-title')).toHaveTextContent(
'Dashboard Title',
);
});
test('should render the editable title', () => {
setup(editableProps);
setup(editableState);
expect(screen.getByDisplayValue('Dashboard Title')).toBeInTheDocument();
});
test('should edit the title', () => {
setup(editableProps);
setup(editableState);
const editableTitle = screen.getByDisplayValue('Dashboard Title');
expect(editableProps.onChange).not.toHaveBeenCalled();
expect(onChange).not.toHaveBeenCalled();
userEvent.click(editableTitle);
userEvent.clear(editableTitle);
userEvent.type(editableTitle, 'New Title');
userEvent.click(document.body);
expect(editableProps.onChange).toHaveBeenCalled();
expect(onChange).toHaveBeenCalled();
expect(screen.getByDisplayValue('New Title')).toBeInTheDocument();
});
test('should render the "Draft" status', () => {
const mockedProps = createProps();
setup(mockedProps);
setup();
expect(screen.getByText('Draft')).toBeInTheDocument();
});
test('should publish', () => {
const mockedProps = createProps();
const canEditProps = {
...mockedProps,
const canEditState = {
dashboardInfo: {
...mockedProps.dashboardInfo,
...initialState.dashboardInfo,
dash_edit_perm: true,
dash_save_perm: true,
},
};
setup(canEditProps);
setup(canEditState);
const draft = screen.getByText('Draft');
expect(mockedProps.savePublished).toHaveBeenCalledTimes(0);
expect(savePublished).toHaveBeenCalledTimes(0);
userEvent.click(draft);
expect(mockedProps.savePublished).toHaveBeenCalledTimes(1);
expect(savePublished).toHaveBeenCalledTimes(1);
});
test('should render metadata', () => {
const mockedProps = createProps();
setup(mockedProps);
setup();
expect(
screen.getByText(getOwnerName(mockedProps.dashboardInfo.created_by)),
screen.getByText(getOwnerName(initialState.dashboardInfo.created_by)),
).toBeInTheDocument();
expect(
screen.getByText(mockedProps.dashboardInfo.changed_on_delta_humanized),
screen.getByText(initialState.dashboardInfo.changed_on_delta_humanized),
).toBeInTheDocument();
});
test('should render the "Undo" action as disabled', () => {
setup(editableProps);
setup(editableState);
expect(screen.getByTestId('undo-action').parentElement).toBeDisabled();
});
test('should undo', () => {
setup(undoProps);
setup(undoState);
const undo = screen.getByTestId('undo-action');
expect(undoProps.onUndo).not.toHaveBeenCalled();
expect(onUndo).not.toHaveBeenCalled();
userEvent.click(undo);
expect(undoProps.onUndo).toHaveBeenCalledTimes(1);
expect(onUndo).toHaveBeenCalledTimes(1);
});
test('should undo with key listener', () => {
undoProps.onUndo.mockReset();
setup(undoProps);
expect(undoProps.onUndo).not.toHaveBeenCalled();
onUndo.mockReset();
setup(undoState);
expect(onUndo).not.toHaveBeenCalled();
fireEvent.keyDown(document.body, { key: 'z', code: 'KeyZ', ctrlKey: true });
expect(undoProps.onUndo).toHaveBeenCalledTimes(1);
expect(onUndo).toHaveBeenCalledTimes(1);
});
test('should render the "Redo" action as disabled', () => {
setup(editableProps);
setup(editableState);
expect(screen.getByTestId('redo-action').parentElement).toBeDisabled();
});
test('should redo', () => {
setup(redoProps);
setup(redoState);
const redo = screen.getByTestId('redo-action');
expect(redoProps.onRedo).not.toHaveBeenCalled();
expect(onRedo).not.toHaveBeenCalled();
userEvent.click(redo);
expect(redoProps.onRedo).toHaveBeenCalledTimes(1);
expect(onRedo).toHaveBeenCalledTimes(1);
});
test('should redo with key listener', () => {
redoProps.onRedo.mockReset();
setup(redoProps);
expect(redoProps.onRedo).not.toHaveBeenCalled();
setup(redoState);
expect(onRedo).not.toHaveBeenCalled();
fireEvent.keyDown(document.body, { key: 'y', code: 'KeyY', ctrlKey: true });
expect(redoProps.onRedo).toHaveBeenCalledTimes(1);
expect(onRedo).toHaveBeenCalledTimes(1);
});
test('should render the "Discard changes" button', () => {
setup(editableProps);
setup(editableState);
expect(screen.getByText('Discard')).toBeInTheDocument();
});
test('should render the "Save" button as disabled', () => {
setup(editableProps);
setup(editableState);
expect(screen.getByText('Save').parentElement).toBeDisabled();
});
test('should save', () => {
const unsavedProps = {
...editableProps,
hasUnsavedChanges: true,
const unsavedState = {
...editableState,
dashboardState: {
...editableState.dashboardState,
hasUnsavedChanges: true,
},
};
setup(unsavedProps);
setup(unsavedState);
const save = screen.getByText('Save');
expect(unsavedProps.onSave).not.toHaveBeenCalled();
expect(onSave).not.toHaveBeenCalled();
userEvent.click(save);
expect(unsavedProps.onSave).toHaveBeenCalledTimes(1);
expect(onSave).toHaveBeenCalledTimes(1);
});
test('should NOT render the "Draft" status', () => {
const mockedProps = createProps();
const publishedProps = {
...mockedProps,
isPublished: true,
const publishedState = {
...initialState,
dashboardState: {
...initialState.dashboardState,
isPublished: true,
},
};
setup(publishedProps);
setup(publishedState);
expect(screen.queryByText('Draft')).not.toBeInTheDocument();
});
test('should render the unselected fave icon', () => {
const mockedProps = createProps();
setup(mockedProps);
expect(mockedProps.fetchFaveStar).toHaveBeenCalled();
setup();
expect(fetchFaveStar).toHaveBeenCalled();
expect(
screen.getByRole('img', { name: 'favorite-unselected' }),
).toBeInTheDocument();
});
test('should render the selected fave icon', () => {
const mockedProps = createProps();
const favedProps = {
...mockedProps,
isStarred: true,
const favedState = {
dashboardState: {
...initialState.dashboardState,
isStarred: true,
},
};
setup(favedProps);
setup(favedState);
expect(
screen.getByRole('img', { name: 'favorite-selected' }),
).toBeInTheDocument();
});
test('should NOT render the fave icon on anonymous user', () => {
const mockedProps = createProps();
const anonymousUserProps = {
...mockedProps,
const anonymousUserState = {
user: undefined,
};
setup(anonymousUserProps);
setup(anonymousUserState);
expect(() =>
screen.getByRole('img', { name: 'favorite-unselected' }),
).toThrow('Unable to find');
@@ -322,42 +365,37 @@ test('should NOT render the fave icon on anonymous user', () => {
});
test('should fave', async () => {
const mockedProps = createProps();
setup(mockedProps);
setup();
const fave = screen.getByRole('img', { name: 'favorite-unselected' });
expect(mockedProps.saveFaveStar).not.toHaveBeenCalled();
expect(saveFaveStar).not.toHaveBeenCalled();
userEvent.click(fave);
expect(mockedProps.saveFaveStar).toHaveBeenCalledTimes(1);
expect(saveFaveStar).toHaveBeenCalledTimes(1);
});
test('should toggle the edit mode', () => {
const mockedProps = createProps();
const canEditProps = {
...mockedProps,
const canEditState = {
dashboardInfo: {
...mockedProps.dashboardInfo,
...initialState.dashboardInfo,
dash_edit_perm: true,
},
};
setup(canEditProps);
setup(canEditState);
const editDashboard = screen.getByText('Edit dashboard');
expect(screen.queryByText('Edit dashboard')).toBeInTheDocument();
userEvent.click(editDashboard);
expect(mockedProps.logEvent).toHaveBeenCalled();
expect(logEvent).toHaveBeenCalled();
});
test('should render the dropdown icon', () => {
const mockedProps = createProps();
setup(mockedProps);
setup();
expect(screen.getByRole('img', { name: 'more-horiz' })).toBeInTheDocument();
});
test('should refresh the charts', async () => {
const mockedProps = createProps();
setup(mockedProps);
setup();
await openActionsDropdown();
userEvent.click(screen.getByText('Refresh dashboard'));
expect(mockedProps.onRefresh).toHaveBeenCalledTimes(1);
expect(onRefresh).toHaveBeenCalledTimes(1);
});
test('should render an extension component if one is supplied', () => {
@@ -367,54 +405,48 @@ test('should render an extension component if one is supplied', () => {
));
setupExtensions();
const mockedProps = createProps();
setup(mockedProps);
setup();
expect(
screen.getByText('dashboard.nav.right extension component'),
).toBeInTheDocument();
});
test('should NOT render MetadataBar when in edit mode', () => {
const mockedProps = {
...createProps(),
editMode: true,
const state = {
...editableState,
dashboardInfo: {
...createProps().dashboardInfo,
...initialState.dashboardInfo,
userId: '123',
},
};
setup(mockedProps);
setup(state);
expect(
screen.queryByText(mockedProps.dashboardInfo.changed_on_delta_humanized),
screen.queryByText(state.dashboardInfo.changed_on_delta_humanized),
).not.toBeInTheDocument();
});
test('should NOT render MetadataBar when embedded', () => {
const mockedProps = {
...createProps(),
editMode: false,
const state = {
dashboardInfo: {
...createProps().dashboardInfo,
...initialState.dashboardInfo,
userId: undefined,
},
};
setup(mockedProps);
setup(state);
expect(
screen.queryByText(mockedProps.dashboardInfo.changed_on_delta_humanized),
screen.queryByText(state.dashboardInfo.changed_on_delta_humanized),
).not.toBeInTheDocument();
});
test('should render MetadataBar when not in edit mode and not embedded', () => {
const mockedProps = {
...createProps(),
editMode: false,
const state = {
dashboardInfo: {
...createProps().dashboardInfo,
...initialState.dashboardInfo,
userId: '123',
},
};
setup(mockedProps);
setup(state);
expect(
screen.getByText(mockedProps.dashboardInfo.changed_on_delta_humanized),
screen.getByText(state.dashboardInfo.changed_on_delta_humanized),
).toBeInTheDocument();
});
File diff suppressed because it is too large Load Diff
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { omit } from 'lodash';
import { Input } from 'src/components/Input';
import { FormItem } from 'src/components/Form';
@@ -44,9 +44,19 @@ import withToasts from 'src/components/MessageToasts/withToasts';
import TagType from 'src/types/TagType';
import { fetchTags, OBJECT_TYPES } from 'src/features/tags/tags';
import { loadTags } from 'src/components/Tags/utils';
import { applyColors, getColorNamespace } from 'src/utils/colorScheme';
import {
applyColors,
getColorNamespace,
getLabelsColorMapEntries,
} from 'src/utils/colorScheme';
import getOwnerName from 'src/utils/getOwnerName';
import Owner from 'src/types/Owner';
import { useDispatch } from 'react-redux';
import {
setColorScheme,
setDashboardMetadata,
} from 'src/dashboard/actions/dashboardState';
import { areObjectsEqual } from 'src/reduxUtils';
const StyledFormItem = styled(FormItem)`
margin-bottom: 0;
@@ -84,6 +94,7 @@ type DashboardInfo = {
certifiedBy: string;
certificationDetails: string;
isManagedExternally: boolean;
metadata: Record<string, any>;
};
const PropertiesModal = ({
@@ -98,10 +109,11 @@ const PropertiesModal = ({
onSubmit = () => {},
show = false,
}: PropertiesModalProps) => {
const dispatch = useDispatch();
const [form] = AntdForm.useForm();
const [isLoading, setIsLoading] = useState(false);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const [colorScheme, setColorScheme] = useState(currentColorScheme);
const [colorScheme, setCurrentColorScheme] = useState(currentColorScheme);
const [jsonMetadata, setJsonMetadata] = useState('');
const [dashboardInfo, setDashboardInfo] = useState<DashboardInfo>();
const [owners, setOwners] = useState<Owners>([]);
@@ -109,6 +121,7 @@ const PropertiesModal = ({
const saveLabel = onlyApply ? t('Apply') : t('Save');
const [tags, setTags] = useState<TagType[]>([]);
const categoricalSchemeRegistry = getCategoricalSchemeRegistry();
const originalDashboardMetadata = useRef<Record<string, any>>({});
const tagsAsSelectValues = useMemo(() => {
const selectTags = tags.map((tag: { id: number; name: string }) => ({
@@ -182,21 +195,24 @@ const PropertiesModal = ({
certifiedBy: certified_by || '',
certificationDetails: certification_details || '',
isManagedExternally: is_managed_externally || false,
metadata,
};
form.setFieldsValue(dashboardInfo);
setDashboardInfo(dashboardInfo);
setOwners(owners);
setRoles(roles);
setColorScheme(metadata.color_scheme);
setCurrentColorScheme(metadata.color_scheme);
const metaDataCopy = omit(metadata, [
'positions',
'shared_label_colors',
'map_label_colors',
'color_scheme_domain',
]);
setJsonMetadata(metaDataCopy ? jsonStringify(metaDataCopy) : '');
originalDashboardMetadata.current = metadata;
},
[form],
);
@@ -269,6 +285,8 @@ const PropertiesModal = ({
return parsedRoles;
};
const handleOnCancel = () => onHide();
const onColorSchemeChange = (
colorScheme = '',
{ updateMetadata = true } = {},
@@ -287,20 +305,21 @@ const PropertiesModal = ({
throw new Error('A valid color scheme is required');
}
jsonMetadataObj.color_scheme = colorScheme;
jsonMetadataObj.label_colors = jsonMetadataObj.label_colors || {};
setCurrentColorScheme(colorScheme);
dispatch(setColorScheme(colorScheme));
// update metadata to match selection
if (updateMetadata) {
jsonMetadataObj.color_scheme = colorScheme;
jsonMetadataObj.label_colors = jsonMetadataObj.label_colors || {};
setJsonMetadata(jsonStringify(jsonMetadataObj));
}
setColorScheme(colorScheme);
};
const onFinish = () => {
const { title, slug, certifiedBy, certificationDetails } =
form.getFieldsValue();
let currentColorScheme = colorScheme;
let currentJsonMetadata = jsonMetadata;
// validate currentJsonMetadata
@@ -318,29 +337,48 @@ const PropertiesModal = ({
return;
}
const copyMetadata = { ...metadata };
const colorNamespace = getColorNamespace(metadata?.color_namespace);
// color scheme in json metadata has precedence over selection
currentColorScheme = metadata?.color_scheme || colorScheme;
const updatedColorScheme = metadata?.color_scheme || colorScheme;
const shouldGoFresh =
updatedColorScheme !== originalDashboardMetadata.current.color_scheme;
const shouldResetCustomLabels = !areObjectsEqual(
originalDashboardMetadata.current.label_colors || {},
metadata?.label_colors || {},
);
const currentCustomLabels = Object.keys(metadata?.label_colors || {});
const prevCustomLabels = Object.keys(
originalDashboardMetadata.current.label_colors || {},
);
const resettableCustomLabels =
currentCustomLabels.length > 0 ? currentCustomLabels : prevCustomLabels;
const freshCustomLabels =
shouldResetCustomLabels && resettableCustomLabels.length > 0
? resettableCustomLabels
: false;
const jsonMetadataObj = getJsonMetadata();
const customLabelColors = jsonMetadataObj.label_colors || {};
const updatedDashboardMetadata = {
...originalDashboardMetadata.current,
label_colors: customLabelColors,
color_scheme: updatedColorScheme,
};
// remove information from user facing input
if (metadata?.shared_label_colors) {
delete metadata.shared_label_colors;
}
if (metadata?.color_scheme_domain) {
delete metadata.color_scheme_domain;
}
originalDashboardMetadata.current = updatedDashboardMetadata;
applyColors(updatedDashboardMetadata, shouldGoFresh || freshCustomLabels);
dispatch(
setDashboardMetadata({
...updatedDashboardMetadata,
map_label_colors: getLabelsColorMapEntries(customLabelColors),
}),
);
// only apply colors, the user has not saved yet
applyColors(copyMetadata, true);
currentJsonMetadata = jsonStringify(metadata);
onColorSchemeChange(currentColorScheme, {
onColorSchemeChange(updatedColorScheme, {
updateMetadata: false,
});
currentJsonMetadata = jsonStringify(metadata);
const moreOnSubmitProps: { roles?: Roles } = {};
const morePutProps: { roles?: number[]; tags?: (number | undefined)[] } =
{};
@@ -557,14 +595,14 @@ const PropertiesModal = ({
return (
<Modal
show={show}
onHide={onHide}
onHide={handleOnCancel}
title={t('Dashboard properties')}
footer={
<>
<Button
htmlType="button"
buttonSize="small"
onClick={onHide}
onClick={handleOnCancel}
data-test="properties-modal-cancel-button"
cta
>
@@ -66,8 +66,8 @@ const SyncDashboardState: FC<Props> = ({ dashboardPageId }) => {
>(
({ dashboardInfo, dashboardState, nativeFilters, dataMask }) => ({
labelsColor: dashboardInfo.metadata?.label_colors || EMPTY_OBJECT,
labelsColorMap:
dashboardInfo.metadata?.shared_label_colors || EMPTY_OBJECT,
labelsColorMap: dashboardInfo.metadata?.map_label_colors || EMPTY_OBJECT,
sharedLabelsColors: dashboardInfo.metadata?.shared_label_colors || [],
colorScheme: dashboardState?.colorScheme,
chartConfiguration:
dashboardInfo.metadata?.chart_configuration || EMPTY_OBJECT,
@@ -197,11 +197,19 @@ class Chart extends Component {
}
}
} else if (
// chart should re-render if color scheme or label color was changed
// chart should re-render if color scheme or label colors were changed
nextProps.formData?.color_scheme !== this.props.formData?.color_scheme ||
!areObjectsEqual(
nextProps.formData?.label_colors,
this.props.formData?.label_colors,
nextProps.formData?.label_colors || {},
this.props.formData?.label_colors || {},
) ||
!areObjectsEqual(
nextProps.formData?.map_label_colors || {},
this.props.formData?.map_label_colors || {},
) ||
!isEqual(
nextProps.formData?.shared_label_colors || [],
this.props.formData?.shared_label_colors || [],
)
) {
return true;
@@ -33,7 +33,7 @@ export const crossFiltersSelector = (props: {
verboseMaps: { [key: string]: Record<string, string> };
}): CrossFilterIndicator[] => {
const { dataMask, chartConfiguration, dashboardLayout, verboseMaps } = props;
const chartsIds = Object.keys(chartConfiguration);
const chartsIds = Object.keys(chartConfiguration || {});
return chartsIds
.map(chartId => {
@@ -41,7 +41,8 @@ const initialState: { dashboardInfo: DashboardInfo } = {
color_namespace: '',
color_scheme_domain: [],
label_colors: {},
shared_label_colors: {},
shared_label_colors: [],
map_label_colors: {},
cross_filters_enabled: false,
},
json_metadata: '',
@@ -485,7 +485,7 @@ describe('Ensure buildTree does not throw runtime errors when encountering an in
Sports: '#8FD3E4',
Strategy: '#A1A6BD',
},
shared_label_colors: {},
shared_label_colors: [],
color_scheme: 'supersetColors',
extra_filters: [
{
@@ -710,7 +710,7 @@ describe('Ensure buildTree does not throw runtime errors when encountering an in
queryFields: {
metric: 'metrics',
},
shared_label_colors: {},
shared_label_colors: [],
color_scheme: 'supersetColors',
extra_filters: [
{
@@ -759,7 +759,7 @@ describe('Ensure buildTree does not throw runtime errors when encountering an in
queryFields: {
metric: 'metrics',
},
shared_label_colors: {},
shared_label_colors: [],
color_scheme: 'supersetColors',
dashboardId: 9,
applied_time_extras: {},
@@ -3046,7 +3046,7 @@ describe('Ensure buildTree does not throw runtime errors when encountering an in
groupby: 'groupby',
metrics: 'metrics',
},
shared_label_colors: {},
shared_label_colors: [],
extra_filters: [
{
col: '__time_range',
@@ -3125,7 +3125,7 @@ describe('Ensure buildTree does not throw runtime errors when encountering an in
groupby: 'groupby',
metrics: 'metrics',
},
shared_label_colors: {},
shared_label_colors: [],
dashboardId: 9,
applied_time_extras: {},
where: '',
@@ -16514,7 +16514,7 @@ describe('Ensure buildTree does not throw runtime errors when encountering an in
groupby: 'groupby',
metrics: 'metrics',
},
shared_label_colors: {},
shared_label_colors: [],
extra_filters: [
{
col: '__time_range',
@@ -16661,7 +16661,7 @@ describe('Ensure buildTree does not throw runtime errors when encountering an in
groupby: 'groupby',
metrics: 'metrics',
},
shared_label_colors: {},
shared_label_colors: [],
dashboardId: 9,
applied_time_extras: {},
where: '',
@@ -17523,7 +17523,7 @@ describe('Ensure buildTree does not throw runtime errors when encountering an in
Sports: '#8FD3E4',
Strategy: '#A1A6BD',
},
shared_label_colors: {},
shared_label_colors: [],
extra_filters: [
{
col: '__time_range',
@@ -17678,7 +17678,7 @@ describe('Ensure buildTree does not throw runtime errors when encountering an in
groupby: 'groupby',
metrics: 'metrics',
},
shared_label_colors: {},
shared_label_colors: [],
extra_filters: [
{
col: '__time_range',
@@ -17715,7 +17715,7 @@ describe('Ensure buildTree does not throw runtime errors when encountering an in
groupby: 'groupby',
metrics: 'metrics',
},
shared_label_colors: {},
shared_label_colors: [],
dashboardId: 9,
applied_time_extras: {},
where: '',
@@ -59,17 +59,24 @@ function mapStateToProps(
const datasource =
(chart && chart.form_data && datasources[chart.form_data.datasource]) ||
PLACEHOLDER_DATASOURCE;
const { colorScheme, colorNamespace, datasetsStatus } = dashboardState;
const {
colorScheme: appliedColorScheme,
colorNamespace,
datasetsStatus,
} = dashboardState;
const labelsColor = dashboardInfo?.metadata?.label_colors || {};
const labelsColorMap = dashboardInfo?.metadata?.shared_label_colors || {};
const labelsColorMap = dashboardInfo?.metadata?.map_label_colors || {};
const sharedLabelsColors = dashboardInfo?.metadata?.shared_label_colors || [];
const ownColorScheme = chart.form_data?.color_scheme;
// note: this method caches filters if possible to prevent render cascades
const formData = getFormDataWithExtraFilters({
chart,
chartConfiguration: dashboardInfo.metadata?.chart_configuration,
charts: chartQueries,
filters: getAppliedFilterValues(id),
colorScheme,
colorNamespace,
colorScheme: appliedColorScheme,
ownColorScheme,
sliceId: id,
nativeFilters: nativeFilters?.filters,
allSliceIds: dashboardState.sliceIds,
@@ -77,6 +84,7 @@ function mapStateToProps(
extraControls,
labelsColor,
labelsColorMap,
sharedLabelsColors,
});
formData.dashboardId = dashboardInfo.id;
@@ -1,137 +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 { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { updateDataMask } from 'src/dataMask/actions';
import DashboardHeader from 'src/dashboard/components/Header';
import isDashboardLoading from 'src/dashboard/util/isDashboardLoading';
import { dashboardInfoChanged } from 'src/dashboard/actions/dashboardInfo';
import {
setEditMode,
showBuilderPane,
fetchFaveStar,
saveFaveStar,
savePublished,
setColorScheme,
setUnsavedChanges,
fetchCharts,
updateCss,
onChange,
saveDashboardRequest,
setMaxUndoHistoryExceeded,
maxUndoHistoryToast,
setRefreshFrequency,
onRefresh,
} from 'src/dashboard/actions/dashboardState';
import {
undoLayoutAction,
redoLayoutAction,
updateDashboardTitle,
dashboardTitleChanged,
} from 'src/dashboard/actions/dashboardLayout';
import {
addSuccessToast,
addDangerToast,
addWarningToast,
} from 'src/components/MessageToasts/actions';
import { logEvent } from 'src/logger/actions';
import { DASHBOARD_HEADER_ID } from 'src/dashboard/util/constants';
function mapStateToProps({
dashboardLayout: undoableLayout,
dashboardState,
reports,
dashboardInfo,
charts,
dataMask,
user,
}) {
return {
dashboardInfo,
undoLength: undoableLayout.past.length,
redoLength: undoableLayout.future.length,
layout: undoableLayout.present,
dashboardTitle: (
(undoableLayout.present[DASHBOARD_HEADER_ID] || {}).meta || {}
).text,
expandedSlices: dashboardState.expandedSlices,
refreshFrequency: dashboardState.refreshFrequency,
shouldPersistRefreshFrequency:
!!dashboardState.shouldPersistRefreshFrequency,
customCss: dashboardState.css,
colorNamespace: dashboardState.colorNamespace,
colorScheme: dashboardState.colorScheme,
charts,
dataMask,
user,
isStarred: !!dashboardState.isStarred,
isPublished: !!dashboardState.isPublished,
isLoading: isDashboardLoading(charts),
hasUnsavedChanges: !!dashboardState.hasUnsavedChanges,
maxUndoHistoryExceeded: !!dashboardState.maxUndoHistoryExceeded,
lastModifiedTime: Math.max(
dashboardState.lastModifiedTime,
dashboardInfo.last_modified_time,
),
editMode: !!dashboardState.editMode,
slug: dashboardInfo.slug,
metadata: dashboardInfo.metadata,
reports,
};
}
function mapDispatchToProps(dispatch) {
return bindActionCreators(
{
addSuccessToast,
addDangerToast,
addWarningToast,
onUndo: undoLayoutAction,
onRedo: redoLayoutAction,
setEditMode,
showBuilderPane,
setColorScheme,
setUnsavedChanges,
fetchFaveStar,
saveFaveStar,
savePublished,
fetchCharts,
updateDashboardTitle,
updateCss,
onChange,
onSave: saveDashboardRequest,
setMaxUndoHistoryExceeded,
maxUndoHistoryToast,
logEvent,
setRefreshFrequency,
onRefresh,
dashboardInfoChanged,
dashboardTitleChanged,
updateDataMask,
},
dispatch,
);
}
export default connect(mapStateToProps, mapDispatchToProps)(DashboardHeader);
@@ -46,6 +46,10 @@ import {
SET_OVERRIDE_CONFIRM,
SAVE_DASHBOARD_STARTED,
SAVE_DASHBOARD_FINISHED,
SET_DASHBOARD_LABELS_COLORMAP_SYNCABLE,
SET_DASHBOARD_LABELS_COLORMAP_SYNCED,
SET_DASHBOARD_SHARED_LABELS_COLORS_SYNCABLE,
SET_DASHBOARD_SHARED_LABELS_COLORS_SYNCED,
} from '../actions/dashboardState';
import { HYDRATE_DASHBOARD } from '../actions/hydrate';
@@ -101,6 +105,30 @@ export default function dashboardStateReducer(state = {}, action) {
updatedColorScheme: true,
};
},
[SET_DASHBOARD_LABELS_COLORMAP_SYNCABLE]() {
return {
...state,
labelsColorMapMustSync: true,
};
},
[SET_DASHBOARD_LABELS_COLORMAP_SYNCED]() {
return {
...state,
labelsColorMapMustSync: false,
};
},
[SET_DASHBOARD_SHARED_LABELS_COLORS_SYNCABLE]() {
return {
...state,
sharedLabelsColorsMustSync: true,
};
},
[SET_DASHBOARD_SHARED_LABELS_COLORS_SYNCED]() {
return {
...state,
sharedLabelsColorsMustSync: false,
};
},
[TOGGLE_EXPAND_SLICE]() {
const updatedExpandedSlices = { ...state.expandedSlices };
const { sliceId } = action;
+4 -1
View File
@@ -52,6 +52,8 @@ export type Chart = ChartState & {
form_data: {
viz_type: string;
datasource: string;
color_scheme: string;
slice_id: number;
};
};
@@ -131,7 +133,8 @@ export type DashboardInfo = {
color_namespace: string;
color_scheme_domain: string[];
label_colors: JsonObject;
shared_label_colors: JsonObject;
shared_label_colors: string[];
map_label_colors: JsonObject;
cross_filters_enabled: boolean;
};
crossFiltersEnabled: boolean;
@@ -25,6 +25,7 @@ import {
import { ChartConfiguration, ChartQueryPayload } from 'src/dashboard/types';
import { getExtraFormData } from 'src/dashboard/components/nativeFilters/utils';
import { areObjectsEqual } from 'src/reduxUtils';
import { isEqual } from 'lodash';
import getEffectiveExtraFilters from './getEffectiveExtraFilters';
import { getAllActiveFilters } from '../activeAllDashboardFilters';
@@ -38,6 +39,7 @@ export interface GetFormDataWithExtraFiltersArguments {
chart: ChartQueryPayload;
filters: DataRecordFilters;
colorScheme?: string;
ownColorScheme?: string;
colorNamespace?: string;
sliceId: number;
dataMask: DataMaskStateWithId;
@@ -45,6 +47,7 @@ export interface GetFormDataWithExtraFiltersArguments {
extraControls: Record<string, string | boolean | null>;
labelsColor?: Record<string, string>;
labelsColorMap?: Record<string, string>;
sharedLabelsColors?: string[];
allSliceIds: number[];
}
@@ -57,30 +60,32 @@ export default function getFormDataWithExtraFilters({
nativeFilters,
chartConfiguration,
colorScheme,
ownColorScheme,
colorNamespace,
sliceId,
dataMask,
extraControls,
labelsColor,
labelsColorMap,
sharedLabelsColors,
allSliceIds,
}: GetFormDataWithExtraFiltersArguments) {
// if dashboard metadata + filters have not changed, use cache if possible
const cachedFormData = cachedFormdataByChart[sliceId];
if (
cachedFiltersByChart[sliceId] === filters &&
areObjectsEqual(cachedFormData?.color_scheme, colorScheme, {
ignoreUndefined: true,
}) &&
areObjectsEqual(cachedFormData?.own_color_scheme, ownColorScheme) &&
areObjectsEqual(cachedFormData?.color_scheme, colorScheme) &&
areObjectsEqual(cachedFormData?.color_namespace, colorNamespace, {
ignoreUndefined: true,
}) &&
areObjectsEqual(cachedFormData?.label_colors, labelsColor, {
ignoreUndefined: true,
}) &&
areObjectsEqual(cachedFormData?.shared_label_colors, labelsColorMap, {
areObjectsEqual(cachedFormData?.map_label_colors, labelsColorMap, {
ignoreUndefined: true,
}) &&
isEqual(cachedFormData?.shared_label_colors, sharedLabelsColors) &&
!!cachedFormData &&
areObjectsEqual(cachedFormData?.dataMask, dataMask, {
ignoreUndefined: true,
@@ -110,9 +115,14 @@ export default function getFormDataWithExtraFilters({
const formData = {
...chart.form_data,
chart_id: chart.id,
label_colors: labelsColor,
shared_label_colors: labelsColorMap,
shared_label_colors: sharedLabelsColors,
map_label_colors: labelsColorMap,
...(colorScheme && { color_scheme: colorScheme }),
...(ownColorScheme && {
own_color_scheme: ownColorScheme,
}),
extra_filters: getEffectiveExtraFilters(filters),
...extraData,
...extraControls,
@@ -58,6 +58,7 @@ const CHARTS = {
datasource: '2__table',
viz_type: 'echarts_timeseries_line',
slice_id: 1,
color_scheme: 'supersetColors',
},
chartAlert: null,
chartStatus: 'rendered' as const,
@@ -76,6 +77,7 @@ const CHARTS = {
'2': {
id: 2,
form_data: {
color_scheme: 'supersetColors',
datasource: '2__table',
viz_type: 'echarts_timeseries_line',
slice_id: 2,
@@ -262,8 +264,10 @@ test('Recalculate charts in global filter scope when charts change', () => {
'3': {
id: 3,
form_data: {
slice_id: 3,
datasource: '3__table',
viz_type: 'echarts_timeseries_line',
color_scheme: 'supersetColors',
},
chartAlert: null,
chartStatus: 'rendered' as const,
@@ -39,6 +39,7 @@ const propTypes = {
canOverwrite: PropTypes.bool.isRequired,
canDownload: PropTypes.bool.isRequired,
dashboardId: PropTypes.number,
colorScheme: PropTypes.string,
isStarred: PropTypes.bool.isRequired,
slice: PropTypes.object,
sliceName: PropTypes.string,
@@ -68,6 +69,7 @@ const additionalItemsStyles = theme => css`
export const ExploreChartHeader = ({
dashboardId,
colorScheme: dashboardColorScheme,
slice,
actions,
formData,
@@ -84,17 +86,15 @@ export const ExploreChartHeader = ({
const dispatch = useDispatch();
const { latestQueryFormData, sliceFormData } = chart;
const [isPropertiesModalOpen, setIsPropertiesModalOpen] = useState(false);
const updateCategoricalNamespace = async () => {
const { dashboards } = metadata || {};
const dashboard =
dashboardId && dashboards && dashboards.find(d => d.id === dashboardId);
if (!dashboard) {
if (!dashboard || !dashboardColorScheme) {
// clean up color namespace and shared color maps
// to avoid colors spill outside of dashboard context
resetColors(metadata?.color_namespace);
return;
}
if (dashboard) {
@@ -108,6 +108,7 @@ export const ExploreChartHeader = ({
// setting the chart to use the dashboard custom label colors if any
const dashboardMetadata = JSON.parse(result.json_metadata);
// ensure consistency with the dashboard
applyColors(dashboardMetadata);
} catch (error) {
logging.info(t('Unable to retrieve dashboard colors'));
@@ -76,6 +76,9 @@ const propTypes = {
actions: PropTypes.object.isRequired,
datasource_type: PropTypes.string.isRequired,
dashboardId: PropTypes.number,
colorScheme: PropTypes.string,
ownColorScheme: PropTypes.string,
dashboardColorScheme: PropTypes.string,
isDatasourceMetaLoading: PropTypes.bool.isRequired,
chart: chartPropShape.isRequired,
slice: PropTypes.object,
@@ -563,6 +566,7 @@ function ExploreViewContainer(props) {
canOverwrite={props.can_overwrite}
canDownload={props.can_download}
dashboardId={props.dashboardId}
colorScheme={props.dashboardColorScheme}
isStarred={props.isStarred}
slice={props.slice}
sliceName={props.sliceName}
@@ -741,6 +745,9 @@ function mapStateToProps(state) {
},
);
const chart = charts[slice_id];
const colorScheme = explore.form_data?.color_scheme;
const ownColorScheme = explore.form_data?.own_color_scheme;
const dashboardColorScheme = explore.form_data?.dashboard_color_scheme;
let dashboardId = Number(explore.form_data?.dashboardId);
if (Number.isNaN(dashboardId)) {
@@ -753,6 +760,9 @@ function mapStateToProps(state) {
datasource_type: datasource.type,
datasourceId: datasource.datasource_id,
dashboardId,
colorScheme,
ownColorScheme,
dashboardColorScheme,
controls: explore.controls,
can_add: !!explore.can_add,
can_download: !!explore.can_download,
@@ -29,6 +29,7 @@ import ColorSchemeControl, { ColorSchemes } from '.';
const defaultProps = () => ({
hasCustomLabelsColor: false,
sharedLabelsColors: [],
label: 'Color scheme',
labelMargin: 0,
name: 'color',
@@ -128,8 +129,8 @@ test('displays color scheme options', async () => {
});
});
test('Renders control with dashboard id', () => {
setup({ dashboardId: 1 });
test('Renders control with dashboard id and dashboard color scheme', () => {
setup({ dashboardId: 1, hasDashboardColorScheme: true });
expect(screen.getByText('Dashboard scheme')).toBeInTheDocument();
expect(
screen.getByLabelText('Select color scheme', { selector: 'input' }),
@@ -26,6 +26,8 @@ import {
styled,
t,
useTheme,
getLabelsColorMap,
CategoricalColorNamespace,
} from '@superset-ui/core';
import AntdSelect from 'antd/lib/select';
import { isFunction, sortBy } from 'lodash';
@@ -35,6 +37,7 @@ import Icons from 'src/components/Icons';
import { SelectOptionsType } from 'src/components/Select/types';
import { StyledSelect } from 'src/components/Select/styles';
import { handleFilterOptionHelper } from 'src/components/Select/utils';
import { getColorNamespace } from 'src/utils/colorScheme';
import ColorSchemeLabel from './ColorSchemeLabel';
const { Option, OptGroup } = AntdSelect;
@@ -47,6 +50,12 @@ export interface ColorSchemes {
export interface ColorSchemeControlProps {
hasCustomLabelsColor: boolean;
hasDashboardColorScheme?: boolean;
hasSharedLabelsColor?: boolean;
sharedLabelsColors?: string[];
mapLabelsColors?: Record<string, any>;
colorNamespace?: string;
chartId?: number;
dashboardId?: number;
label: string;
name: string;
@@ -64,8 +73,8 @@ const StyledAlert = styled(Icons.AlertSolid)`
`;
const CUSTOM_LABEL_ALERT = t(
`This color scheme is being overridden by custom label colors.
Check the JSON metadata in the Advanced settings`,
`The colors of this chart might be overridden by custom label colors of the related dashboard.
Check the JSON metadata in the Advanced settings.`,
);
const DASHBOARD_ALERT = t(
@@ -73,18 +82,38 @@ const DASHBOARD_ALERT = t(
Edit the color scheme in the dashboard properties.`,
);
const DASHBOARD_CONTEXT_ALERT = t(
`You are viewing this chart in a dashboard context with labels shared across multiple charts.
The color scheme selection is disabled.`,
);
const DASHBOARD_CONTEXT_TOOLTIP = t(
`You are viewing this chart in the context of a dashboard that is directly affecting its colors.
To edit the color scheme, open this chart outside of the dashboard.`,
);
const Label = ({
label,
hasCustomLabelsColor,
dashboardId,
hasSharedLabelsColor,
hasCustomLabelsColor,
hasDashboardColorScheme,
}: Pick<
ColorSchemeControlProps,
'label' | 'hasCustomLabelsColor' | 'dashboardId'
| 'label'
| 'dashboardId'
| 'hasCustomLabelsColor'
| 'hasSharedLabelsColor'
| 'hasDashboardColorScheme'
>) => {
if (hasCustomLabelsColor || dashboardId) {
const alertTitle = hasCustomLabelsColor
? CUSTOM_LABEL_ALERT
: DASHBOARD_ALERT;
if (hasSharedLabelsColor || hasCustomLabelsColor || hasDashboardColorScheme) {
const alertTitle =
hasCustomLabelsColor && !hasSharedLabelsColor
? CUSTOM_LABEL_ALERT
: dashboardId && hasDashboardColorScheme
? DASHBOARD_ALERT
: DASHBOARD_CONTEXT_ALERT;
return (
<>
{label}{' '}
@@ -99,7 +128,12 @@ const Label = ({
const ColorSchemeControl = ({
hasCustomLabelsColor = false,
hasDashboardColorScheme = false,
mapLabelsColors = {},
sharedLabelsColors = [],
dashboardId,
colorNamespace,
chartId,
label = t('Color scheme'),
onChange = () => {},
value,
@@ -110,9 +144,21 @@ const ColorSchemeControl = ({
isLinear,
...rest
}: ColorSchemeControlProps) => {
const countSharedLabelsColor = sharedLabelsColors.length;
const colorMapInstance = getLabelsColorMap();
const chartLabels = chartId
? colorMapInstance.chartsLabelsMap.get(chartId)?.labels || []
: [];
const hasSharedLabelsColor = !!(
dashboardId &&
countSharedLabelsColor > 0 &&
chartLabels.some(label => sharedLabelsColors.includes(label))
);
const hasDashboardScheme = dashboardId && hasDashboardColorScheme;
const showDashboardLockedOption = hasDashboardScheme || hasSharedLabelsColor;
const theme = useTheme();
const currentScheme = useMemo(() => {
if (dashboardId) {
if (showDashboardLockedOption) {
return 'dashboard';
}
let result = value || defaultScheme;
@@ -121,13 +167,15 @@ const ColorSchemeControl = ({
result = schemesObject?.SUPERSET_DEFAULT?.id;
}
return result;
}, [dashboardId, defaultScheme, schemes, value]);
}, [defaultScheme, schemes, showDashboardLockedOption, value]);
const options = useMemo(() => {
if (dashboardId) {
if (showDashboardLockedOption) {
return [
<Option value="dashboard" label={t('dashboard')} key="dashboard">
<Tooltip title={DASHBOARD_ALERT}>{t('Dashboard scheme')}</Tooltip>
<Option value="dashboard" label={t('Dashboard')} key="dashboard">
<Tooltip title={DASHBOARD_CONTEXT_TOOLTIP}>
{t('Dashboard scheme')}
</Tooltip>
</Option>,
];
}
@@ -218,11 +266,29 @@ const ColorSchemeControl = ({
))}
</OptGroup>
));
}, [choices, dashboardId, isLinear, schemes]);
}, [choices, hasDashboardScheme, hasSharedLabelsColor, isLinear, schemes]);
// We can't pass on change directly because it receives a second
// parameter and it would be interpreted as the error parameter
const handleOnChange = (value: string) => onChange(value);
const handleOnChange = (value: string) => {
if (chartId) {
colorMapInstance.setOwnColorScheme(chartId, value);
if (dashboardId) {
const colorNameSpace = getColorNamespace(colorNamespace);
const categoricalNamespace =
CategoricalColorNamespace.getNamespace(colorNameSpace);
const sharedLabelsSet = new Set(sharedLabelsColors);
// reset colors except shared and custom labels to keep dashboard consistency
const resettableLabels = Object.keys(mapLabelsColors).filter(
l => !sharedLabelsSet.has(l),
);
categoricalNamespace.resetColorsForLabels(resettableLabels);
}
}
onChange(value);
};
return (
<>
@@ -231,8 +297,10 @@ const ColorSchemeControl = ({
label={
<Label
label={label}
hasCustomLabelsColor={hasCustomLabelsColor}
dashboardId={dashboardId}
hasCustomLabelsColor={hasCustomLabelsColor}
hasDashboardColorScheme={hasDashboardColorScheme}
hasSharedLabelsColor={hasSharedLabelsColor}
/>
}
/>
@@ -249,7 +317,7 @@ const ColorSchemeControl = ({
`}
aria-label={t('Select color scheme')}
allowClear={clearable}
disabled={!!dashboardId}
disabled={hasDashboardScheme || hasSharedLabelsColor}
onChange={handleOnChange}
placeholder={t('Select scheme')}
value={currentScheme}
@@ -24,6 +24,7 @@ import {
import { JsonArray, JsonValue, t } from '@superset-ui/core';
import { ControlProps } from 'src/explore/components/Control';
import builtInControlComponents from 'src/explore/components/controls';
import useEffectEvent from 'src/hooks/useEffectEvent';
/**
* Full control component map.
@@ -72,7 +73,7 @@ export type AsyncVerify = (
* Whether the extra props will update the original props.
*/
function hasUpdates(
props: ControlPropsWithExtras,
props: Partial<ControlPropsWithExtras>,
newProps: ExtraControlProps,
) {
return (
@@ -165,17 +166,17 @@ export default function withAsyncVerification({
[basicOnChange, otherProps, verifiedProps],
);
useEffect(() => {
if (needAsyncVerification && verify) {
const verifyProps = useEffectEvent(
(verifyFunc: AsyncVerify, props: typeof otherProps) => {
if (showLoadingState) {
setIsLoading(true);
}
verify(otherProps)
verifyFunc(props)
.then(updatedProps => {
if (showLoadingState) {
setIsLoading(false);
}
if (updatedProps && hasUpdates(otherProps, updatedProps)) {
if (updatedProps && hasUpdates(verifiedProps, updatedProps)) {
setVerifiedProps({
// save isLoading in combination with other props to avoid
// rendering twice.
@@ -198,14 +199,14 @@ export default function withAsyncVerification({
);
}
});
},
);
useEffect(() => {
if (needAsyncVerification && verify) {
verifyProps(verify, otherProps);
}
}, [
needAsyncVerification,
showLoadingState,
verify,
otherProps,
addWarningToast,
]);
}, [needAsyncVerification, verify, otherProps, verifyProps]);
return (
<ControlComponent
@@ -61,6 +61,9 @@ describe('controlUtils', () => {
controls: {},
form_data: { datasource: '1__table', viz_type: 'table' },
common: {},
slice: {
slice_id: 1,
},
};
beforeAll(() => {
@@ -151,10 +151,9 @@ const getExpectedResultFormData = (overrides: JsonObject = {}) => ({
girl: '#FF69B4',
boy: '#ADD8E6',
},
shared_label_colors: {
boy: '#ADD8E6',
girl: '#FF69B4',
},
shared_label_colors: ['boy', 'girl'],
own_color_scheme: 'supersetColors',
dashboard_color_scheme: 'd3Category20b',
extra_filters: [
{
col: '__time_range',
@@ -218,11 +218,18 @@ export const getFormDataWithDashboardContext = (
{},
);
const ownColorScheme = exploreFormData.color_scheme;
const dashboardColorScheme = dashboardContextFormData.color_scheme;
const appliedColorScheme = dashboardColorScheme || ownColorScheme;
return {
...exploreFormData,
...dashboardContextFormData,
...filterBoxData,
...nativeFiltersData,
...adhocFilters,
own_color_scheme: ownColorScheme,
color_scheme: appliedColorScheme,
dashboard_color_scheme: dashboardColorScheme,
};
};
+7 -3
View File
@@ -99,9 +99,10 @@ const getDashboardContextFormData = () => {
if (dashboardContext) {
const sliceId = getUrlParam(URL_PARAMS.sliceId) || 0;
const {
colorScheme,
labelsColor,
labelsColorMap,
colorScheme,
sharedLabelsColors,
chartConfiguration,
nativeFilters,
filterBoxFilters,
@@ -113,15 +114,18 @@ const getDashboardContextFormData = () => {
filters: getAppliedFilterValues(sliceId, filterBoxFilters),
nativeFilters,
chartConfiguration,
colorScheme,
dataMask,
colorScheme,
labelsColor,
labelsColorMap,
sharedLabelsColors,
sliceId,
allSliceIds: [sliceId],
extraControls: {},
});
Object.assign(dashboardContextWithFilters, { dashboardId });
Object.assign(dashboardContextWithFilters, {
dashboardId,
});
return dashboardContextWithFilters;
}
return null;
@@ -26,6 +26,7 @@ import { ChartConfiguration } from 'src/dashboard/types';
export interface DashboardContextForExplore {
labelsColor: Record<string, string>;
labelsColorMap: Record<string, string>;
sharedLabelsColors: string[];
colorScheme: string;
chartConfiguration: ChartConfiguration;
nativeFilters: PartialFilters;
+133 -35
View File
@@ -32,16 +32,60 @@ import {
export const getColorNamespace = (namespace?: string) => namespace || undefined;
/**
* Get the labels color map entries
* Get labels shared across all charts in a dashboard.
* Merges a fresh instance of shared label colors with a stored one.
*
* @returns Record<string, string>
*/
export const getLabelsColorMapEntries = (): Record<string, string> => {
const labelsColorMapInstance = getLabelsColorMap();
const updatedLabelsColorMapEntries = Object.fromEntries(
labelsColorMapInstance.getColorMap(),
export const getFreshSharedLabels = (
currentSharedLabels: string[] = [],
): string[] => {
const { chartsLabelsMap } = getLabelsColorMap();
const allLabels = Array.from(chartsLabelsMap.values()).flatMap(
({ labels }) => labels,
);
return updatedLabelsColorMapEntries;
const duplicates = Array.from(
allLabels.reduce(
(counts, label) => counts.set(label, (counts.get(label) || 0) + 1),
new Map(),
),
)
.filter(([, count]) => count > 1)
.map(([label]) => label);
return Array.from(new Set([...currentSharedLabels, ...duplicates]));
};
export const getSharedLabelsColorMapEntries = (
currentColorMap: Record<string, string>,
sharedLabels: string[],
): Record<string, string> =>
Object.fromEntries(
Object.entries(currentColorMap).filter(([label]) =>
sharedLabels.includes(label),
),
);
/**
* Returns all entries (labels and colors) except custom label colors.
*
* @param customLabelsColor - the custom label colors in label_colors field
* @returns all color entries except custom label colors
*/
export const getLabelsColorMapEntries = (
customLabelsColor: Record<string, string>,
): Record<string, string> => {
const labelsColorMapInstance = getLabelsColorMap();
const allEntries = Object.fromEntries(labelsColorMapInstance.getColorMap());
// custom label colors are applied and stored separetely via label_colors
// removing all instances of custom label colors from the entries
Object.keys(customLabelsColor).forEach(label => {
delete allEntries[label];
});
return allEntries;
};
export const getColorSchemeDomain = (colorScheme: string) =>
@@ -56,15 +100,20 @@ export const getColorSchemeDomain = (colorScheme: string) =>
export const isLabelsColorMapSynced = (
metadata: Record<string, any>,
): boolean => {
const currentLabelsColorMap = metadata?.shared_label_colors || {};
const customLabelColors = metadata?.label_colors || {};
const freshLabelsColorMap = getLabelsColorMap().getColorMap();
const isSynced = Array.from(freshLabelsColorMap.entries()).every(
const storedLabelsColorMap = metadata.map_label_colors || {};
const customLabelColors = metadata.label_colors || {};
const freshColorMap = getLabelsColorMap().getColorMap();
const fullFreshColorMap = {
...Object.fromEntries(freshColorMap),
...customLabelColors,
};
const isSynced = Object.entries(fullFreshColorMap).every(
([label, color]) =>
currentLabelsColorMap.hasOwnProperty(label) &&
(currentLabelsColorMap[label] === color ||
customLabelColors[label] !== undefined),
storedLabelsColorMap.hasOwnProperty(label) &&
storedLabelsColorMap[label] === color,
);
return isSynced;
};
@@ -79,7 +128,7 @@ export const resetColors = (color_namespace?: string) => {
getColorNamespace(color_namespace),
);
categoricalNamespace.resetColors();
labelsColorMapInstance.clear();
labelsColorMapInstance.reset();
};
/**
@@ -92,13 +141,18 @@ export const resetColors = (color_namespace?: string) => {
export const refreshLabelsColorMap = (
namespace?: string,
colorScheme?: string,
merge = false,
) => {
const colorNameSpace = getColorNamespace(namespace);
const categoricalNamespace =
CategoricalColorNamespace.getNamespace(colorNameSpace);
const labelsColorMapInstance = getLabelsColorMap();
labelsColorMapInstance.updateColorMap(categoricalNamespace, colorScheme);
labelsColorMapInstance.updateColorMap(
categoricalNamespace,
colorScheme,
merge,
);
};
/**
@@ -107,34 +161,78 @@ export const refreshLabelsColorMap = (
*
* @param metadata - the dashboard metadata object
*/
export const applyColors = (metadata: Record<string, any>, fresh = false) => {
export const applyColors = (
metadata: Record<string, any>,
// Create a fresh color map by changing color scheme
fresh: boolean | string[] = false,
// Catch new labels in the color map as they appear
merge = false,
// Apply only label colors that are shared across multiple charts.
shared = false,
) => {
const colorNameSpace = getColorNamespace(metadata?.color_namespace);
const categoricalNamespace =
CategoricalColorNamespace.getNamespace(colorNameSpace);
const colorScheme = metadata?.color_scheme;
const customLabelColors = metadata?.label_colors || {};
// when scheme unset, update only custom label colors
const labelsColorMap = metadata?.shared_label_colors || {};
const fullLabelsColor = metadata?.map_label_colors || {};
const sharedLabels = metadata?.shared_label_colors || [];
const customLabelsColor = metadata?.label_colors || {};
const sharedLabelsColor = getSharedLabelsColorMapEntries(
fullLabelsColor,
sharedLabels,
);
// reset forced colors (custom labels + labels color map)
categoricalNamespace.resetColors();
if (fresh && !Array.isArray(fresh)) {
// reset custom label colors
// re-evaluate all other label colors
categoricalNamespace.resetColors();
}
// apply custom label colors first
Object.keys(customLabelColors).forEach(label => {
categoricalNamespace.setColor(label, customLabelColors[label]);
});
if (fresh && Array.isArray(fresh)) {
// when a color scheme is not set for the dashboard
// should only reset colors for charts that have changed scheme
// while keeping colors of existing shared label colors intact
// this is used also to reset custom label colors when added or removed
categoricalNamespace.resetColorsForLabels(fresh);
}
// re-instantiate a fresh labels color map based on current scheme
// will consider also just applied custom label colors
refreshLabelsColorMap(metadata?.color_namespace, colorScheme);
if (fresh || merge) {
// re-instantiate a fresh labels color map based on current scheme
// it consider just applied custom label colors if present and all forced colors
// it will merge with the existing color map new labels only when merge is true
refreshLabelsColorMap(metadata?.color_namespace, colorScheme, merge);
}
// get the fresh map that was just updated or existing
const labelsColorMapEntries = fresh
? getLabelsColorMapEntries()
: labelsColorMap;
let applicableColorMapEntries: Record<string, any> = fullLabelsColor;
if (fresh) {
// requires a new map all together
applicableColorMapEntries = {
...getLabelsColorMapEntries(customLabelsColor),
};
}
if (merge) {
// must only add up newly appearing labels
// without overriding existing ones
applicableColorMapEntries = {
...fullLabelsColor,
...getLabelsColorMapEntries(customLabelsColor),
};
}
if (shared) {
// must apply the colors to only shared labels
applicableColorMapEntries = sharedLabelsColor;
}
applicableColorMapEntries = {
...applicableColorMapEntries,
...customLabelsColor,
};
// apply the final color map
Object.keys(labelsColorMapEntries).forEach(label => {
categoricalNamespace.setColor(label, labelsColorMapEntries[label]);
});
if (applicableColorMapEntries) {
Object.keys(applicableColorMapEntries).forEach(label => {
categoricalNamespace.setColor(label, applicableColorMapEntries[label]);
});
}
};
@@ -26,6 +26,7 @@ from superset.commands.chart.importers.v1.utils import import_chart
from superset.commands.database.importers.v1.utils import import_database
from superset.commands.dataset.importers.v1.utils import import_dataset
from superset.commands.importers.v1 import ImportModelsCommand
from superset.commands.utils import update_chart_config_dataset
from superset.connectors.sqla.models import SqlaTable
from superset.daos.chart import ChartDAO
from superset.databases.schemas import ImportV1DatabaseSchema
@@ -86,16 +87,10 @@ class ImportChartsCommand(ImportModelsCommand):
# update datasource id, type, and name
dataset = datasets[config["dataset_uuid"]]
config.update(
{
"datasource_id": dataset.id,
"datasource_type": "table",
"datasource_name": dataset.table_name,
}
)
config["params"].update({"datasource": dataset.uid})
if "query_context" in config:
config["query_context"] = None
dataset_dict = {
"datasource_id": dataset.id,
"datasource_type": "table",
"datasource_name": dataset.table_name,
}
config = update_chart_config_dataset(config, dataset_dict)
import_chart(config, overwrite=overwrite)
@@ -34,6 +34,7 @@ from superset.commands.dashboard.importers.v1.utils import (
from superset.commands.database.importers.v1.utils import import_database
from superset.commands.dataset.importers.v1.utils import import_dataset
from superset.commands.importers.v1 import ImportModelsCommand
from superset.commands.utils import update_chart_config_dataset
from superset.daos.dashboard import DashboardDAO
from superset.dashboards.schemas import ImportV1DashboardSchema
from superset.databases.schemas import ImportV1DatabaseSchema
@@ -113,11 +114,7 @@ class ImportDashboardsCommand(ImportModelsCommand):
):
# update datasource id, type, and name
dataset_dict = dataset_info[config["dataset_uuid"]]
config.update(dataset_dict)
dataset_uid = f"{dataset_dict['datasource_id']}__{dataset_dict['datasource_type']}"
config["params"].update({"datasource": dataset_uid})
if "query_context" in config:
config["query_context"] = None
config = update_chart_config_dataset(config, dataset_dict)
chart = import_chart(config, overwrite=False)
charts.append(chart)
+2 -5
View File
@@ -36,7 +36,7 @@ from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetErrorException
from superset.extensions import db
from superset.models.core import Database
from superset.sql_parse import ParsedQuery, Table
from superset.sql_parse import Table
from superset.utils.decorators import on_error, transaction
logger = logging.getLogger(__name__)
@@ -70,10 +70,7 @@ class DuplicateDatasetCommand(CreateMixin, BaseCommand):
table.normalize_columns = self._base_model.normalize_columns
table.always_filter_main_dttm = self._base_model.always_filter_main_dttm
table.is_sqllab_view = True
table.sql = ParsedQuery(
self._base_model.sql,
engine=database.db_engine_spec.engine,
).stripped()
table.sql = self._base_model.sql.strip().strip(";")
db.session.add(table)
cols = []
for config_ in self._base_model.columns:
+2 -5
View File
@@ -39,6 +39,7 @@ from superset.commands.importers.v1.utils import (
validate_metadata_type,
)
from superset.commands.query.importers.v1.utils import import_saved_query
from superset.commands.utils import update_chart_config_dataset
from superset.dashboards.schemas import ImportV1DashboardSchema
from superset.databases.schemas import ImportV1DatabaseSchema
from superset.datasets.schemas import ImportV1DatasetSchema
@@ -113,11 +114,7 @@ class ImportAssetsCommand(BaseCommand):
for file_name, config in configs.items():
if file_name.startswith("charts/"):
dataset_dict = dataset_info[config["dataset_uuid"]]
config.update(dataset_dict)
dataset_uid = f"{dataset_dict['datasource_id']}__{dataset_dict['datasource_type']}"
config["params"].update({"datasource": dataset_uid})
if "query_context" in config:
config["query_context"] = None
config = update_chart_config_dataset(config, dataset_dict)
chart = import_chart(config, overwrite=True)
charts.append(chart)
chart_ids[str(chart.uuid)] = chart.id
+42 -1
View File
@@ -17,7 +17,7 @@
from __future__ import annotations
from collections import Counter
from typing import Optional, TYPE_CHECKING
from typing import Any, Optional, TYPE_CHECKING
from flask import g
from flask_appbuilder.security.sqla.models import Role, User
@@ -34,6 +34,7 @@ from superset.daos.datasource import DatasourceDAO
from superset.daos.exceptions import DatasourceNotFound
from superset.daos.tag import TagDAO
from superset.tags.models import ObjectType, Tag, TagType
from superset.utils import json
from superset.utils.core import DatasourceType, get_user_id
if TYPE_CHECKING:
@@ -185,3 +186,43 @@ def update_tags(
TagDAO.create_custom_tagged_objects(
object_type, object_id, [tag.name for tag in tags_to_add]
)
def update_chart_config_dataset(
config: dict[str, Any], dataset_info: dict[str, Any]
) -> dict[str, Any]:
"""
Update the chart configuration and query_context with new dataset information
:param config: The original chart configuration
:param dataset_info: Dict with datasource_id, datasource_type, and datasource_name
:return: The updated chart configuration
"""
# Update datasource id, type, and name
config.update(dataset_info)
dataset_uid = f"{dataset_info['datasource_id']}__{dataset_info['datasource_type']}"
config["params"].update({"datasource": dataset_uid})
if "query_context" in config and config["query_context"] is not None:
try:
query_context = json.loads(config["query_context"])
query_context["datasource"] = {
"id": dataset_info["datasource_id"],
"type": dataset_info["datasource_type"],
}
if "form_data" in query_context:
query_context["form_data"]["datasource"] = dataset_uid
if "queries" in query_context:
for query in query_context["queries"]:
if "datasource" in query:
query["datasource"] = query_context["datasource"]
config["query_context"] = json.dumps(query_context)
except json.JSONDecodeError:
config["query_context"] = None
return config
+1 -1
View File
@@ -1778,7 +1778,7 @@ GUEST_TOKEN_VALIDATOR_HOOK = None
# def DATASET_HEALTH_CHECK(datasource: SqlaTable) -> Optional[str]:
# if (
# datasource.sql and
# len(sql_parse.ParsedQuery(datasource.sql, strip_comments=True).tables) == 1
# len(SQLScript(datasource.sql).tables) == 1
# ):
# return (
# "This virtual dataset queries only one table and therefore could be "
+5 -26
View File
@@ -67,7 +67,7 @@ from sqlalchemy.orm.mapper import Mapper
from sqlalchemy.schema import UniqueConstraint
from sqlalchemy.sql import column, ColumnElement, literal_column, table
from sqlalchemy.sql.elements import ColumnClause, TextClause
from sqlalchemy.sql.expression import Label, TextAsFrom
from sqlalchemy.sql.expression import Label
from sqlalchemy.sql.selectable import Alias, TableClause
from superset import app, db, is_feature_enabled, security_manager
@@ -104,7 +104,7 @@ from superset.models.helpers import (
QueryResult,
)
from superset.models.slice import Slice
from superset.sql_parse import ParsedQuery, Table
from superset.sql_parse import Table
from superset.superset_typing import (
AdhocColumn,
AdhocMetric,
@@ -1469,34 +1469,13 @@ class SqlaTable(
return tbl
def get_from_clause(
self, template_processor: BaseTemplateProcessor | None = None
self,
template_processor: BaseTemplateProcessor | None = None,
) -> tuple[TableClause | Alias, str | None]:
"""
Return where to select the columns and metrics from. Either a physical table
or a virtual table with it's own subquery. If the FROM is referencing a
CTE, the CTE is returned as the second value in the return tuple.
"""
if not self.is_virtual:
return self.get_sqla_table(), None
from_sql = self.get_rendered_sql(template_processor) + "\n"
parsed_query = ParsedQuery(from_sql, engine=self.db_engine_spec.engine)
if not (
parsed_query.is_unknown()
or self.db_engine_spec.is_readonly_query(parsed_query)
):
raise QueryObjectValidationError(
_("Virtual dataset query must be read-only")
)
cte = self.db_engine_spec.get_cte_query(from_sql)
from_clause = (
table(self.db_engine_spec.cte_alias)
if cte
else TextAsFrom(self.text(from_sql), []).alias(VIRTUAL_TABLE_ALIAS)
)
return from_clause, cte
return super().get_from_clause(template_processor)
def adhoc_metric_to_sqla(
self,
+6 -6
View File
@@ -38,7 +38,8 @@ from superset.exceptions import (
)
from superset.models.core import Database
from superset.result_set import SupersetResultSet
from superset.sql_parse import ParsedQuery, Table
from superset.sql.parse import SQLScript
from superset.sql_parse import Table
from superset.superset_typing import ResultSetColumnType
if TYPE_CHECKING:
@@ -105,8 +106,8 @@ def get_virtual_table_metadata(dataset: SqlaTable) -> list[ResultSetColumnType]:
sql = dataset.get_template_processor().process_template(
dataset.sql, **dataset.template_params_dict
)
parsed_query = ParsedQuery(sql, engine=db_engine_spec.engine)
if not db_engine_spec.is_readonly_query(parsed_query):
parsed_script = SQLScript(sql, engine=db_engine_spec.engine)
if parsed_script.has_mutation():
raise SupersetSecurityException(
SupersetError(
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
@@ -114,8 +115,7 @@ def get_virtual_table_metadata(dataset: SqlaTable) -> list[ResultSetColumnType]:
level=ErrorLevel.ERROR,
)
)
statements = parsed_query.get_statements()
if len(statements) > 1:
if len(parsed_script.statements) > 1:
raise SupersetSecurityException(
SupersetError(
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
@@ -127,7 +127,7 @@ def get_virtual_table_metadata(dataset: SqlaTable) -> list[ResultSetColumnType]:
dataset.database,
dataset.catalog,
dataset.schema,
statements[0],
sql,
)
+2 -1
View File
@@ -260,7 +260,8 @@ class DashboardDAO(BaseDAO[Dashboard]):
md["refresh_frequency"] = data.get("refresh_frequency", 0)
md["color_scheme"] = data.get("color_scheme", "")
md["label_colors"] = data.get("label_colors", {})
md["shared_label_colors"] = data.get("shared_label_colors", {})
md["shared_label_colors"] = data.get("shared_label_colors", [])
md["map_label_colors"] = data.get("map_label_colors", {})
md["color_scheme_domain"] = data.get("color_scheme_domain", [])
md["cross_filters_enabled"] = data.get("cross_filters_enabled", True)
dashboard.json_metadata = json.dumps(md)
+2 -1
View File
@@ -137,7 +137,8 @@ class DashboardJSONMetadataSchema(Schema):
color_namespace = fields.Str(allow_none=True)
positions = fields.Dict(allow_none=True)
label_colors = fields.Dict()
shared_label_colors = fields.Dict()
shared_label_colors = fields.List(fields.Str())
map_label_colors = fields.Dict()
color_scheme_domain = fields.List(fields.Str())
cross_filters_enabled = fields.Boolean(dump_default=True)
# used for v0 import/export
+10 -23
View File
@@ -63,7 +63,7 @@ from superset.constants import QUERY_CANCEL_KEY, TimeGrain as TimeGrainConstants
from superset.databases.utils import get_table_metadata, make_url_safe
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import DisallowedSQLFunction, OAuth2Error, OAuth2RedirectError
from superset.sql.parse import SQLScript, Table
from superset.sql.parse import BaseSQLStatement, SQLScript, Table
from superset.sql_parse import ParsedQuery
from superset.superset_typing import (
OAuth2ClientConfig,
@@ -1737,18 +1737,19 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
)
@classmethod
def process_statement(cls, statement: str, database: Database) -> str:
def process_statement(
cls,
statement: BaseSQLStatement[Any],
database: Database,
) -> str:
"""
Process a SQL statement by stripping and mutating it.
Process a SQL statement by mutating it.
:param statement: A single SQL statement
:param database: Database instance
:return: Dictionary with different costs
"""
parsed_query = ParsedQuery(statement, engine=cls.engine)
sql = parsed_query.stripped()
return database.mutate_sql_based_on_config(sql, is_split=True)
return database.mutate_sql_based_on_config(str(statement), is_split=True)
@classmethod
def estimate_query_cost( # pylint: disable=too-many-arguments
@@ -1773,8 +1774,7 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
"Database does not support cost estimation"
)
parsed_query = sql_parse.ParsedQuery(sql, engine=cls.engine)
statements = parsed_query.get_statements()
parsed_script = SQLScript(sql, engine=cls.engine)
with database.get_raw_connection(
catalog=catalog,
@@ -1788,7 +1788,7 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
cls.process_statement(statement, database),
cursor,
)
for statement in statements
for statement in parsed_script.statements
]
@classmethod
@@ -2056,15 +2056,6 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
logger.error(ex, exc_info=True)
raise
@classmethod
def is_readonly_query(cls, parsed_query: ParsedQuery) -> bool:
"""Pessimistic readonly, 100% sure statement won't mutate anything"""
return (
parsed_query.is_select()
or parsed_query.is_explain()
or parsed_query.is_show()
)
@classmethod
def is_select_query(cls, parsed_query: ParsedQuery) -> bool:
"""
@@ -2178,10 +2169,6 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
return False
@classmethod
def parse_sql(cls, sql: str) -> list[str]:
return [str(s).strip(" ;") for s in sqlparse.parse(sql)]
@classmethod
def get_impersonation_key(cls, user: User | None) -> Any:
"""
+3 -4
View File
@@ -36,7 +36,6 @@ from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.engine.url import URL
from sqlalchemy.sql import sqltypes
from superset import sql_parse
from superset.constants import TimeGrain
from superset.databases.schemas import encrypted_field_properties, EncryptedString
from superset.databases.utils import make_url_safe
@@ -44,6 +43,7 @@ from superset.db_engine_specs.base import BaseEngineSpec, BasicPropertiesType
from superset.db_engine_specs.exceptions import SupersetDBAPIConnectionError
from superset.errors import SupersetError, SupersetErrorType
from superset.exceptions import SupersetException
from superset.sql.parse import SQLScript
from superset.sql_parse import Table
from superset.superset_typing import ResultSetColumnType
from superset.utils import core as utils, json
@@ -449,8 +449,7 @@ class BigQueryEngineSpec(BaseEngineSpec): # pylint: disable=too-many-public-met
if not cls.get_allow_cost_estimate(extra):
raise SupersetException("Database does not support cost estimation")
parsed_query = sql_parse.ParsedQuery(sql, engine=cls.engine)
statements = parsed_query.get_statements()
parsed_script = SQLScript(sql, engine=cls.engine)
with cls.get_engine(
database,
@@ -463,7 +462,7 @@ class BigQueryEngineSpec(BaseEngineSpec): # pylint: disable=too-many-public-met
cls.process_statement(statement, database),
client,
)
for statement in statements
for statement in parsed_script.statements
]
@classmethod
+1 -10
View File
@@ -45,7 +45,7 @@ from superset.db_engine_specs.presto import PrestoEngineSpec
from superset.exceptions import SupersetException
from superset.extensions import cache_manager
from superset.models.sql_lab import Query
from superset.sql_parse import ParsedQuery, Table
from superset.sql_parse import Table
from superset.superset_typing import ResultSetColumnType
if TYPE_CHECKING:
@@ -605,15 +605,6 @@ class HiveEngineSpec(PrestoEngineSpec):
# otherwise, return no function names to prevent errors
return []
@classmethod
def is_readonly_query(cls, parsed_query: ParsedQuery) -> bool:
"""Pessimistic readonly, 100% sure statement won't mutate anything"""
return (
super().is_readonly_query(parsed_query)
or parsed_query.is_set()
or parsed_query.is_show()
)
@classmethod
def has_implicit_cancel(cls) -> bool:
"""
-22
View File
@@ -104,11 +104,6 @@ class KustoSqlEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method
return f"""CONVERT(DATETIME, '{datetime_formatted}', 126)"""
return None
@classmethod
def is_readonly_query(cls, parsed_query: ParsedQuery) -> bool:
"""Pessimistic readonly, 100% sure statement won't mutate anything"""
return parsed_query.sql.lower().startswith("select")
class KustoKqlEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method
limit_method = LimitMethod.WRAP_SQL
@@ -158,23 +153,6 @@ class KustoKqlEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method
return None
@classmethod
def is_readonly_query(cls, parsed_query: ParsedQuery) -> bool:
"""
Pessimistic readonly, 100% sure statement won't mutate anything.
"""
return KustoKqlEngineSpec.is_select_query(
parsed_query
) or parsed_query.sql.startswith(".show")
@classmethod
def is_select_query(cls, parsed_query: ParsedQuery) -> bool:
return not parsed_query.sql.startswith(".")
@classmethod
def parse_sql(cls, sql: str) -> list[str]:
"""
Kusto supports a single query statement, but it could include sub queries
and variables declared via let keyword.
"""
return [sql]
+21 -2
View File
@@ -21,7 +21,7 @@ import pandas as pd
from sqlalchemy import DateTime, inspect, String
from sqlalchemy.sql import column
from superset import app, db
from superset import app, db, security_manager
from superset.connectors.sqla.models import SqlaTable, SqlMetric, TableColumn
from superset.models.core import Database
from superset.models.dashboard import Dashboard
@@ -152,6 +152,7 @@ def _add_table_metrics(datasource: SqlaTable) -> None:
def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
owner = security_manager.get_user_by_id(1)
metrics = [
{
"expressionType": "SIMPLE",
@@ -193,7 +194,6 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
slice_kwargs = {
"datasource_id": tbl.id,
"datasource_type": DatasourceType.TABLE,
"owners": [],
}
print("Creating some slices")
@@ -210,6 +210,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
compare_suffix="over 5Y",
metric=metric,
),
owners=[],
),
Slice(
**slice_kwargs,
@@ -218,6 +219,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
params=get_slice_json(
defaults, viz_type="pie", groupby=["gender"], metric=metric
),
owners=[],
),
Slice(
**slice_kwargs,
@@ -232,6 +234,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
show_legend=True,
metrics=metrics,
),
owners=[],
),
Slice(
**slice_kwargs,
@@ -268,6 +271,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
],
groupby=["state"],
),
owners=[],
),
Slice(
**slice_kwargs,
@@ -281,6 +285,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
timeseries_limit_metric=metric,
metrics=[metric],
),
owners=[],
),
Slice(
**slice_kwargs,
@@ -297,6 +302,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
adhoc_filters=[gen_filter("gender", "girl")],
metric=metric,
),
owners=[],
),
Slice(
**slice_kwargs,
@@ -310,6 +316,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
timeseries_limit_metric=metric,
metrics=[metric],
),
owners=[],
),
Slice(
**slice_kwargs,
@@ -326,6 +333,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
adhoc_filters=[gen_filter("gender", "boy")],
metric=metric,
),
owners=[],
),
Slice(
**slice_kwargs,
@@ -343,6 +351,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
x_axis_forma="smart_date",
metrics=metrics,
),
owners=[],
),
Slice(
**slice_kwargs,
@@ -360,6 +369,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
x_axis_forma="smart_date",
metrics=metrics,
),
owners=[],
),
Slice(
**slice_kwargs,
@@ -381,6 +391,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
}
],
),
owners=[],
),
]
misc_slices = [
@@ -405,12 +416,14 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
yAxisIndex=0,
yAxisIndexB=1,
),
owners=[],
),
Slice(
**slice_kwargs,
slice_name="Num Births Trend",
viz_type="line",
params=get_slice_json(defaults, viz_type="line", metrics=metrics),
owners=[],
),
Slice(
**slice_kwargs,
@@ -433,6 +446,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
}
],
),
owners=[],
),
Slice(
**slice_kwargs,
@@ -452,6 +466,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
viz_type="big_number_total",
granularity_sqla="ds",
),
owners=[],
),
Slice(
**slice_kwargs,
@@ -484,6 +499,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
},
limit="10",
),
owners=[owner] if owner else [],
),
Slice(
**slice_kwargs,
@@ -504,6 +520,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
"label": "SUM(num_california)",
},
),
owners=[],
),
Slice(
**slice_kwargs,
@@ -517,6 +534,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
adhoc_filters=[gen_filter("gender", "girl")],
subheader="total female participants",
),
owners=[],
),
Slice(
**slice_kwargs,
@@ -529,6 +547,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
groupbyColumns=["state"],
metrics=metrics,
),
owners=[],
),
]
for slc in slices:
+6 -4
View File
@@ -74,6 +74,7 @@ from superset.extensions import (
)
from superset.models.helpers import AuditMixinNullable, ImportExportMixin
from superset.result_set import SupersetResultSet
from superset.sql.parse import SQLScript
from superset.sql_parse import Table
from superset.superset_typing import OAuth2ClientConfig, ResultSetColumnType
from superset.utils import cache as cache_util, core as utils, json
@@ -674,7 +675,7 @@ class Database(Model, AuditMixinNullable, ImportExportMixin): # pylint: disable
schema: str | None = None,
mutator: Callable[[pd.DataFrame], None] | None = None,
) -> pd.DataFrame:
sqls = self.db_engine_spec.parse_sql(sql)
parsed_script = SQLScript(sql, engine=self.db_engine_spec.engine)
with self.get_sqla_engine(catalog=catalog, schema=schema) as engine:
engine_url = engine.url
@@ -691,8 +692,9 @@ class Database(Model, AuditMixinNullable, ImportExportMixin): # pylint: disable
with self.get_raw_connection(catalog=catalog, schema=schema) as conn:
cursor = conn.cursor()
df = None
for i, sql_ in enumerate(sqls):
sql_ = self.mutate_sql_based_on_config(sql_, is_split=True)
for i, statement in enumerate(parsed_script.statements):
# pylint: disable=protected-access
sql_ = self.mutate_sql_based_on_config(statement._sql, is_split=True)
_log_query(sql_)
with event_logger.log_context(
action="execute_sql",
@@ -700,7 +702,7 @@ class Database(Model, AuditMixinNullable, ImportExportMixin): # pylint: disable
object_ref=__name__,
):
self.db_engine_spec.execute(cursor, sql_, self)
if i < len(sqls) - 1:
if i < len(parsed_script.statements) - 1:
# If it's not the last, we don't keep the results
cursor.fetchall()
else:
+5 -7
View File
@@ -72,7 +72,6 @@ from superset.sql.parse import SQLScript
from superset.sql_parse import (
has_table_query,
insert_rls_in_predicate,
ParsedQuery,
sanitize_clause,
)
from superset.superset_typing import (
@@ -1039,6 +1038,9 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
"""
Render sql with template engine (Jinja).
"""
if not self.sql:
return ""
sql = self.sql.strip("\t\r\n; ")
if template_processor:
try:
@@ -1072,13 +1074,9 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
or a virtual table with it's own subquery. If the FROM is referencing a
CTE, the CTE is returned as the second value in the return tuple.
"""
from_sql = self.get_rendered_sql(template_processor) + "\n"
parsed_query = ParsedQuery(from_sql, engine=self.db_engine_spec.engine)
if not (
parsed_query.is_unknown()
or self.db_engine_spec.is_readonly_query(parsed_query)
):
parsed_script = SQLScript(from_sql, engine=self.db_engine_spec.engine)
if parsed_script.has_mutation():
raise QueryObjectValidationError(
_("Virtual dataset query must be read-only")
)
+185 -41
View File
@@ -20,10 +20,11 @@ from __future__ import annotations
import enum
import logging
import re
import string
import urllib.parse
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any, Generic, TypeVar
from typing import Any, Generic, Iterator, TypeVar
import sqlglot
import sqlparse
@@ -226,6 +227,12 @@ class BaseSQLStatement(Generic[InternalRepresentation]):
"""
raise NotImplementedError()
def is_select(self) -> bool:
"""
Check if the statement is a `SELECT` statement.
"""
raise NotImplementedError()
def __str__(self) -> str:
return self.format()
@@ -382,6 +389,12 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
return False
def is_select(self) -> bool:
"""
Check if the statement is a `SELECT` statement.
"""
return isinstance(self._parsed, exp.Select)
def format(self, comments: bool = True) -> str:
"""
Pretty-format the SQL statement.
@@ -431,60 +444,115 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
}
class KQLSplitState(enum.Enum):
class KQLTokenizeState(enum.Enum):
"""
State machine for splitting a KQL script.
State machine for tokenizing a KQL script.
The state machine keeps track of whether we're inside a string or not, so we
don't split the script in a semi-colon that's part of a string.
"""
OUTSIDE_STRING = enum.auto()
OUTSIDE = enum.auto()
INSIDE_SINGLE_QUOTED_STRING = enum.auto()
INSIDE_DOUBLE_QUOTED_STRING = enum.auto()
INSIDE_MULTILINE_STRING = enum.auto()
INSIDE_SINGLE_QUOTED_IDENTIFIER = enum.auto()
INSIDE_DOUBLE_QUOTED_IDENTIFIER = enum.auto()
def tokenize_kql(kql: str) -> Iterator[str]:
"""
Tokenize a KQL script.
"""
valid_identifier_chars = set(string.ascii_letters + string.digits + "_")
valid_quoted_identifier_chars = valid_identifier_chars | set(" .-")
script = kql if kql.endswith(";") else kql + ";"
cursor = 0
while cursor < len(script):
rest = script[cursor:]
# quoted identifiers
if rest[:2] in {"['", '["'}:
match = "']" if rest[:2] == "['" else '"]'
if match not in rest[2:]:
raise SupersetParseError(
script,
"kustokql",
message="Unclosed quoted identifier",
)
token = rest[: rest.index(match, 2) + 2]
if any(char not in valid_quoted_identifier_chars for char in token[2:-2]):
raise SupersetParseError(
script,
"kustokql",
message="Invalid quoted identifier",
)
yield token
cursor += len(token)
# multi-line strings
elif rest[:3] == "```":
if "```" not in rest[3:]:
raise SupersetParseError(
script,
"kustokql",
message="Unclosed multi-line string",
)
token = rest[: rest.index("```", 3) + 3]
yield token
cursor += len(token)
# single-quoted strings
elif rest[0] in {'"', "'"}:
match = rest[0]
# find first unescaped quote
start = 1
while True:
if match not in rest[start:]:
raise SupersetParseError(
script,
"kustokql",
message="Unclosed string",
)
index = rest.index(match, start)
if rest[index - 1] != "\\":
break
start = index + 1
token = rest[: index + 1]
yield token
cursor += len(token)
# identifiers and keywords
else:
for i, char in enumerate(rest):
if char not in valid_identifier_chars:
if i > 0:
yield rest[:i]
yield char
cursor += i + 1
break
def split_kql(kql: str) -> list[str]:
"""
Custom function for splitting KQL statements.
"""
statements = []
state = KQLSplitState.OUTSIDE_STRING
statement_start = 0
script = kql if kql.endswith(";") else kql + ";"
for i, character in enumerate(script):
if state == KQLSplitState.OUTSIDE_STRING:
if character == ";":
statements.append(script[statement_start:i])
statement_start = i + 1
elif character == "'":
state = KQLSplitState.INSIDE_SINGLE_QUOTED_STRING
elif character == '"':
state = KQLSplitState.INSIDE_DOUBLE_QUOTED_STRING
elif character == "`" and script[i - 2 : i] == "``":
state = KQLSplitState.INSIDE_MULTILINE_STRING
elif (
state == KQLSplitState.INSIDE_SINGLE_QUOTED_STRING
and character == "'"
and script[i - 1] != "\\"
):
state = KQLSplitState.OUTSIDE_STRING
elif (
state == KQLSplitState.INSIDE_DOUBLE_QUOTED_STRING
and character == '"'
and script[i - 1] != "\\"
):
state = KQLSplitState.OUTSIDE_STRING
elif (
state == KQLSplitState.INSIDE_MULTILINE_STRING
and character == "`"
and script[i - 2 : i] == "``"
):
state = KQLSplitState.OUTSIDE_STRING
statements: list[str] = []
statement: list[str] = []
for token in tokenize_kql(kql):
if token == ";":
statements.append("".join(statement))
statement = []
else:
statement.append(token)
return statements
@@ -506,6 +574,14 @@ class KustoKQLStatement(BaseSQLStatement[str]):
details about it.
"""
def __init__(
self,
statement: str,
engine: str = "kustokql",
ast: str | None = None,
):
super().__init__(statement, engine, ast)
@classmethod
def split_script(
cls,
@@ -588,6 +664,56 @@ class KustoKQLStatement(BaseSQLStatement[str]):
"""
return self._parsed.startswith(".") and not self._parsed.startswith(".show")
def is_select(self) -> bool:
"""
Check if the statement is a `SELECT` statement.
"""
if not self._parsed or self.is_mutating():
return False
# strip comments
kql = "\n".join(
line
for line in self._parsed.split("\n")
if not line.strip().startswith("//")
).strip()
first_token = next(tokenize_kql(kql), None)
if not first_token:
return False
return first_token == "|" or self._is_identifier(first_token)
@staticmethod
def _is_identifier(identifier: str) -> bool:
"""
Validates if a given string is a valid KQL identifier.
From the documentation:
Identifiers are case-sensitive. Database names are case-insensitive, and
therefore an exception to this rule.
Identifiers must be between 1 and 1024 characters long.
Identifiers may contain letters, digits, and underscores (_).
Identifiers may contain certain special characters: spaces, dots (.), and
dashes (-). For information on how to reference identifiers with special
characters, see Reference identifiers in queries.
"""
valid_chars = set(string.ascii_letters + string.digits + "_")
# Identifiers names that (1) include special character, (2) are language
# keywords, or (3) are literals must be enclosed using [' and '] or [" and "].
if (identifier.startswith("['") and identifier.endswith("']")) or (
identifier.startswith('["') and identifier.endswith('"]')
):
identifier = identifier[2:-2]
valid_chars.update(" .-")
return 1 <= len(identifier) <= 1024 and all(
char in valid_chars for char in identifier
)
class SQLScript:
"""
@@ -642,6 +768,24 @@ class SQLScript:
"""
return any(statement.is_mutating() for statement in self.statements)
def is_valid_ctas(self) -> bool:
"""
Check if the script contains a valid CTAS statement.
CTAS (`CREATE TABLE AS SELECT`) can only be run with scripts where the last
statement is a `SELECT`.
"""
return self.statements[-1].is_select()
def is_valid_cvas(self) -> bool:
"""
Check if the script contains a valid CVAS statement.
CVAS (`CREATE VIEW AS SELECT`) can only be run with scripts with a single
`SELECT` statement.
"""
return len(self.statements) == 1 and self.statements[0].is_select()
def extract_tables_from_statement(
statement: exp.Expression,
@@ -650,7 +794,7 @@ def extract_tables_from_statement(
"""
Extract all table references in a single statement.
Please not that this is not trivial; consider the following queries:
Please note that this is not trivial; consider the following queries:
DESCRIBE some_table;
SHOW PARTITIONS FROM some_table;
+12 -11
View File
@@ -20,11 +20,11 @@ from __future__ import annotations
import logging
import time
from contextlib import closing
from typing import Any
from typing import Any, cast
from superset import app
from superset.models.core import Database
from superset.sql_parse import ParsedQuery
from superset.sql.parse import SQLScript, SQLStatement
from superset.sql_validators.base import BaseSQLValidator, SQLValidationAnnotation
from superset.utils.core import QuerySource
@@ -46,17 +46,15 @@ class PrestoDBSQLValidator(BaseSQLValidator):
@classmethod
def validate_statement(
cls,
statement: str,
statement: SQLStatement,
database: Database,
cursor: Any,
) -> SQLValidationAnnotation | None:
# pylint: disable=too-many-locals
db_engine_spec = database.db_engine_spec
parsed_query = ParsedQuery(statement, engine=db_engine_spec.engine)
sql = parsed_query.stripped()
# Hook to allow environment-specific mutation (usually comments) to the SQL
sql = database.mutate_sql_based_on_config(sql)
sql = database.mutate_sql_based_on_config(str(statement))
# Transform the final statement to an explain call before sending it on
# to presto to validate
@@ -155,10 +153,9 @@ class PrestoDBSQLValidator(BaseSQLValidator):
For example, "SELECT 1 FROM default.mytable" becomes "EXPLAIN (TYPE
VALIDATE) SELECT 1 FROM default.mytable.
"""
parsed_query = ParsedQuery(sql, engine=database.db_engine_spec.engine)
statements = parsed_query.get_statements()
parsed_script = SQLScript(sql, engine=database.db_engine_spec.engine)
logger.info("Validating %i statement(s)", len(statements))
logger.info("Validating %i statement(s)", len(parsed_script.statements))
# todo(hughhh): update this to use new database.get_raw_connection()
# this function keeps stalling CI
with database.get_sqla_engine(
@@ -171,8 +168,12 @@ class PrestoDBSQLValidator(BaseSQLValidator):
annotations: list[SQLValidationAnnotation] = []
with closing(engine.raw_connection()) as conn:
cursor = conn.cursor()
for statement in parsed_query.get_statements():
annotation = cls.validate_statement(statement, database, cursor)
for statement in parsed_script.statements:
annotation = cls.validate_statement(
cast(SQLStatement, statement),
database,
cursor,
)
if annotation:
annotations.append(annotation)
logger.debug("Validation found %i error(s)", len(annotations))
+2 -6
View File
@@ -26,7 +26,6 @@ from jinja2.meta import find_undeclared_variables
from superset import is_feature_enabled
from superset.commands.sql_lab.execute import SqlQueryRender
from superset.errors import SupersetErrorType
from superset.sql_parse import ParsedQuery
from superset.sqllab.exceptions import SqlLabException
from superset.utils import core as utils
@@ -58,12 +57,9 @@ class SqlQueryRenderImpl(SqlQueryRender):
database=query_model.database, query=query_model
)
parsed_query = ParsedQuery(
query_model.sql,
engine=query_model.database.db_engine_spec.engine,
)
rendered_query = sql_template_processor.process_template(
parsed_query.stripped(), **execution_context.template_params
query_model.sql.strip().strip(";"),
**execution_context.template_params,
)
self._validate(execution_context, rendered_query, sql_template_processor)
return rendered_query
@@ -1964,6 +1964,7 @@ class TestChartApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCase):
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
data["result"].sort(key=lambda x: x["datasource_id"])
assert data["result"][0]["slice_name"] == "name0"
assert data["result"][0]["datasource_id"] == 1
+23 -1
View File
@@ -139,7 +139,29 @@ class TestImportAssetsCommand(SupersetTestCase):
dataset = chart.table
assert str(dataset.uuid) == dataset_config["uuid"]
assert chart.query_context is None
assert json.loads(chart.query_context) == {
"datasource": {"id": dataset.id, "type": "table"},
"force": False,
"queries": [
{
"annotation_layers": [],
"applied_time_extras": {},
"columns": [],
"custom_form_data": {},
"custom_params": {},
"extras": {"having": "", "time_grain_sqla": None, "where": ""},
"filters": [],
"metrics": [],
"order_desc": True,
"row_limit": 5000,
"time_range": " : ",
"timeseries_limit": 0,
"url_params": {},
}
],
"result_format": "json",
"result_type": "full",
}
assert json.loads(chart.params)["datasource"] == dataset.uid
database = dataset.database
@@ -81,7 +81,7 @@ class TestDashboardApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCas
"slug": "slug1_changed",
"position_json": '{"b": "B"}',
"css": "css_changed",
"json_metadata": '{"refresh_frequency": 30, "timed_refresh_immune_slices": [], "expanded_slices": {}, "color_scheme": "", "label_colors": {}, "shared_label_colors": {}, "color_scheme_domain": [], "cross_filters_enabled": false}',
"json_metadata": '{"refresh_frequency": 30, "timed_refresh_immune_slices": [], "expanded_slices": {}, "color_scheme": "", "label_colors": {}, "shared_label_colors": [], "map_label_colors": {}, "color_scheme_domain": [], "cross_filters_enabled": false}',
"published": False,
}

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