Merge branch 'master' into uplift-cypress-to-v13

Signed-off-by: hainenber <dotronghai96@gmail.com>
This commit is contained in:
hainenber 2024-06-25 21:23:52 +07:00
commit b46baffb09
No known key found for this signature in database
187 changed files with 2389 additions and 1275 deletions

View File

@ -63,9 +63,12 @@ github:
# combination here. # combination here.
contexts: contexts:
- lint-check - lint-check
- cypress-matrix (0, chrome)
- cypress-matrix (1, chrome) - cypress-matrix (1, chrome)
- cypress-matrix (2, chrome) - cypress-matrix (2, chrome)
- cypress-matrix (3, chrome) - cypress-matrix (3, chrome)
- cypress-matrix (4, chrome)
- cypress-matrix (5, chrome)
- frontend-build - frontend-build
- pre-commit - pre-commit
- python-lint - python-lint

View File

@ -5,7 +5,7 @@ on:
- "superset/migrations/**" - "superset/migrations/**"
branches: branches:
- "master" - "master"
- "[0-9].[0-9]" - "[0-9].[0-9]*"
pull_request: pull_request:
paths: paths:
- "superset/migrations/**" - "superset/migrations/**"

View File

@ -2,7 +2,7 @@ name: "CodeQL"
on: on:
push: push:
branches: ["master", "[0-9].[0-9]"] branches: ["master", "[0-9].[0-9]*"]
pull_request: pull_request:
# The branches below must be a subset of the branches above # The branches below must be a subset of the branches above
branches: ["master"] branches: ["master"]

View File

@ -4,7 +4,7 @@ on:
push: push:
branches: branches:
- "master" - "master"
- "[0-9].[0-9]" - "[0-9].[0-9]*"
pull_request: pull_request:
branches: branches:
- "master" - "master"

View File

@ -4,7 +4,7 @@ on:
push: push:
branches: branches:
- "master" - "master"
- "[0-9].[0-9]" - "[0-9].[0-9]*"
jobs: jobs:
config: config:

View File

@ -4,7 +4,7 @@ on:
push: push:
branches: branches:
- "master" - "master"
- "[0-9].[0-9]" - "[0-9].[0-9]*"
jobs: jobs:
config: config:

View File

@ -4,7 +4,7 @@ on:
push: push:
branches: branches:
- "master" - "master"
- "[0-9].[0-9]" - "[0-9].[0-9]*"
pull_request: pull_request:
types: [synchronize, opened, reopened, ready_for_review] types: [synchronize, opened, reopened, ready_for_review]

View File

@ -4,7 +4,7 @@ on:
push: push:
branches: branches:
- "master" - "master"
- "[0-9].[0-9]" - "[0-9].[0-9]*"
pull_request: pull_request:
types: [synchronize, opened, reopened, ready_for_review] types: [synchronize, opened, reopened, ready_for_review]

View File

@ -4,7 +4,7 @@ on:
push: push:
branches: branches:
- "master" - "master"
- "[0-9].[0-9]" - "[0-9].[0-9]*"
paths: paths:
- "superset-frontend/src/**" - "superset-frontend/src/**"
pull_request: pull_request:

View File

@ -4,7 +4,7 @@ on:
push: push:
branches: branches:
- "master" - "master"
- "[0-9].[0-9]" - "[0-9].[0-9]*"
jobs: jobs:
config: config:

View File

@ -4,7 +4,7 @@ on:
push: push:
branches: branches:
- "master" - "master"
- "[0-9].[0-9]" - "[0-9].[0-9]*"
pull_request: pull_request:
types: [synchronize, opened, reopened, ready_for_review] types: [synchronize, opened, reopened, ready_for_review]

View File

@ -4,13 +4,13 @@ on:
push: push:
branches: branches:
- "master" - "master"
- "[0-9].[0-9]" - "[0-9].[0-9]*"
pull_request: pull_request:
types: [synchronize, opened, reopened, ready_for_review] types: [synchronize, opened, reopened, ready_for_review]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
use_dashboard: use_dashboard:
description: 'Use Cypress Dashboard (true/false) [paid service - trigger manually when needed]' description: 'Use Cypress Dashboard (true/false) [paid service - trigger manually when needed]. You MUST provide a branch and/or PR number below for this to work.'
required: false required: false
default: 'false' default: 'false'
ref: ref:
@ -130,12 +130,12 @@ jobs:
CYPRESS_BROWSER: ${{ matrix.browser }} CYPRESS_BROWSER: ${{ matrix.browser }}
PARALLEL_ID: ${{ matrix.parallel_id }} PARALLEL_ID: ${{ matrix.parallel_id }}
PARALLELISM: 6 PARALLELISM: 6
CYPRESS_KEY: YjljODE2MzAtODcwOC00NTA3LWE4NmMtMTU3YmFmMjIzOTRhCg== CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
with: with:
run: cypress-run-all ${{ env.USE_DASHBOARD }} run: cypress-run-all ${{ env.USE_DASHBOARD }}
- name: Upload Artifacts - name: Upload Artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: github.event_name == 'workflow_dispatch' && (steps.check.outputs.python || steps.check.outputs.frontend) if: github.event_name == 'workflow_dispatch' && (steps.check.outputs.python || steps.check.outputs.frontend)
with: with:
name: screenshots
path: ${{ github.workspace }}/superset-frontend/cypress-base/cypress/screenshots path: ${{ github.workspace }}/superset-frontend/cypress-base/cypress/screenshots
name: cypress-artifact-${{ github.run_id }}-${{ github.job }}

View File

@ -4,7 +4,7 @@ on:
push: push:
branches: branches:
- "master" - "master"
- "[0-9].[0-9]" - "[0-9].[0-9]*"
pull_request: pull_request:
types: [synchronize, opened, reopened, ready_for_review] types: [synchronize, opened, reopened, ready_for_review]

View File

@ -4,7 +4,7 @@ on:
push: push:
branches: branches:
- "master" - "master"
- "[0-9].[0-9]" - "[0-9].[0-9]*"
paths: paths:
- "helm/**" - "helm/**"

View File

@ -5,7 +5,7 @@ on:
push: push:
branches: branches:
- "master" - "master"
- "[0-9].[0-9]" - "[0-9].[0-9]*"
pull_request: pull_request:
types: [synchronize, opened, reopened, ready_for_review] types: [synchronize, opened, reopened, ready_for_review]
@ -24,7 +24,7 @@ jobs:
mysql+mysqldb://superset:superset@127.0.0.1:13306/superset?charset=utf8mb4&binary_prefix=true mysql+mysqldb://superset:superset@127.0.0.1:13306/superset?charset=utf8mb4&binary_prefix=true
services: services:
mysql: mysql:
image: mysql:5.7 image: mysql:8.0
env: env:
MYSQL_ROOT_PASSWORD: root MYSQL_ROOT_PASSWORD: root
ports: ports:

View File

@ -5,7 +5,7 @@ on:
push: push:
branches: branches:
- "master" - "master"
- "[0-9].[0-9]" - "[0-9].[0-9]*"
pull_request: pull_request:
types: [synchronize, opened, reopened, ready_for_review] types: [synchronize, opened, reopened, ready_for_review]

View File

@ -5,7 +5,7 @@ on:
push: push:
branches: branches:
- "master" - "master"
- "[0-9].[0-9]" - "[0-9].[0-9]*"
pull_request: pull_request:
types: [synchronize, opened, reopened, ready_for_review] types: [synchronize, opened, reopened, ready_for_review]

View File

@ -5,7 +5,7 @@ on:
push: push:
branches: branches:
- "master" - "master"
- "[0-9].[0-9]" - "[0-9].[0-9]*"
pull_request: pull_request:
types: [synchronize, opened, reopened, ready_for_review] types: [synchronize, opened, reopened, ready_for_review]

View File

@ -4,7 +4,7 @@ on:
push: push:
branches: branches:
- "master" - "master"
- "[0-9].[0-9]" - "[0-9].[0-9]*"
pull_request: pull_request:
types: [synchronize, opened, reopened, ready_for_review] types: [synchronize, opened, reopened, ready_for_review]

View File

@ -3,7 +3,7 @@ on:
push: push:
branches: branches:
- "master" - "master"
- "[0-9].[0-9]" - "[0-9].[0-9]*"
paths: paths:
- "superset-websocket/**" - "superset-websocket/**"
pull_request: pull_request:

View File

@ -4,7 +4,7 @@ on:
push: push:
branches: branches:
- master - master
- "[0-9].[0-9]" - "[0-9].[0-9]*"
jobs: jobs:
config: config:

View File

@ -71,3 +71,4 @@ snowflake.svg
# docs-related # docs-related
erd.puml erd.puml
erd.svg erd.svg
intro_header.txt

View File

@ -1,7 +1,3 @@
---
hide_title: true
sidebar_position: 1
---
<!-- <!--
Licensed to the Apache Software Foundation (ASF) under one Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file or more contributor license agreements. See the NOTICE file

View File

@ -89,6 +89,7 @@ These features flags currently default to True and **will be removed in a future
[//]: # "PLEASE KEEP THE LIST SORTED ALPHABETICALLY" [//]: # "PLEASE KEEP THE LIST SORTED ALPHABETICALLY"
- AVOID_COLORS_COLLISION
- DASHBOARD_CROSS_FILTERS - DASHBOARD_CROSS_FILTERS
- ENABLE_JAVASCRIPT_CONTROLS - ENABLE_JAVASCRIPT_CONTROLS
- KV_STORE - KV_STORE

View File

@ -154,6 +154,7 @@ Join our growing community!
### Healthcare ### Healthcare
- [Amino](https://amino.com) [@shkr] - [Amino](https://amino.com) [@shkr]
- [Beans](https://www.beans.fi) [@kakoni] - [Beans](https://www.beans.fi) [@kakoni]
- [Bluesquare](https://www.bluesquarehub.com/) [@madewulf]
- [Care](https://www.getcare.io/)[@alandao2021] - [Care](https://www.getcare.io/)[@alandao2021]
- [Living Goods](https://www.livinggoods.org) [@chelule] - [Living Goods](https://www.livinggoods.org) [@chelule]
- [Maieutical Labs](https://maieuticallabs.it) [@xrmx] - [Maieutical Labs](https://maieuticallabs.it) [@xrmx]
@ -171,6 +172,7 @@ Join our growing community!
- [RIS3 Strategy of CZ, MIT CR](https://www.ris3.cz/) [@RIS3CZ] - [RIS3 Strategy of CZ, MIT CR](https://www.ris3.cz/) [@RIS3CZ]
### Travel ### Travel
- [Agoda](https://www.agoda.com/) [@lostseaway, @maiake, @obombayo]
- [Skyscanner](https://www.skyscanner.net/) [@cleslie, @stanhoucke] - [Skyscanner](https://www.skyscanner.net/) [@cleslie, @stanhoucke]
### Others ### Others

View File

@ -24,6 +24,10 @@ assists people when migrating to a new version.
## Next ## Next
- [29274](https://github.com/apache/superset/pull/29274): We made it easier to trigger CI on your
forks, whether they are public or private. Simply push to a branch that fits `[0-9].[0-9]*` and
should run on your fork, giving you flexibility on naming your release branches and triggering
CI
- [27505](https://github.com/apache/superset/pull/27505): We simplified the files under - [27505](https://github.com/apache/superset/pull/27505): We simplified the files under
`requirements/` folder. If you use these files for your builds you may want to double `requirements/` folder. If you use these files for your builds you may want to double
check that your builds are not affected. `base.txt` should be the same as before, though check that your builds are not affected. `base.txt` should be the same as before, though

View File

@ -97,7 +97,6 @@ ALERT_REPORTS_NOTIFICATION_DRY_RUN = True
WEBDRIVER_BASEURL = "http://superset:8088/" # When using docker compose baseurl should be http://superset_app:8088/ WEBDRIVER_BASEURL = "http://superset:8088/" # When using docker compose baseurl should be http://superset_app:8088/
# The base URL for the email report hyperlinks. # The base URL for the email report hyperlinks.
WEBDRIVER_BASEURL_USER_FRIENDLY = WEBDRIVER_BASEURL WEBDRIVER_BASEURL_USER_FRIENDLY = WEBDRIVER_BASEURL
SQLLAB_CTAS_NO_LIMIT = True SQLLAB_CTAS_NO_LIMIT = True
# #

View File

@ -2,6 +2,12 @@
title: CVEs fixed by release title: CVEs fixed by release
sidebar_position: 2 sidebar_position: 2
--- ---
#### Version 3.1.3, 4.0.1
| CVE | Title | Affected |
|:---------------|:----------------------------|----------------------------:|
| CVE-2024-34693 | Server arbitrary file read | < 3.1.3, >= 4.0.0, < 4.0.1 |
#### Version 3.1.2 #### Version 3.1.2
| CVE | Title | Affected | | CVE | Title | Affected |

View File

@ -5,13 +5,13 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"scripts": { "scripts": {
"docusaurus": "docusaurus", "docusaurus": "docusaurus",
"_init": "cp ../README.md docs/intro.md", "_init": "cat src/intro_header.txt ../README.md > docs/intro.md",
"start": "npm run _init && docusaurus start", "start": "npm run _init && docusaurus start",
"build": "npm run _init && docusaurus build", "build": "npm run _init && docusaurus build",
"swizzle": "docusaurus swizzle", "swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy", "deploy": "docusaurus deploy",
"clear": "docusaurus clear", "clear": "docusaurus clear",
"serve": "docusaurus serve", "serve": "npm run _init && docusaurus serve",
"write-translations": "docusaurus write-translations", "write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids", "write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc" "typecheck": "tsc"

View File

@ -0,0 +1,4 @@
---
hide_title: true
sidebar_position: 1
---

View File

@ -10350,14 +10350,14 @@ write-file-atomic@^3.0.3:
typedarray-to-buffer "^3.1.5" typedarray-to-buffer "^3.1.5"
ws@^7.3.1: ws@^7.3.1:
version "7.5.9" version "7.5.10"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591" resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9"
integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q== integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==
ws@^8.13.0: ws@^8.13.0:
version "8.17.0" version "8.17.1"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.0.tgz#d145d18eca2ed25aaf791a183903f7be5e295fea" resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b"
integrity sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow== integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==
xdg-basedir@^5.0.1, xdg-basedir@^5.1.0: xdg-basedir@^5.0.1, xdg-basedir@^5.1.0:
version "5.1.0" version "5.1.0"

View File

@ -24,6 +24,7 @@ from datetime import datetime
XVFB_PRE_CMD = "xvfb-run --auto-servernum --server-args='-screen 0, 1024x768x24' " XVFB_PRE_CMD = "xvfb-run --auto-servernum --server-args='-screen 0, 1024x768x24' "
REPO = os.getenv("GITHUB_REPOSITORY") or "apache/superset" REPO = os.getenv("GITHUB_REPOSITORY") or "apache/superset"
GITHUB_EVENT_NAME = os.getenv("GITHUB_REPOSITORY") or "push" GITHUB_EVENT_NAME = os.getenv("GITHUB_REPOSITORY") or "push"
CYPRESS_RECORD_KEY = os.getenv("CYPRESS_RECORD_KEY") or ""
def compute_hash(file_path: str) -> str: def compute_hash(file_path: str) -> str:
@ -55,13 +56,7 @@ def get_cypress_cmd(
if use_dashboard: if use_dashboard:
# Run using cypress.io service # Run using cypress.io service
cypress_key = os.getenv("CYPRESS_KEY") spec: str = "cypress/e2e/*/**/*"
command = f"echo {cypress_key} | base64 --decode"
cypress_record_key = (
subprocess.check_output(command, shell=True).decode("utf-8").strip()
)
os.environ["CYPRESS_RECORD_KEY"] = cypress_record_key
spec: str = "*/**/*"
cmd = ( cmd = (
f"{XVFB_PRE_CMD} " f"{XVFB_PRE_CMD} "
f'{cypress_cmd} --spec "{spec}" --browser {browser} ' f'{cypress_cmd} --spec "{spec}" --browser {browser} '
@ -70,7 +65,7 @@ def get_cypress_cmd(
) )
else: else:
# Run local, but split the execution # Run local, but split the execution
os.environ.pop("CYPRESS_KEY", None) os.environ.pop("CYPRESS_RECORD_KEY", None)
spec_list_str = ",".join(sorted(spec_list)) spec_list_str = ",".join(sorted(spec_list))
if _filter: if _filter:
spec_list_str = ",".join(sorted([s for s in spec_list if _filter in s])) spec_list_str = ",".join(sorted([s for s in spec_list if _filter in s]))

View File

@ -61,7 +61,10 @@ function test_init() {
DB_NAME="test" DB_NAME="test"
DB_USER="superset" DB_USER="superset"
DB_PASSWORD="superset" DB_PASSWORD="superset"
# Pointing to use the test database in local docker-compose setup
export SUPERSET__SQLALCHEMY_DATABASE_URI=${SUPERSET__SQLALCHEMY_DATABASE_URI:-postgresql+psycopg2://"${DB_USER}":"${DB_PASSWORD}"@localhost/"${DB_NAME}"} export SUPERSET__SQLALCHEMY_DATABASE_URI=${SUPERSET__SQLALCHEMY_DATABASE_URI:-postgresql+psycopg2://"${DB_USER}":"${DB_PASSWORD}"@localhost/"${DB_NAME}"}
export SUPERSET_CONFIG=${SUPERSET_CONFIG:-tests.integration_tests.superset_test_config} export SUPERSET_CONFIG=${SUPERSET_CONFIG:-tests.integration_tests.superset_test_config}
RUN_INIT=1 RUN_INIT=1
RUN_RESET_DB=1 RUN_RESET_DB=1

View File

@ -8169,9 +8169,9 @@
} }
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "7.5.7", "version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
"integrity": "sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A==", "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": ">=8.3.0" "node": ">=8.3.0"
@ -14349,9 +14349,9 @@
} }
}, },
"ws": { "ws": {
"version": "7.5.7", "version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
"integrity": "sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A==", "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
"dev": true, "dev": true,
"requires": {} "requires": {}
}, },

View File

@ -30,7 +30,7 @@ export default eyesPlugin(
video: false, video: false,
viewportWidth: 1280, viewportWidth: 1280,
viewportHeight: 1024, viewportHeight: 1024,
projectId: 'ukwxzo', projectId: 'ud5x2f',
retries: { retries: {
runMode: 2, runMode: 2,
openMode: 0, openMode: 0,

View File

@ -124,7 +124,7 @@ function selectColorScheme(color: string) {
) )
.first() .first()
.click(); .click();
cy.getBySel(color).click(); cy.getBySel(color).click({ force: true });
} }
function applyChanges() { function applyChanges() {
@ -169,6 +169,7 @@ function writeMetadata(metadata: string) {
function openExplore(chartName: string) { function openExplore(chartName: string) {
interceptExploreJson(); interceptExploreJson();
interceptGet();
cy.get( cy.get(
`[data-test-chart-name='${chartName}'] [aria-label='More Options']`, `[data-test-chart-name='${chartName}'] [aria-label='More Options']`,
@ -210,7 +211,7 @@ describe('Dashboard edit', { testIsolation: false }, () => {
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol', '[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
) )
.first() .first()
.should('have.css', 'fill', 'rgb(69, 78, 124)'); .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', () => {
@ -231,7 +232,7 @@ describe('Dashboard edit', { testIsolation: false }, () => {
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol', '[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
) )
.first() .first()
.should('have.css', 'fill', 'rgb(0, 234, 162)'); .should('have.css', 'fill', 'rgb(50, 0, 167)');
// open 2nd main tab // open 2nd main tab
openTab(0, 1); openTab(0, 1);
@ -240,7 +241,7 @@ describe('Dashboard edit', { testIsolation: false }, () => {
// label Anthony // label Anthony
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol') cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.eq(2) .eq(2)
.should('have.css', 'fill', 'rgb(0, 234, 162)'); .should('have.css', 'fill', 'rgb(50, 0, 167)');
}); });
it('should apply same color to same labels with no color scheme set', () => { it('should apply same color to same labels with no color scheme set', () => {
@ -261,7 +262,7 @@ describe('Dashboard edit', { testIsolation: false }, () => {
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol', '[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
) )
.first() .first()
.should('have.css', 'fill', 'rgb(69, 78, 124)'); .should('have.css', 'fill', 'rgb(31, 168, 201)');
// open 2nd main tab // open 2nd main tab
openTab(0, 1); openTab(0, 1);
@ -270,7 +271,7 @@ describe('Dashboard edit', { testIsolation: false }, () => {
// label Anthony // label Anthony
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol') cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.eq(2) .eq(2)
.should('have.css', 'fill', 'rgb(69, 78, 124)'); .should('have.css', 'fill', 'rgb(31, 168, 201)');
}); });
it('custom label colors should take the precedence in nested tabs', () => { it('custom label colors should take the precedence in nested tabs', () => {
@ -384,17 +385,17 @@ describe('Dashboard edit', { testIsolation: false }, () => {
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol', '[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
) )
.first() .first()
.should('have.css', 'fill', 'rgb(69, 78, 124)'); .should('have.css', 'fill', 'rgb(31, 168, 201)');
cy.get( cy.get(
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol', '[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
) )
.eq(1) .eq(1)
.should('have.css', 'fill', 'rgb(224, 67, 85)'); .should('have.css', 'fill', 'rgb(69, 78, 124)');
cy.get( cy.get(
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol', '[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
) )
.eq(2) .eq(2)
.should('have.css', 'fill', 'rgb(163, 143, 121)'); .should('have.css', 'fill', 'rgb(90, 193, 137)');
openProperties(); openProperties();
cy.get('[aria-label="Select color scheme"]').should('have.value', ''); cy.get('[aria-label="Select color scheme"]').should('have.value', '');
@ -423,17 +424,17 @@ describe('Dashboard edit', { testIsolation: false }, () => {
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol', '[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
) )
.first() .first()
.should('have.css', 'fill', 'rgb(69, 78, 124)'); .should('have.css', 'fill', 'rgb(31, 168, 201)');
cy.get( cy.get(
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol', '[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
) )
.eq(1) .eq(1)
.should('have.css', 'fill', 'rgb(224, 67, 85)'); .should('have.css', 'fill', 'rgb(69, 78, 124)');
cy.get( cy.get(
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol', '[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
) )
.eq(2) .eq(2)
.should('have.css', 'fill', 'rgb(163, 143, 121)'); .should('have.css', 'fill', 'rgb(90, 193, 137)');
}); });
it('should show the same colors in Explore', () => { it('should show the same colors in Explore', () => {
@ -459,12 +460,6 @@ describe('Dashboard edit', { testIsolation: false }, () => {
) )
.first() .first()
.should('have.css', 'fill', 'rgb(255, 0, 0)'); .should('have.css', 'fill', 'rgb(255, 0, 0)');
// label Christopher
cy.get(
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.eq(1)
.should('have.css', 'fill', 'rgb(172, 32, 119)');
openExplore('Top 10 California Names Timeseries'); openExplore('Top 10 California Names Timeseries');
@ -472,10 +467,6 @@ describe('Dashboard edit', { testIsolation: false }, () => {
cy.get('[data-test="chart-container"] .line .nv-legend-symbol') cy.get('[data-test="chart-container"] .line .nv-legend-symbol')
.first() .first()
.should('have.css', 'fill', 'rgb(255, 0, 0)'); .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(172, 32, 119)');
}); });
it.skip('should change color scheme multiple times', () => { it.skip('should change color scheme multiple times', () => {
@ -496,7 +487,7 @@ describe('Dashboard edit', { testIsolation: false }, () => {
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol', '[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
) )
.first() .first()
.should('have.css', 'fill', 'rgb(51, 61, 71)'); .should('have.css', 'fill', 'rgb(234, 11, 140)');
// open 2nd main tab // open 2nd main tab
openTab(0, 1); openTab(0, 1);
@ -505,7 +496,7 @@ describe('Dashboard edit', { testIsolation: false }, () => {
// label Anthony // label Anthony
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol') cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.eq(2) .eq(2)
.should('have.css', 'fill', 'rgb(51, 61, 71)'); .should('have.css', 'fill', 'rgb(234, 11, 140)');
editDashboard(); editDashboard();
openProperties(); openProperties();
@ -516,7 +507,7 @@ describe('Dashboard edit', { testIsolation: false }, () => {
// label Anthony // label Anthony
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol') cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.eq(2) .eq(2)
.should('have.css', 'fill', 'rgb(244, 176, 42)'); .should('have.css', 'fill', 'rgb(41, 105, 107)');
// open main tab and nested tab // open main tab and nested tab
openTab(0, 0); openTab(0, 0);
@ -527,7 +518,7 @@ describe('Dashboard edit', { testIsolation: false }, () => {
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol', '[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
) )
.first() .first()
.should('have.css', 'fill', 'rgb(244, 176, 42)'); .should('have.css', 'fill', 'rgb(41, 105, 107)');
}); });
it.skip('should apply the color scheme across main tabs', () => { it.skip('should apply the color scheme across main tabs', () => {
@ -542,7 +533,7 @@ describe('Dashboard edit', { testIsolation: false }, () => {
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol') cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.first() .first()
.should('have.css', 'fill', 'rgb(51, 61, 71)'); .should('have.css', 'fill', 'rgb(234, 11, 140)');
}); });
it.skip('should apply the color scheme across main tabs for rendered charts', () => { it.skip('should apply the color scheme across main tabs for rendered charts', () => {
@ -558,7 +549,7 @@ describe('Dashboard edit', { testIsolation: false }, () => {
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol') cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.first() .first()
.should('have.css', 'fill', 'rgb(156, 52, 152)'); .should('have.css', 'fill', 'rgb(41, 105, 107)');
// change scheme now that charts are rendered across the main tabs // change scheme now that charts are rendered across the main tabs
editDashboard(); editDashboard();
@ -588,7 +579,7 @@ describe('Dashboard edit', { testIsolation: false }, () => {
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol', '[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
) )
.first() .first()
.should('have.css', 'fill', 'rgb(51, 61, 71)'); .should('have.css', 'fill', 'rgb(234, 11, 140)');
// open another nested tab // open another nested tab
openTab(2, 1); openTab(2, 1);

View File

@ -97,8 +97,5 @@ describe('Visualization > Compare', { testIsolation: false }, () => {
cy.get( cy.get(
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="supersetColors"]', '.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="supersetColors"]',
).should('exist'); ).should('exist');
cy.get('.compare .nv-legend .nv-legend-symbol')
.first()
.should('have.css', 'fill', 'rgb(31, 168, 201)');
}); });
}); });

View File

@ -90,9 +90,6 @@ describe(
cy.get( cy.get(
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="bnbColors"]', '.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="bnbColors"]',
).should('exist'); ).should('exist');
cy.get('.dist_bar .nv-legend .nv-legend-symbol')
.first()
.should('have.css', 'fill', 'rgb(41, 105, 107)');
}); });
}, },
); );

View File

@ -5903,12 +5903,12 @@
} }
}, },
"node_modules/@floating-ui/dom": { "node_modules/@floating-ui/dom": {
"version": "1.6.1", "version": "1.6.5",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.1.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.5.tgz",
"integrity": "sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==", "integrity": "sha512-Nsdud2X65Dz+1RHjAIP0t8z5e2ff/IRbei6BqFrl1urT8sDVzM1HMQ+R0XcU5ceRfyO3I6ayeqIfh+6Wb8LGTw==",
"dependencies": { "dependencies": {
"@floating-ui/core": "^1.6.0", "@floating-ui/core": "^1.0.0",
"@floating-ui/utils": "^0.2.1" "@floating-ui/utils": "^0.2.0"
} }
}, },
"node_modules/@floating-ui/react-dom": { "node_modules/@floating-ui/react-dom": {
@ -32060,9 +32060,9 @@
} }
}, },
"node_modules/dayjs": { "node_modules/dayjs": {
"version": "1.10.7", "version": "1.11.10",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.7.tgz", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz",
"integrity": "sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig==" "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="
}, },
"node_modules/deasync": { "node_modules/deasync": {
"version": "0.1.29", "version": "0.1.29",
@ -68993,7 +68993,7 @@
"d3-array": "^1.2.4", "d3-array": "^1.2.4",
"d3-color": "^1.4.1", "d3-color": "^1.4.1",
"d3-scale": "^3.0.0", "d3-scale": "^3.0.0",
"deck.gl": "9.0.6", "deck.gl": "9.0.12",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.30.1", "moment": "^2.30.1",
"mousetrap": "^1.6.5", "mousetrap": "^1.6.5",
@ -75280,12 +75280,12 @@
} }
}, },
"@floating-ui/dom": { "@floating-ui/dom": {
"version": "1.6.1", "version": "1.6.5",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.1.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.5.tgz",
"integrity": "sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==", "integrity": "sha512-Nsdud2X65Dz+1RHjAIP0t8z5e2ff/IRbei6BqFrl1urT8sDVzM1HMQ+R0XcU5ceRfyO3I6ayeqIfh+6Wb8LGTw==",
"requires": { "requires": {
"@floating-ui/core": "^1.6.0", "@floating-ui/core": "^1.0.0",
"@floating-ui/utils": "^0.2.1" "@floating-ui/utils": "^0.2.0"
} }
}, },
"@floating-ui/react-dom": { "@floating-ui/react-dom": {
@ -87294,7 +87294,7 @@
"d3-array": "^1.2.4", "d3-array": "^1.2.4",
"d3-color": "^1.4.1", "d3-color": "^1.4.1",
"d3-scale": "^3.0.0", "d3-scale": "^3.0.0",
"deck.gl": "9.0.6", "deck.gl": "9.0.12",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.30.1", "moment": "^2.30.1",
"mousetrap": "^1.6.5", "mousetrap": "^1.6.5",
@ -97251,9 +97251,9 @@
"dev": true "dev": true
}, },
"dayjs": { "dayjs": {
"version": "1.10.7", "version": "1.11.10",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.7.tgz", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz",
"integrity": "sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig==" "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="
}, },
"deasync": { "deasync": {
"version": "0.1.29", "version": "0.1.29",

View File

@ -362,6 +362,14 @@ const temporal_columns_lookup: SharedControlConfig<'HiddenControl'> = {
), ),
}; };
const sort_by_metric: SharedControlConfig<'CheckboxControl'> = {
type: 'CheckboxControl',
label: t('Sort by metric'),
description: t(
'Whether to sort results by the selected metric in descending order.',
),
};
export default { export default {
metrics: dndAdhocMetricsControl, metrics: dndAdhocMetricsControl,
metric: dndAdhocMetricControl, metric: dndAdhocMetricControl,
@ -400,4 +408,5 @@ export default {
show_empty_columns, show_empty_columns,
temporal_columns_lookup, temporal_columns_lookup,
currency_format, currency_format,
sort_by_metric,
}; };

View File

@ -17,19 +17,18 @@
* under the License. * under the License.
*/ */
/* eslint-disable no-dupe-class-members */
import { scaleOrdinal, ScaleOrdinal } from 'd3-scale'; import { scaleOrdinal, ScaleOrdinal } from 'd3-scale';
import { ExtensibleFunction } from '../models'; import { ExtensibleFunction } from '../models';
import { ColorsInitLookup, ColorsLookup } from './types'; import { ColorsInitLookup, ColorsLookup } from './types';
import stringifyAndTrim from './stringifyAndTrim'; import stringifyAndTrim from './stringifyAndTrim';
import getSharedLabelColor from './SharedLabelColorSingleton'; import getLabelsColorMap from './LabelsColorMapSingleton';
import { getAnalogousColors } from './utils'; import { getAnalogousColors } from './utils';
import { FeatureFlag, isFeatureEnabled } from '../utils'; import { FeatureFlag, isFeatureEnabled } from '../utils';
// Use type augmentation to correct the fact that // Use type augmentation to correct the fact that
// an instance of CategoricalScale is also a function // an instance of CategoricalScale is also a function
interface CategoricalColorScale { interface CategoricalColorScale {
(x: { toString(): string }, y?: number): string; (x: { toString(): string }, y?: number, w?: string): string;
} }
class CategoricalColorScale extends ExtensibleFunction { class CategoricalColorScale extends ExtensibleFunction {
@ -39,101 +38,183 @@ class CategoricalColorScale extends ExtensibleFunction {
scale: ScaleOrdinal<{ toString(): string }, string>; scale: ScaleOrdinal<{ toString(): string }, string>;
parentForcedColors: ColorsLookup;
forcedColors: ColorsLookup; forcedColors: ColorsLookup;
labelsColorMapInstance: ReturnType<typeof getLabelsColorMap>;
chartLabelsColorMap: Map<string, string>;
multiple: number; multiple: number;
/** /**
* Constructor * Constructor
* @param {*} colors an array of colors * @param {*} colors an array of colors
* @param {*} parentForcedColors optional parameter that comes from parent * @param {*} forcedColors optional parameter that comes from parent
* (usually CategoricalColorNamespace) and supersede this.forcedColors * (usually CategoricalColorNamespace)
*/ */
constructor(colors: string[], parentForcedColors: ColorsInitLookup = {}) { constructor(colors: string[], forcedColors: ColorsInitLookup = {}) {
super((value: string, sliceId?: number) => this.getColor(value, sliceId)); super((value: string, sliceId?: number, colorScheme?: string) =>
this.getColor(value, sliceId, colorScheme),
);
// holds original color scheme colors
this.originColors = colors; this.originColors = colors;
// holds the extended color range (includes analagous colors)
this.colors = colors; this.colors = colors;
// holds the values of this specific slice (label+color)
this.chartLabelsColorMap = new Map();
// shared color map instance (when context is shared, i.e. dashboard)
this.labelsColorMapInstance = getLabelsColorMap();
// holds the multiple value for analogous colors range
this.multiple = 0;
this.scale = scaleOrdinal<{ toString(): string }, string>(); this.scale = scaleOrdinal<{ toString(): string }, string>();
this.scale.range(colors); this.scale.range(colors);
// reserve fixed colors in parent map based on their index in the scale // reserve fixed colors in parent map based on their index in the scale
Object.entries(parentForcedColors).forEach(([key, value]) => { Object.entries(forcedColors).forEach(([key, value]) => {
if (typeof value === 'number') { if (typeof value === 'number') {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
parentForcedColors[key] = colors[value % colors.length]; forcedColors[key] = colors[value % colors.length];
} }
}); });
// all indexes have been replaced by a fixed color // forced colors from parent (usually CategoricalColorNamespace)
this.parentForcedColors = parentForcedColors as ColorsLookup; // currently used in dashboards to set custom label colors
this.forcedColors = {}; this.forcedColors = forcedColors as ColorsLookup;
this.multiple = 0;
} }
removeSharedLabelColorFromRange( /**
sharedColorMap: Map<string, string>, * Increment the color range with analogous colors
cleanedValue: string, */
) { incrementColorRange() {
// make sure we don't overwrite the origin colors const multiple = Math.floor(
const updatedRange = new Set(this.originColors); this.domain().length / this.originColors.length,
// remove the color option from shared color );
sharedColorMap.forEach((value: string, key: string) => { // the domain has grown larger than the original range
if (key !== cleanedValue) { // increments the range with analogous colors
updatedRange.delete(value); if (multiple > this.multiple) {
} this.multiple = multiple;
}); const newRange = getAnalogousColors(this.originColors, multiple);
// remove the color option from forced colors const extendedColors = this.originColors.concat(newRange);
Object.entries(this.parentForcedColors).forEach(([key, value]) => {
if (key !== cleanedValue) { this.range(extendedColors);
updatedRange.delete(value); this.colors = extendedColors;
} }
});
this.range(updatedRange.size > 0 ? [...updatedRange] : this.originColors);
} }
getColor(value?: string, sliceId?: number) { /**
* Get the color for a given value
*
* @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
* @returns the color or the next available color
*/
getColor(value?: string, sliceId?: number, colorScheme?: string): string {
const cleanedValue = stringifyAndTrim(value); const cleanedValue = stringifyAndTrim(value);
const sharedLabelColor = getSharedLabelColor(); // priority: forced color (i.e. custom label colors) > shared color > scale color
const sharedColorMap = sharedLabelColor.getColorMap(); const forcedColor = this.forcedColors?.[cleanedValue];
const sharedColor = sharedColorMap.get(cleanedValue); const isExistingLabel = this.chartLabelsColorMap.has(cleanedValue);
let color = forcedColor || this.scale(cleanedValue);
// priority: parentForcedColors > forcedColors > labelColors // a forced color will always be used independently of the usage count
let color = if (!forcedColor && !isExistingLabel) {
this.parentForcedColors?.[cleanedValue] || if (isFeatureEnabled(FeatureFlag.UseAnalagousColors)) {
this.forcedColors?.[cleanedValue] || this.incrementColorRange();
sharedColor; }
if (
// feature flag to be deprecated (will become standard behaviour)
isFeatureEnabled(FeatureFlag.AvoidColorsCollision) &&
this.isColorUsed(color)
) {
// fallback to least used color
color = this.getNextAvailableColor(color);
}
}
if (isFeatureEnabled(FeatureFlag.UseAnalagousColors)) { // keep track of values in this slice
const multiple = Math.floor( this.chartLabelsColorMap.set(cleanedValue, color);
this.domain().length / this.originColors.length,
// store the value+color in the LabelsColorMapSingleton
if (sliceId) {
this.labelsColorMapInstance.addSlice(
cleanedValue,
color,
sliceId,
colorScheme,
); );
if (multiple > this.multiple) {
this.multiple = multiple;
const newRange = getAnalogousColors(this.originColors, multiple);
this.range(this.originColors.concat(newRange));
}
} }
const newColor = this.scale(cleanedValue);
if (!color) {
color = newColor;
if (isFeatureEnabled(FeatureFlag.AvoidColorsCollision)) {
this.removeSharedLabelColorFromRange(sharedColorMap, cleanedValue);
color = this.scale(cleanedValue);
}
}
sharedLabelColor.addSlice(cleanedValue, color, sliceId);
return color; return color;
} }
/** /**
* Enforce specific color for given value * Verify if a color is used in this slice
*
* @param color
* @returns true if the color is used in this slice
*/
isColorUsed(color: string): boolean {
return this.getColorUsageCount(color) > 0;
}
/**
* Get the count of the color usage in this slice
*
* @param sliceId the ID of the current slice
* @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;
}
/**
* Lower chances of color collision by returning the least used color
* Checks across colors of current slice within LabelsColorMapSingleton
*
* @param currentColor the current color
* @returns the least used color that is not the excluded 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,
);
// fallback to currentColor color
if (!otherColors.length || hasNoneAvailable) {
return currentColor;
}
// Finding the least used color
const leastUsedColor = otherColors.reduce((min, entry) =>
entry.count < min.count ? entry : min,
).color;
return leastUsedColor;
}
/**
* Enforce specific color for a given value at the scale level
* Overrides any existing color and forced color for the given value
*
* @param {*} value value * @param {*} value value
* @param {*} forcedColor forcedColor * @param {*} forcedColor forcedColor
* @returns {CategoricalColorScale}
*/ */
setColor(value: string, forcedColor: string) { setColor(value: string, forcedColor: string) {
this.forcedColors[stringifyAndTrim(value)] = forcedColor; this.forcedColors[stringifyAndTrim(value)] = forcedColor;
@ -142,6 +223,7 @@ class CategoricalColorScale extends ExtensibleFunction {
/** /**
* Get a mapping of data values to colors * Get a mapping of data values to colors
*
* @returns an object where the key is the data value and the value is the hex color code * @returns an object where the key is the data value and the value is the hex color code
*/ */
getColorMap() { getColorMap() {
@ -153,22 +235,23 @@ class CategoricalColorScale extends ExtensibleFunction {
return { return {
...colorMap, ...colorMap,
...this.forcedColors, ...this.forcedColors,
...this.parentForcedColors,
}; };
} }
/** /**
* Returns an exact copy of this scale. Changes to this scale will not affect the returned scale, and vice versa. * Return an exact copy of this scale.
* Changes to this scale will not affect the returned scale and vice versa.
*
* @returns {CategoricalColorScale} A copy of this scale.
*/ */
copy() { copy() {
const copy = new CategoricalColorScale( const copy = new CategoricalColorScale(
this.scale.range(), this.scale.range(),
this.parentForcedColors, this.forcedColors,
); );
copy.forcedColors = { ...this.forcedColors }; copy.forcedColors = { ...this.forcedColors };
copy.domain(this.domain()); copy.domain(this.domain());
copy.unknown(this.unknown()); copy.unknown(this.unknown());
return copy; return copy;
} }

View File

@ -17,36 +17,37 @@
* under the License. * under the License.
*/ */
import { CategoricalColorNamespace } from '.';
import { makeSingleton } from '../utils'; import { makeSingleton } from '../utils';
export enum SharedLabelColorSource { export enum LabelsColorMapSource {
Dashboard, Dashboard,
Explore, Explore,
} }
export class SharedLabelColor {
sliceLabelMap: Map<number, string[]>; export class LabelsColorMap {
chartsLabelsMap: Map<number, { labels: string[]; scheme?: string }>;
colorMap: Map<string, string>; colorMap: Map<string, string>;
source: SharedLabelColorSource; source: LabelsColorMapSource;
constructor() { constructor() {
// { sliceId1: [label1, label2, ...], sliceId2: [label1, label2, ...] } // holds labels and original color schemes for each chart in context
this.sliceLabelMap = new Map(); this.chartsLabelsMap = new Map();
this.colorMap = new Map(); this.colorMap = new Map();
this.source = SharedLabelColorSource.Dashboard; this.source = LabelsColorMapSource.Dashboard;
} }
updateColorMap(colorNamespace?: string, colorScheme?: string) { updateColorMap(categoricalNamespace: any, colorScheme?: string) {
const categoricalNamespace =
CategoricalColorNamespace.getNamespace(colorNamespace);
const newColorMap = new Map(); const newColorMap = new Map();
this.colorMap.clear(); this.colorMap.clear();
this.sliceLabelMap.forEach(labels => { this.chartsLabelsMap.forEach((chartConfig, sliceId) => {
const colorScale = categoricalNamespace.getScale(colorScheme); const { labels, scheme: originalChartColorScheme } = chartConfig;
const currentColorScheme = colorScheme || originalChartColorScheme;
const colorScale = categoricalNamespace.getScale(currentColorScheme);
labels.forEach(label => { labels.forEach(label => {
const newColor = colorScale(label); const newColor = colorScale.getColor(label, sliceId);
newColorMap.set(label, newColor); newColorMap.set(label, newColor);
}); });
}); });
@ -57,25 +58,37 @@ export class SharedLabelColor {
return this.colorMap; return this.colorMap;
} }
addSlice(label: string, color: string, sliceId?: number) { addSlice(
if ( label: string,
this.source !== SharedLabelColorSource.Dashboard || color: string,
sliceId === undefined sliceId: number,
) colorScheme?: string,
return; ) {
const labels = this.sliceLabelMap.get(sliceId) || []; if (this.source !== LabelsColorMapSource.Dashboard) return;
const chartConfig = this.chartsLabelsMap.get(sliceId) || {
labels: [],
scheme: '',
};
const { labels } = chartConfig;
if (!labels.includes(label)) { if (!labels.includes(label)) {
labels.push(label); labels.push(label);
this.sliceLabelMap.set(sliceId, labels); this.chartsLabelsMap.set(sliceId, {
labels,
scheme: colorScheme,
});
} }
this.colorMap.set(label, color); this.colorMap.set(label, color);
} }
removeSlice(sliceId: number) { removeSlice(sliceId: number) {
if (this.source !== SharedLabelColorSource.Dashboard) return; if (this.source !== LabelsColorMapSource.Dashboard) return;
this.sliceLabelMap.delete(sliceId);
this.chartsLabelsMap.delete(sliceId);
const newColorMap = new Map(); const newColorMap = new Map();
this.sliceLabelMap.forEach(labels => {
this.chartsLabelsMap.forEach(chartConfig => {
const { labels } = chartConfig;
labels.forEach(label => { labels.forEach(label => {
newColorMap.set(label, this.colorMap.get(label)); newColorMap.set(label, this.colorMap.get(label));
}); });
@ -83,19 +96,12 @@ export class SharedLabelColor {
this.colorMap = newColorMap; this.colorMap = newColorMap;
} }
reset() {
const copyColorMap = new Map(this.colorMap);
copyColorMap.forEach((_, label) => {
this.colorMap.set(label, '');
});
}
clear() { clear() {
this.sliceLabelMap.clear(); this.chartsLabelsMap.clear();
this.colorMap.clear(); this.colorMap.clear();
} }
} }
const getInstance = makeSingleton(SharedLabelColor); const getInstance = makeSingleton(LabelsColorMap);
export default getInstance; export default getInstance;

View File

@ -34,9 +34,9 @@ export * from './colorSchemes';
export * from './utils'; export * from './utils';
export * from './types'; export * from './types';
export { export {
default as getSharedLabelColor, default as getLabelsColorMap,
SharedLabelColor, LabelsColorMap,
SharedLabelColorSource, LabelsColorMapSource,
} from './SharedLabelColorSingleton'; } from './LabelsColorMapSingleton';
export const BRAND_COLOR = '#00A699'; export const BRAND_COLOR = '#00A699';

View File

@ -55,11 +55,12 @@ export function getContrastingColor(color: string, thresholds = 186) {
export function getAnalogousColors(colors: string[], results: number) { export function getAnalogousColors(colors: string[], results: number) {
const generatedColors: string[] = []; const generatedColors: string[] = [];
// This is to solve the problem that the first three values generated by tinycolor.analogous
// may have the same or very close colors.
const ext = 3; const ext = 3;
const analogousColors = colors.map(color => { const analogousColors = colors.map(color => {
// returns an array of tinycolor instances
const result = tinycolor(color).analogous(results + ext); const result = tinycolor(color).analogous(results + ext);
// remove the first three colors to avoid the same or very close colors
return result.slice(ext); return result.slice(ext);
}); });

View File

@ -17,9 +17,12 @@
* under the License. * under the License.
*/ */
import { EffectCallback, useEffect, useRef } from 'react'; import { DependencyList, EffectCallback, useEffect, useRef } from 'react';
export const useComponentDidUpdate = (effect: EffectCallback) => { export const useComponentDidUpdate = (
effect: EffectCallback,
deps?: DependencyList,
) => {
const isMountedRef = useRef(false); const isMountedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (isMountedRef.current) { if (isMountedRef.current) {
@ -27,5 +30,6 @@ export const useComponentDidUpdate = (effect: EffectCallback) => {
} else { } else {
isMountedRef.current = true; isMountedRef.current = true;
} }
}, [effect]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [...(deps || [effect])]);
}; };

View File

@ -23,6 +23,8 @@ import {
ComponentType, ComponentType,
} from 'react'; } from 'react';
import type { Editor } from 'brace'; import type { Editor } from 'brace';
import { BaseFormData } from '../query';
import { JsonResponse } from '../connection';
/** /**
* A function which returns text (or marked-up text) * A function which returns text (or marked-up text)
@ -30,6 +32,14 @@ import type { Editor } from 'brace';
*/ */
type ReturningDisplayable<P = void> = (props: P) => string | ReactElement; type ReturningDisplayable<P = void> = (props: P) => string | ReactElement;
/**
* A function which returns the drill by options for a given dataset and chart's formData.
*/
export type LoadDrillByOptions = (
datasetId: number,
formData: BaseFormData,
) => Promise<JsonResponse>;
/** /**
* This type defines all available extensions of Superset's default UI. * This type defines all available extensions of Superset's default UI.
* Namespace the keys here to follow the form of 'some_domain.functionality.item'. * Namespace the keys here to follow the form of 'some_domain.functionality.item'.
@ -193,6 +203,7 @@ export interface CustomAutocomplete extends AutocompleteItem {
export type Extensions = Partial<{ export type Extensions = Partial<{
'alertsreports.header.icon': ComponentType; 'alertsreports.header.icon': ComponentType;
'load.drillby.options': LoadDrillByOptions;
'embedded.documentation.configuration_details': ComponentType<ConfigDetailsProps>; 'embedded.documentation.configuration_details': ComponentType<ConfigDetailsProps>;
'embedded.documentation.description': ReturningDisplayable; 'embedded.documentation.description': ReturningDisplayable;
'embedded.documentation.url': string; 'embedded.documentation.url': string;

View File

@ -57,6 +57,7 @@ export enum FeatureFlag {
TaggingSystem = 'TAGGING_SYSTEM', TaggingSystem = 'TAGGING_SYSTEM',
Thumbnails = 'THUMBNAILS', Thumbnails = 'THUMBNAILS',
UseAnalagousColors = 'USE_ANALAGOUS_COLORS', UseAnalagousColors = 'USE_ANALAGOUS_COLORS',
ForceSqlLabRunAsync = 'SQLLAB_FORCE_RUN_ASYNC',
} }
export type ScheduleQueriesProps = { export type ScheduleQueriesProps = {

View File

@ -98,7 +98,7 @@ describe('CategoricalColorNamespace', () => {
namespace.setColor('dog', 'black'); namespace.setColor('dog', 'black');
const scale = namespace.getScale('testColors'); const scale = namespace.getScale('testColors');
scale.setColor('dog', 'pink'); scale.setColor('dog', 'pink');
expect(scale.getColor('dog')).toBe('black'); expect(scale.getColor('dog')).toBe('pink');
expect(scale.getColor('boy')).not.toBe('black'); expect(scale.getColor('boy')).not.toBe('black');
}); });
it('does not affect scales in other namespaces', () => { it('does not affect scales in other namespaces', () => {

View File

@ -18,50 +18,76 @@
*/ */
import { ScaleOrdinal } from 'd3-scale'; import { ScaleOrdinal } from 'd3-scale';
import { import { CategoricalColorScale, FeatureFlag } from '@superset-ui/core';
CategoricalColorScale,
FeatureFlag,
getSharedLabelColor,
} from '@superset-ui/core';
describe('CategoricalColorScale', () => { describe('CategoricalColorScale', () => {
beforeEach(() => {
window.featureFlags = {};
});
it('exists', () => { it('exists', () => {
expect(CategoricalColorScale !== undefined).toBe(true); expect(CategoricalColorScale !== undefined).toBe(true);
}); });
describe('new CategoricalColorScale(colors, parentForcedColors)', () => { describe('new CategoricalColorScale(colors, forcedColors)', () => {
it('can create new scale when parentForcedColors is not given', () => { it('can create new scale when forcedColors is not given', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']); const scale = new CategoricalColorScale(['blue', 'red', 'green']);
expect(scale).toBeInstanceOf(CategoricalColorScale); expect(scale).toBeInstanceOf(CategoricalColorScale);
}); });
it('can create new scale when parentForcedColors is given', () => { it('can create new scale when forcedColors is given', () => {
const parentForcedColors = {}; const forcedColors = {};
const scale = new CategoricalColorScale( const scale = new CategoricalColorScale(
['blue', 'red', 'green'], ['blue', 'red', 'green'],
parentForcedColors, forcedColors,
); );
expect(scale).toBeInstanceOf(CategoricalColorScale); expect(scale).toBeInstanceOf(CategoricalColorScale);
expect(scale.parentForcedColors).toBe(parentForcedColors); expect(scale.forcedColors).toBe(forcedColors);
}); });
it('can refer to colors based on their index', () => { it('can refer to colors based on their index', () => {
const parentForcedColors = { pig: 1, horse: 5 }; const forcedColors = { pig: 1, horse: 5 };
const scale = new CategoricalColorScale( const scale = new CategoricalColorScale(
['blue', 'red', 'green'], ['blue', 'red', 'green'],
parentForcedColors, forcedColors,
); );
expect(scale.getColor('pig')).toEqual('red'); expect(scale.getColor('pig')).toEqual('red');
expect(parentForcedColors.pig).toEqual('red'); expect(forcedColors.pig).toEqual('red');
// can loop around the scale // can loop around the scale
expect(scale.getColor('horse')).toEqual('green'); expect(scale.getColor('horse')).toEqual('green');
expect(parentForcedColors.horse).toEqual('green'); expect(forcedColors.horse).toEqual('green');
}); });
}); });
describe('.getColor(value)', () => { describe('.getColor(value, sliceId)', () => {
let scale: CategoricalColorScale;
let addSliceSpy: jest.SpyInstance<
void,
[label: string, color: string, sliceId: number, colorScheme?: string]
>;
let getNextAvailableColorSpy: jest.SpyInstance<
string,
[currentColor: string]
>;
beforeEach(() => {
scale = new CategoricalColorScale(['blue', 'red', 'green']);
// Spy on the addSlice method of labelsColorMapInstance
addSliceSpy = jest.spyOn(scale.labelsColorMapInstance, 'addSlice');
getNextAvailableColorSpy = jest
.spyOn(scale, 'getNextAvailableColor')
.mockImplementation(color => color);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('returns same color for same value', () => { it('returns same color for same value', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']); const scale = new CategoricalColorScale(['blue', 'red', 'green'], {
pig: 'red',
horse: 'green',
});
const c1 = scale.getColor('pig'); const c1 = scale.getColor('pig');
const c2 = scale.getColor('horse'); const c2 = scale.getColor('horse');
const c3 = scale.getColor('pig'); const c3 = scale.getColor('pig');
@ -82,9 +108,6 @@ describe('CategoricalColorScale', () => {
expect(c3).not.toBe(c1); expect(c3).not.toBe(c1);
}); });
it('recycles colors when number of items exceed available colors', () => { it('recycles colors when number of items exceed available colors', () => {
window.featureFlags = {
[FeatureFlag.UseAnalagousColors]: false,
};
const colorSet: { [key: string]: number } = {}; const colorSet: { [key: string]: number } = {};
const scale = new CategoricalColorScale(['blue', 'red', 'green']); const scale = new CategoricalColorScale(['blue', 'red', 'green']);
const colors = [ const colors = [
@ -118,43 +141,70 @@ describe('CategoricalColorScale', () => {
scale.getColor('cow'); scale.getColor('cow');
scale.getColor('donkey'); scale.getColor('donkey');
scale.getColor('goat'); scale.getColor('goat');
expect(scale.range()).toHaveLength(6); expect(scale.range()).toHaveLength(9);
}); });
it('adds the color and value to chartLabelsColorMap and calls addSlice', () => {
const value = 'testValue';
const sliceId = 123;
const colorScheme = 'preset';
it('should remove shared color from range if avoid colors collision enabled', () => { expect(scale.chartLabelsColorMap.has(value)).toBe(false);
scale.getColor(value, sliceId, colorScheme);
expect(scale.chartLabelsColorMap.has(value)).toBe(true);
expect(scale.chartLabelsColorMap.get(value)).toBeDefined();
expect(addSliceSpy).toHaveBeenCalledWith(
value,
expect.any(String),
sliceId,
colorScheme,
);
const expectedColor = scale.chartLabelsColorMap.get(value);
const returnedColor = scale.getColor(value, sliceId);
expect(returnedColor).toBe(expectedColor);
});
it('conditionally calls getNextAvailableColor', () => {
window.featureFlags = { window.featureFlags = {
[FeatureFlag.AvoidColorsCollision]: true, [FeatureFlag.AvoidColorsCollision]: true,
}; };
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
const color1 = scale.getColor('a', 1); scale.getColor('testValue1');
expect(scale.range()).toHaveLength(3); scale.getColor('testValue2');
const color2 = scale.getColor('a', 2); scale.getColor('testValue1');
expect(color1).toBe(color2); scale.getColor('testValue3');
scale.getColor('b', 2); scale.getColor('testValue4');
expect(scale.range()).toHaveLength(2);
scale.getColor('c', 2); expect(getNextAvailableColorSpy).toHaveBeenCalledWith('blue');
expect(scale.range()).toHaveLength(1);
getNextAvailableColorSpy.mockClear();
window.featureFlags = {
[FeatureFlag.AvoidColorsCollision]: false,
};
scale.getColor('testValue3');
expect(getNextAvailableColorSpy).not.toHaveBeenCalled();
}); });
window.featureFlags = {
[FeatureFlag.AvoidColorsCollision]: false,
};
}); });
describe('.setColor(value, forcedColor)', () => { describe('.setColor(value, forcedColor)', () => {
it('overrides default color', () => { it('overrides default color', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']); const scale = new CategoricalColorScale(['blue', 'red', 'green']);
scale.setColor('pig', 'pink'); scale.setColor('pig', 'pink');
expect(scale.getColor('pig')).toBe('pink'); expect(scale.getColor('pig')).toBe('pink');
}); });
it('does not override parentForcedColors', () => { it('does override forcedColors', () => {
const scale1 = new CategoricalColorScale(['blue', 'red', 'green']); const scale1 = new CategoricalColorScale(['blue', 'red', 'green']);
scale1.setColor('pig', 'black'); scale1.setColor('pig', 'black');
const scale2 = new CategoricalColorScale(
['blue', 'red', 'green'], const scale2 = new CategoricalColorScale(['blue', 'red', 'green']);
scale1.forcedColors,
);
scale2.setColor('pig', 'pink'); scale2.setColor('pig', 'pink');
expect(scale2.getColor('pig')).toBe('pink');
expect(scale1.getColor('pig')).toBe('black'); expect(scale1.getColor('pig')).toBe('black');
expect(scale2.getColor('pig')).toBe('black');
}); });
it('returns the scale', () => { it('returns the scale', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']); const scale = new CategoricalColorScale(['blue', 'red', 'green']);
@ -163,7 +213,7 @@ describe('CategoricalColorScale', () => {
}); });
}); });
describe('.getColorMap()', () => { describe('.getColorMap()', () => {
it('returns correct mapping and parentForcedColors and forcedColors are specified', () => { it('returns correct mapping using least used color', () => {
const scale1 = new CategoricalColorScale(['blue', 'red', 'green']); const scale1 = new CategoricalColorScale(['blue', 'red', 'green']);
scale1.setColor('cow', 'black'); scale1.setColor('cow', 'black');
const scale2 = new CategoricalColorScale( const scale2 = new CategoricalColorScale(
@ -177,7 +227,7 @@ describe('CategoricalColorScale', () => {
expect(scale2.getColorMap()).toEqual({ expect(scale2.getColorMap()).toEqual({
cow: 'black', cow: 'black',
pig: 'pink', pig: 'pink',
horse: 'green', horse: 'blue', // least used color
}); });
}); });
}); });
@ -230,10 +280,114 @@ describe('CategoricalColorScale', () => {
}); });
describe('a CategoricalColorScale instance is also a color function itself', () => { describe('a CategoricalColorScale instance is also a color function itself', () => {
it('scale(value) returns color similar to calling scale.getColor(value)', () => { it('scale(value) returns same color for same value', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']); const scale = new CategoricalColorScale(['blue', 'red', 'green']);
expect(scale.getColor('pig')).toBe(scale('pig')); expect(scale.getColor('pig')).toBe('blue');
expect(scale.getColor('cat')).toBe(scale('cat')); expect(scale('pig')).toBe('blue');
expect(scale.getColor('cat')).toBe('red');
expect(scale('cat')).toBe('red');
});
});
describe('.getNextAvailableColor(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');
});
it('handles cases where all colors are equally used and returns the current color', () => {
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');
});
it('returns the least used color accurately even when some colors are used more frequently', () => {
const scale = new CategoricalColorScale([
'blue',
'red',
'green',
'yellow',
]);
scale.getColor('cat'); // blue
scale.getColor('dog'); // red
scale.getColor('frog'); // green
scale.getColor('fish'); // yellow
scale.getColor('goat'); // blue
scale.getColor('horse'); // red
scale.getColor('pony'); // green
// Yellow is the least used color, so it should be returned.
expect(scale.getNextAvailableColor('blue')).toBe('yellow');
});
});
describe('.isColorUsed(color)', () => {
it('returns true if the color is already used, false otherwise', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
// Initially, no color is used
expect(scale.isColorUsed('blue')).toBe(false);
expect(scale.isColorUsed('red')).toBe(false);
expect(scale.isColorUsed('green')).toBe(false);
scale.getColor('item1');
// Now, 'blue' is used, but 'red' and 'green' are not
expect(scale.isColorUsed('blue')).toBe(true);
expect(scale.isColorUsed('red')).toBe(false);
expect(scale.isColorUsed('green')).toBe(false);
// Simulate using the 'red' color
scale.getColor('item2'); // Assigns 'red' to 'item2'
// Now, 'blue' and 'red' are used
expect(scale.isColorUsed('blue')).toBe(true);
expect(scale.isColorUsed('red')).toBe(true);
expect(scale.isColorUsed('green')).toBe(false);
});
});
describe('.getColorUsageCount(color)', () => {
it('accurately counts the occurrences of a specific color', () => {
const scale = new CategoricalColorScale([
'blue',
'red',
'green',
'yellow',
]);
// No colors are used initially
expect(scale.getColorUsageCount('blue')).toBe(0);
expect(scale.getColorUsageCount('red')).toBe(0);
expect(scale.getColorUsageCount('green')).toBe(0);
expect(scale.getColorUsageCount('yellow')).toBe(0);
// Simulate using colors
scale.getColor('item1');
scale.getColor('item2');
scale.getColor('item1');
// Check the counts after using the colors
expect(scale.getColorUsageCount('blue')).toBe(1);
expect(scale.getColorUsageCount('red')).toBe(1);
expect(scale.getColorUsageCount('green')).toBe(0);
expect(scale.getColorUsageCount('yellow')).toBe(0);
// Simulate using colors more
scale.getColor('item3');
scale.getColor('item4');
scale.getColor('item3');
// Final counts
expect(scale.getColorUsageCount('blue')).toBe(1);
expect(scale.getColorUsageCount('red')).toBe(1);
expect(scale.getColorUsageCount('green')).toBe(1);
expect(scale.getColorUsageCount('yellow')).toBe(1);
}); });
}); });
@ -244,50 +398,4 @@ describe('CategoricalColorScale', () => {
expect(scale('pig')).toBe('blue'); expect(scale('pig')).toBe('blue');
}); });
}); });
describe('.removeSharedLabelColorFromRange(colorMap, cleanedValue)', () => {
it('should remove shared color from range', () => {
const scale = new CategoricalColorScale(['blue', 'green', 'red']);
expect(scale.range()).toEqual(['blue', 'green', 'red']);
const sharedLabelColor = getSharedLabelColor();
sharedLabelColor.clear();
const colorMap = sharedLabelColor.getColorMap();
sharedLabelColor.addSlice('cow', 'blue', 1);
scale.removeSharedLabelColorFromRange(colorMap, 'pig');
expect(scale.range()).toEqual(['green', 'red']);
scale.removeSharedLabelColorFromRange(colorMap, 'cow');
expect(scale.range()).toEqual(['blue', 'green', 'red']);
sharedLabelColor.clear();
});
it('recycles colors when all colors are in sharedLabelColor', () => {
const scale = new CategoricalColorScale(['blue', 'green', 'red']);
expect(scale.range()).toEqual(['blue', 'green', 'red']);
const sharedLabelColor = getSharedLabelColor();
const colorMap = sharedLabelColor.getColorMap();
sharedLabelColor.addSlice('cow', 'blue', 1);
sharedLabelColor.addSlice('pig', 'red', 1);
sharedLabelColor.addSlice('horse', 'green', 1);
scale.removeSharedLabelColorFromRange(colorMap, 'goat');
expect(scale.range()).toEqual(['blue', 'green', 'red']);
sharedLabelColor.clear();
});
it('should remove parentForcedColors from range', () => {
const parentForcedColors = { house: 'blue', cow: 'red' };
const scale = new CategoricalColorScale(
['blue', 'red', 'green'],
parentForcedColors,
);
const sharedLabelColor = getSharedLabelColor();
sharedLabelColor.clear();
const colorMap = sharedLabelColor.getColorMap();
scale.removeSharedLabelColorFromRange(colorMap, 'pig');
expect(scale.range()).toEqual(['green']);
scale.removeSharedLabelColorFromRange(colorMap, 'cow');
expect(scale.range()).toEqual(['red', 'green']);
sharedLabelColor.clear();
});
});
}); });

View File

@ -0,0 +1,234 @@
/*
* 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 {
CategoricalColorNamespace,
CategoricalScheme,
FeatureFlag,
getCategoricalSchemeRegistry,
getLabelsColorMap,
LabelsColorMapSource,
LabelsColorMap,
} from '@superset-ui/core';
const actual = jest.requireActual('../../src/color/utils');
const getAnalogousColorsSpy = jest
.spyOn(actual, 'getAnalogousColors')
.mockImplementation(() => ['red', 'green', 'blue']);
describe('LabelsColorMap', () => {
beforeAll(() => {
getCategoricalSchemeRegistry()
.registerValue(
'testColors',
new CategoricalScheme({
id: 'testColors',
colors: ['red', 'green', 'blue'],
}),
)
.registerValue(
'testColors2',
new CategoricalScheme({
id: 'testColors2',
colors: ['yellow', 'green', 'blue'],
}),
);
});
beforeEach(() => {
getLabelsColorMap().source = LabelsColorMapSource.Dashboard;
getLabelsColorMap().clear();
});
it('has default value out-of-the-box', () => {
expect(getLabelsColorMap()).toBeInstanceOf(LabelsColorMap);
});
describe('.addSlice(value, color, sliceId)', () => {
it('should add to sliceLabelColorMap when first adding label', () => {
const labelsColorMap = getLabelsColorMap();
labelsColorMap.addSlice('a', 'red', 1, 'preset');
expect(labelsColorMap.chartsLabelsMap.has(1)).toEqual(true);
const chartConfig = labelsColorMap.chartsLabelsMap.get(1);
expect(chartConfig?.labels?.includes('a')).toEqual(true);
const colorMap = labelsColorMap.getColorMap();
expect(Object.fromEntries(colorMap)).toEqual({ a: 'red' });
});
it('should add to sliceLabelColorMap when slice exist', () => {
const labelsColorMap = getLabelsColorMap();
labelsColorMap.addSlice('a', 'red', 1);
labelsColorMap.addSlice('b', 'blue', 1);
const chartConfig = labelsColorMap.chartsLabelsMap.get(1);
expect(chartConfig?.labels?.includes('b')).toEqual(true);
const colorMap = labelsColorMap.getColorMap();
expect(Object.fromEntries(colorMap)).toEqual({ a: 'red', b: 'blue' });
});
it('should use last color if adding label repeatedly', () => {
const labelsColorMap = getLabelsColorMap();
labelsColorMap.addSlice('b', 'blue', 1);
labelsColorMap.addSlice('b', 'green', 1);
const chartConfig = labelsColorMap.chartsLabelsMap.get(1);
expect(chartConfig?.labels?.includes('b')).toEqual(true);
expect(chartConfig?.labels?.length).toEqual(1);
const colorMap = labelsColorMap.getColorMap();
expect(Object.fromEntries(colorMap)).toEqual({ b: 'green' });
});
it('should do nothing when source is not dashboard', () => {
const labelsColorMap = getLabelsColorMap();
labelsColorMap.source = LabelsColorMapSource.Explore;
labelsColorMap.addSlice('a', 'red', 1);
expect(Object.fromEntries(labelsColorMap.chartsLabelsMap)).toEqual({});
});
});
describe('.remove(sliceId)', () => {
it('should remove sliceId', () => {
const labelsColorMap = getLabelsColorMap();
labelsColorMap.addSlice('a', 'red', 1);
labelsColorMap.removeSlice(1);
expect(labelsColorMap.chartsLabelsMap.has(1)).toEqual(false);
});
it('should update colorMap', () => {
const labelsColorMap = getLabelsColorMap();
labelsColorMap.addSlice('a', 'red', 1);
labelsColorMap.addSlice('b', 'blue', 2);
labelsColorMap.removeSlice(1);
const colorMap = labelsColorMap.getColorMap();
expect(Object.fromEntries(colorMap)).toEqual({ b: 'blue' });
});
it('should do nothing when source is not dashboard', () => {
const labelsColorMap = getLabelsColorMap();
labelsColorMap.addSlice('a', 'red', 1);
labelsColorMap.source = LabelsColorMapSource.Explore;
labelsColorMap.removeSlice(1);
expect(labelsColorMap.chartsLabelsMap.has(1)).toEqual(true);
});
});
describe('.updateColorMap(namespace, scheme)', () => {
let categoricalNamespace: any;
let mockedNamespace: any;
let labelsColorMap: any;
beforeEach(() => {
labelsColorMap = getLabelsColorMap();
categoricalNamespace = CategoricalColorNamespace.getNamespace(undefined);
mockedNamespace = {
getScale: jest.fn().mockReturnValue({
getColor: jest.fn(() => 'mockColor'),
}),
};
});
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);
labelsColorMap.updateColorMap(mockedNamespace);
expect(mockedNamespace.getScale).toHaveBeenCalledWith(undefined);
});
it('should update color map', () => {
// override color with forcedItems
categoricalNamespace.setColor('b', 'green');
// testColors2: 'yellow', 'green', 'blue'
// first-time label, gets color, yellow
labelsColorMap.addSlice('a', 'red', 1);
// overridden, gets green
labelsColorMap.addSlice('b', 'pink', 1);
// overridden, gets green
labelsColorMap.addSlice('b', 'green', 2);
// first-time slice label, gets color, yellow
labelsColorMap.addSlice('c', 'blue', 2);
labelsColorMap.updateColorMap(categoricalNamespace, 'testColors2');
const colorMap = labelsColorMap.getColorMap();
expect(Object.fromEntries(colorMap)).toEqual({
a: 'yellow',
b: 'green',
c: 'yellow',
});
});
it('should use recycle colors', () => {
window.featureFlags = {
[FeatureFlag.UseAnalagousColors]: false,
};
labelsColorMap.addSlice('a', 'red', 1);
labelsColorMap.addSlice('b', 'blue', 2);
labelsColorMap.addSlice('c', 'green', 3);
labelsColorMap.addSlice('d', 'red', 4);
labelsColorMap.updateColorMap(categoricalNamespace, 'testColors');
const colorMap = labelsColorMap.getColorMap();
expect(Object.fromEntries(colorMap)).not.toEqual({});
expect(getAnalogousColorsSpy).not.toBeCalled();
});
it('should use analagous colors', () => {
window.featureFlags = {
[FeatureFlag.UseAnalagousColors]: true,
};
labelsColorMap.addSlice('a', 'red', 1);
labelsColorMap.addSlice('b', 'blue', 1);
labelsColorMap.addSlice('c', 'green', 1);
labelsColorMap.addSlice('d', 'red', 1);
labelsColorMap.updateColorMap(categoricalNamespace, 'testColors');
const colorMap = labelsColorMap.getColorMap();
expect(Object.fromEntries(colorMap)).not.toEqual({});
expect(getAnalogousColorsSpy).toBeCalled();
});
});
describe('.getColorMap()', () => {
it('should get color map', () => {
const labelsColorMap = getLabelsColorMap();
labelsColorMap.addSlice('a', 'red', 1);
labelsColorMap.addSlice('b', 'blue', 2);
const colorMap = labelsColorMap.getColorMap();
expect(Object.fromEntries(colorMap)).toEqual({ a: 'red', b: 'blue' });
});
});
describe('.reset()', () => {
it('should reset color map', () => {
const labelsColorMap = getLabelsColorMap();
labelsColorMap.addSlice('a', 'red', 1);
labelsColorMap.addSlice('b', 'blue', 2);
labelsColorMap.clear();
const colorMap = labelsColorMap.getColorMap();
expect(Object.fromEntries(colorMap)).toEqual({});
});
});
});

View File

@ -1,201 +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 {
CategoricalScheme,
FeatureFlag,
getCategoricalSchemeRegistry,
getSharedLabelColor,
SharedLabelColor,
SharedLabelColorSource,
} from '@superset-ui/core';
const actual = jest.requireActual('../../src/color/utils');
const getAnalogousColorsSpy = jest
.spyOn(actual, 'getAnalogousColors')
.mockImplementation(() => ['red', 'green', 'blue']);
describe('SharedLabelColor', () => {
beforeAll(() => {
getCategoricalSchemeRegistry()
.registerValue(
'testColors',
new CategoricalScheme({
id: 'testColors',
colors: ['red', 'green', 'blue'],
}),
)
.registerValue(
'testColors2',
new CategoricalScheme({
id: 'testColors2',
colors: ['yellow', 'green', 'blue'],
}),
);
});
beforeEach(() => {
getSharedLabelColor().source = SharedLabelColorSource.Dashboard;
getSharedLabelColor().clear();
});
it('has default value out-of-the-box', () => {
expect(getSharedLabelColor()).toBeInstanceOf(SharedLabelColor);
});
describe('.addSlice(value, color, sliceId)', () => {
it('should add to sliceLabelColorMap when first adding label', () => {
const sharedLabelColor = getSharedLabelColor();
sharedLabelColor.addSlice('a', 'red', 1);
expect(sharedLabelColor.sliceLabelMap.has(1)).toEqual(true);
const labels = sharedLabelColor.sliceLabelMap.get(1);
expect(labels?.includes('a')).toEqual(true);
const colorMap = sharedLabelColor.getColorMap();
expect(Object.fromEntries(colorMap)).toEqual({ a: 'red' });
});
it('should add to sliceLabelColorMap when slice exist', () => {
const sharedLabelColor = getSharedLabelColor();
sharedLabelColor.addSlice('a', 'red', 1);
sharedLabelColor.addSlice('b', 'blue', 1);
const labels = sharedLabelColor.sliceLabelMap.get(1);
expect(labels?.includes('b')).toEqual(true);
const colorMap = sharedLabelColor.getColorMap();
expect(Object.fromEntries(colorMap)).toEqual({ a: 'red', b: 'blue' });
});
it('should use last color if adding label repeatedly', () => {
const sharedLabelColor = getSharedLabelColor();
sharedLabelColor.addSlice('b', 'blue', 1);
sharedLabelColor.addSlice('b', 'green', 1);
const labels = sharedLabelColor.sliceLabelMap.get(1);
expect(labels?.includes('b')).toEqual(true);
expect(labels?.length).toEqual(1);
const colorMap = sharedLabelColor.getColorMap();
expect(Object.fromEntries(colorMap)).toEqual({ b: 'green' });
});
it('should do nothing when source is not dashboard', () => {
const sharedLabelColor = getSharedLabelColor();
sharedLabelColor.source = SharedLabelColorSource.Explore;
sharedLabelColor.addSlice('a', 'red');
expect(Object.fromEntries(sharedLabelColor.sliceLabelMap)).toEqual({});
});
it('should do nothing when sliceId is undefined', () => {
const sharedLabelColor = getSharedLabelColor();
sharedLabelColor.addSlice('a', 'red');
expect(Object.fromEntries(sharedLabelColor.sliceLabelMap)).toEqual({});
});
});
describe('.remove(sliceId)', () => {
it('should remove sliceId', () => {
const sharedLabelColor = getSharedLabelColor();
sharedLabelColor.addSlice('a', 'red', 1);
sharedLabelColor.removeSlice(1);
expect(sharedLabelColor.sliceLabelMap.has(1)).toEqual(false);
});
it('should update colorMap', () => {
const sharedLabelColor = getSharedLabelColor();
sharedLabelColor.addSlice('a', 'red', 1);
sharedLabelColor.addSlice('b', 'blue', 2);
sharedLabelColor.removeSlice(1);
const colorMap = sharedLabelColor.getColorMap();
expect(Object.fromEntries(colorMap)).toEqual({ b: 'blue' });
});
it('should do nothing when source is not dashboard', () => {
const sharedLabelColor = getSharedLabelColor();
sharedLabelColor.addSlice('a', 'red', 1);
sharedLabelColor.source = SharedLabelColorSource.Explore;
sharedLabelColor.removeSlice(1);
expect(sharedLabelColor.sliceLabelMap.has(1)).toEqual(true);
});
});
describe('.updateColorMap(namespace, scheme)', () => {
it('should update color map', () => {
const sharedLabelColor = getSharedLabelColor();
sharedLabelColor.addSlice('a', 'red', 1);
sharedLabelColor.addSlice('b', 'pink', 1);
sharedLabelColor.addSlice('b', 'green', 2);
sharedLabelColor.addSlice('c', 'blue', 2);
sharedLabelColor.updateColorMap('', 'testColors2');
const colorMap = sharedLabelColor.getColorMap();
expect(Object.fromEntries(colorMap)).toEqual({
a: 'yellow',
b: 'yellow',
c: 'green',
});
});
it('should use recycle colors', () => {
window.featureFlags = {
[FeatureFlag.UseAnalagousColors]: false,
};
const sharedLabelColor = getSharedLabelColor();
sharedLabelColor.addSlice('a', 'red', 1);
sharedLabelColor.addSlice('b', 'blue', 2);
sharedLabelColor.addSlice('c', 'green', 3);
sharedLabelColor.addSlice('d', 'red', 4);
sharedLabelColor.updateColorMap('', 'testColors');
const colorMap = sharedLabelColor.getColorMap();
expect(Object.fromEntries(colorMap)).not.toEqual({});
expect(getAnalogousColorsSpy).not.toBeCalled();
});
it('should use analagous colors', () => {
window.featureFlags = {
[FeatureFlag.UseAnalagousColors]: true,
};
const sharedLabelColor = getSharedLabelColor();
sharedLabelColor.addSlice('a', 'red', 1);
sharedLabelColor.addSlice('b', 'blue', 1);
sharedLabelColor.addSlice('c', 'green', 1);
sharedLabelColor.addSlice('d', 'red', 1);
sharedLabelColor.updateColorMap('', 'testColors');
const colorMap = sharedLabelColor.getColorMap();
expect(Object.fromEntries(colorMap)).not.toEqual({});
expect(getAnalogousColorsSpy).toBeCalled();
});
});
describe('.getColorMap()', () => {
it('should get color map', () => {
const sharedLabelColor = getSharedLabelColor();
sharedLabelColor.addSlice('a', 'red', 1);
sharedLabelColor.addSlice('b', 'blue', 2);
const colorMap = sharedLabelColor.getColorMap();
expect(Object.fromEntries(colorMap)).toEqual({ a: 'red', b: 'blue' });
});
});
describe('.reset()', () => {
it('should reset color map', () => {
const sharedLabelColor = getSharedLabelColor();
sharedLabelColor.addSlice('a', 'red', 1);
sharedLabelColor.addSlice('b', 'blue', 2);
sharedLabelColor.reset();
const colorMap = sharedLabelColor.getColorMap();
expect(Object.fromEntries(colorMap)).toEqual({ a: '', b: '' });
});
});
});

View File

@ -93,7 +93,7 @@ function Chord(element, props) {
.append('path') .append('path')
.attr('id', (d, i) => `group${i}`) .attr('id', (d, i) => `group${i}`)
.attr('d', arc) .attr('d', arc)
.style('fill', (d, i) => colorFn(nodes[i], sliceId)); .style('fill', (d, i) => colorFn(nodes[i], sliceId, colorScheme));
// Add a text label. // Add a text label.
const groupText = group.append('text').attr('x', 6).attr('dy', 15); const groupText = group.append('text').attr('x', 6).attr('dy', 15);
@ -121,7 +121,7 @@ function Chord(element, props) {
.on('mouseover', d => { .on('mouseover', d => {
chord.classed('fade', p => p !== d); chord.classed('fade', p => p !== d);
}) })
.style('fill', d => colorFn(nodes[d.source.index], sliceId)) .style('fill', d => colorFn(nodes[d.source.index], sliceId, colorScheme))
.attr('d', path); .attr('d', path);
// Add an elaborate mouseover title for each chord. // Add an elaborate mouseover title for each chord.

View File

@ -33,18 +33,7 @@ const config: ControlPanelConfig = {
['metric'], ['metric'],
['adhoc_filters'], ['adhoc_filters'],
['row_limit'], ['row_limit'],
[ ['sort_by_metric'],
{
name: 'sort_by_metric',
config: {
type: 'CheckboxControl',
label: t('Sort by metric'),
description: t(
'Whether to sort results by the selected metric in descending order.',
),
},
},
],
], ],
}, },
{ {

View File

@ -64,18 +64,7 @@ const config: ControlPanelConfig = {
['metric'], ['metric'],
['adhoc_filters'], ['adhoc_filters'],
['row_limit'], ['row_limit'],
[ ['sort_by_metric'],
{
name: 'sort_by_metric',
config: {
type: 'CheckboxControl',
label: t('Sort by metric'),
description: t(
'Whether to sort results by the selected metric in descending order.',
),
},
},
],
], ],
}, },
{ {

View File

@ -78,7 +78,7 @@ class CustomHistogram extends PureComponent {
const keys = data.map(d => d.key); const keys = data.map(d => d.key);
const colorScale = scaleOrdinal({ const colorScale = scaleOrdinal({
domain: keys, domain: keys,
range: keys.map(x => colorFn(x, sliceId)), range: keys.map(x => colorFn(x, sliceId, colorScheme)),
}); });
return ( return (

View File

@ -384,7 +384,7 @@ function Icicle(element, props) {
// Apply color scheme // Apply color scheme
g.selectAll('rect').style('fill', d => { g.selectAll('rect').style('fill', d => {
d.color = colorFn(d.name, sliceId); d.color = colorFn(d.name, sliceId, colorScheme);
return d.color; return d.color;
}); });

View File

@ -120,10 +120,16 @@ function Rose(element, props) {
.map(v => ({ .map(v => ({
key: v.name, key: v.name,
value: v.value, value: v.value,
color: colorFn(v.name, sliceId), color: colorFn(v.name, sliceId, colorScheme),
highlight: v.id === d.arcId, highlight: v.id === d.arcId,
})) }))
: [{ key: d.name, value: d.val, color: colorFn(d.name, sliceId) }]; : [
{
key: d.name,
value: d.val,
color: colorFn(d.name, sliceId, colorScheme),
},
];
return { return {
key: 'Date', key: 'Date',
@ -132,7 +138,7 @@ function Rose(element, props) {
}; };
} }
legend.width(width).color(d => colorFn(d.key, sliceId)); legend.width(width).color(d => colorFn(d.key, sliceId, colorScheme));
legendWrap.datum(legendData(datum)).call(legend); legendWrap.datum(legendData(datum)).call(legend);
tooltip.headerFormatter(timeFormat).valueFormatter(format); tooltip.headerFormatter(timeFormat).valueFormatter(format);
@ -379,7 +385,7 @@ function Rose(element, props) {
const arcs = ae const arcs = ae
.append('path') .append('path')
.attr('class', 'arc') .attr('class', 'arc')
.attr('fill', d => colorFn(d.name, sliceId)) .attr('fill', d => colorFn(d.name, sliceId, colorScheme))
.attr('d', arc); .attr('d', arc);
function mousemove() { function mousemove() {

View File

@ -219,7 +219,7 @@ function Sankey(element, props) {
.attr('width', sankey.nodeWidth()) .attr('width', sankey.nodeWidth())
.style('fill', d => { .style('fill', d => {
const name = d.name || 'N/A'; const name = d.name || 'N/A';
d.color = colorFn(name, sliceId); d.color = colorFn(name, sliceId, colorScheme);
return d.color; return d.color;
}) })

View File

@ -49,18 +49,7 @@ const config: ControlPanelConfig = {
}, },
}, },
], ],
[ ['sort_by_metric'],
{
name: 'sort_by_metric',
config: {
type: 'CheckboxControl',
label: t('Sort by metric'),
description: t(
'Whether to sort results by the selected metric in descending order.',
),
},
},
],
], ],
}, },
{ {

View File

@ -54,18 +54,7 @@ const config: ControlPanelConfig = {
['metric'], ['metric'],
['adhoc_filters'], ['adhoc_filters'],
['row_limit'], ['row_limit'],
[ ['sort_by_metric'],
{
name: 'sort_by_metric',
config: {
type: 'CheckboxControl',
label: t('Sort by metric'),
description: t(
'Whether to sort results by the selected metric in descending order.',
),
},
},
],
], ],
}, },
{ {

View File

@ -31,7 +31,7 @@
"d3-array": "^1.2.4", "d3-array": "^1.2.4",
"d3-color": "^1.4.1", "d3-color": "^1.4.1",
"d3-scale": "^3.0.0", "d3-scale": "^3.0.0",
"deck.gl": "9.0.6", "deck.gl": "9.0.12",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.30.1", "moment": "^2.30.1",
"mousetrap": "^1.6.5", "mousetrap": "^1.6.5",

View File

@ -58,7 +58,10 @@ function getCategories(fd: QueryFormData, data: JsonObject[]) {
if (d.cat_color != null && !categories.hasOwnProperty(d.cat_color)) { if (d.cat_color != null && !categories.hasOwnProperty(d.cat_color)) {
let color; let color;
if (fd.dimension) { if (fd.dimension) {
color = hexToRGB(colorFn(d.cat_color, fd.sliceId), c.a * 255); color = hexToRGB(
colorFn(d.cat_color, fd.sliceId, fd.color_scheme),
c.a * 255,
);
} else { } else {
color = fixedColor; color = fixedColor;
} }
@ -134,7 +137,10 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
return data.map(d => { return data.map(d => {
let color; let color;
if (fd.dimension) { if (fd.dimension) {
color = hexToRGB(colorFn(d.cat_color, fd.sliceId), c.a * 255); color = hexToRGB(
colorFn(d.cat_color, fd.sliceId, fd.color_scheme),
c.a * 255,
);
return { ...d, color }; return { ...d, color };
} }

View File

@ -658,7 +658,9 @@ function nvd3Vis(element, props) {
} else if (vizType !== 'bullet') { } else if (vizType !== 'bullet') {
const colorFn = getScale(colorScheme); const colorFn = getScale(colorScheme);
chart.color( chart.color(
d => d.color || colorFn(cleanColorInput(d[colorKey]), sliceId), d =>
d.color ||
colorFn(cleanColorInput(d[colorKey]), sliceId, colorScheme),
); );
} }

View File

@ -108,9 +108,9 @@ export default function transformProps(
datum[`${metric}__outliers`], datum[`${metric}__outliers`],
], ],
itemStyle: { itemStyle: {
color: colorFn(groupbyLabel, sliceId), color: colorFn(groupbyLabel, sliceId, colorScheme),
opacity: isFiltered ? OpacityEnum.SemiTransparent : 0.6, opacity: isFiltered ? OpacityEnum.SemiTransparent : 0.6,
borderColor: colorFn(groupbyLabel, sliceId), borderColor: colorFn(groupbyLabel, sliceId, colorScheme),
}, },
}; };
}); });
@ -149,7 +149,7 @@ export default function transformProps(
}, },
}, },
itemStyle: { itemStyle: {
color: colorFn(groupbyLabel, sliceId), color: colorFn(groupbyLabel, sliceId, colorScheme),
opacity: isFiltered opacity: isFiltered
? OpacityEnum.SemiTransparent ? OpacityEnum.SemiTransparent
: OpacityEnum.NonTransparent, : OpacityEnum.NonTransparent,

View File

@ -62,12 +62,8 @@ const config: ControlPanelConfig = {
{ {
name: 'sort_by_metric', name: 'sort_by_metric',
config: { config: {
...sharedControls.sort_by_metric,
default: true, default: true,
type: 'CheckboxControl',
label: t('Sort by metric'),
description: t(
'Whether to sort results by the selected metric in descending order.',
),
}, },
}, },
], ],

View File

@ -174,7 +174,7 @@ export default function transformProps(
value, value,
name, name,
itemStyle: { itemStyle: {
color: colorFn(name, sliceId), color: colorFn(name, sliceId, colorScheme),
opacity: isFiltered opacity: isFiltered
? OpacityEnum.SemiTransparent ? OpacityEnum.SemiTransparent
: OpacityEnum.NonTransparent, : OpacityEnum.NonTransparent,

View File

@ -53,18 +53,7 @@ const config: ControlPanelConfig = {
}, },
}, },
], ],
[ ['sort_by_metric'],
{
name: 'sort_by_metric',
config: {
type: 'CheckboxControl',
label: t('Sort by metric'),
description: t(
'Whether to sort results by the selected metric in descending order.',
),
},
},
],
], ],
}, },
{ {

View File

@ -173,7 +173,7 @@ export default function transformProps(
value: data_point[metricLabel] as number, value: data_point[metricLabel] as number,
name, name,
itemStyle: { itemStyle: {
color: colorFn(index, sliceId), color: colorFn(index, sliceId, colorScheme),
}, },
title: { title: {
offsetCenter: [ offsetCenter: [
@ -201,7 +201,7 @@ export default function transformProps(
item = { item = {
...item, ...item,
itemStyle: { itemStyle: {
color: colorFn(index, sliceId), color: colorFn(index, sliceId, colorScheme),
opacity: OpacityEnum.SemiTransparent, opacity: OpacityEnum.SemiTransparent,
}, },
detail: { detail: {

View File

@ -277,7 +277,7 @@ export default function transformProps(
type: 'graph', type: 'graph',
categories: categoryList.map(c => ({ categories: categoryList.map(c => ({
name: c, name: c,
itemStyle: { color: colorFn(c, sliceId) }, itemStyle: { color: colorFn(c, sliceId, colorScheme) },
})), })),
layout, layout,
force: { force: {

View File

@ -26,6 +26,7 @@ import {
D3_FORMAT_OPTIONS, D3_FORMAT_OPTIONS,
D3_TIME_FORMAT_OPTIONS, D3_TIME_FORMAT_OPTIONS,
getStandardizedControls, getStandardizedControls,
sharedControls,
} from '@superset-ui/chart-controls'; } from '@superset-ui/chart-controls';
import { DEFAULT_FORM_DATA } from './types'; import { DEFAULT_FORM_DATA } from './types';
import { legendSection } from '../controls'; import { legendSection } from '../controls';
@ -56,12 +57,8 @@ const config: ControlPanelConfig = {
{ {
name: 'sort_by_metric', name: 'sort_by_metric',
config: { config: {
...sharedControls.sort_by_metric,
default: true, default: true,
type: 'CheckboxControl',
label: t('Sort by metric'),
description: t(
'Whether to sort results by the selected metric in descending order.',
),
}, },
}, },
], ],

View File

@ -222,7 +222,7 @@ export default function transformProps(
value, value,
name, name,
itemStyle: { itemStyle: {
color: colorFn(name, sliceId), color: colorFn(name, sliceId, colorScheme),
opacity: isFiltered opacity: isFiltered
? OpacityEnum.SemiTransparent ? OpacityEnum.SemiTransparent
: OpacityEnum.NonTransparent, : OpacityEnum.NonTransparent,

View File

@ -165,7 +165,7 @@ export default function transformProps(
value: metricLabels.map(metricLabel => datum[metricLabel]), value: metricLabels.map(metricLabel => datum[metricLabel]),
name: joinedName, name: joinedName,
itemStyle: { itemStyle: {
color: colorFn(joinedName, sliceId), color: colorFn(joinedName, sliceId, colorScheme),
opacity: isFiltered opacity: isFiltered
? OpacityEnum.Transparent ? OpacityEnum.Transparent
: OpacityEnum.NonTransparent, : OpacityEnum.NonTransparent,

View File

@ -42,18 +42,7 @@ const config: ControlPanelConfig = {
['secondary_metric'], ['secondary_metric'],
['adhoc_filters'], ['adhoc_filters'],
['row_limit'], ['row_limit'],
[ ['sort_by_metric'],
{
name: 'sort_by_metric',
config: {
type: 'CheckboxControl',
label: t('Sort by metric'),
description: t(
'Whether to sort results by the selected metric in descending order.',
),
},
},
],
], ],
}, },
{ {

View File

@ -40,18 +40,7 @@ const config: ControlPanelConfig = {
['groupby'], ['groupby'],
['metric'], ['metric'],
['row_limit'], ['row_limit'],
[ ['sort_by_metric'],
{
name: 'sort_by_metric',
config: {
type: 'CheckboxControl',
label: t('Sort by metric'),
description: t(
'Whether to sort results by the selected metric in descending order.',
),
},
},
],
['adhoc_filters'], ['adhoc_filters'],
], ],
}, },

View File

@ -183,7 +183,7 @@ export default function transformProps(
colorSaturation: COLOR_SATURATION, colorSaturation: COLOR_SATURATION,
itemStyle: { itemStyle: {
borderColor: BORDER_COLOR, borderColor: BORDER_COLOR,
color: colorFn(name, sliceId), color: colorFn(name, sliceId, colorScheme),
borderWidth: BORDER_WIDTH, borderWidth: BORDER_WIDTH,
gapWidth: GAP_WIDTH, gapWidth: GAP_WIDTH,
}, },
@ -216,7 +216,7 @@ export default function transformProps(
colorSaturation: COLOR_SATURATION, colorSaturation: COLOR_SATURATION,
itemStyle: { itemStyle: {
borderColor: BORDER_COLOR, borderColor: BORDER_COLOR,
color: colorFn(`${metricLabel}`, sliceId), color: colorFn(`${metricLabel}`, sliceId, colorScheme),
borderWidth: BORDER_WIDTH, borderWidth: BORDER_WIDTH,
gapWidth: GAP_WIDTH, gapWidth: GAP_WIDTH,
}, },

View File

@ -66,6 +66,7 @@ export interface WordCloudProps extends WordCloudVisualProps {
height: number; height: number;
width: number; width: number;
sliceId: number; sliceId: number;
colorScheme: string;
} }
export interface WordCloudState { export interface WordCloudState {
@ -221,7 +222,7 @@ class WordCloud extends PureComponent<FullWordCloudProps, WordCloudState> {
render() { render() {
const { scaleFactor } = this.state; const { scaleFactor } = this.state;
const { width, height, encoding, sliceId } = this.props; const { width, height, encoding, sliceId, colorScheme } = this.props;
const { words } = this.state; const { words } = this.state;
// @ts-ignore // @ts-ignore
@ -249,7 +250,11 @@ class WordCloud extends PureComponent<FullWordCloudProps, WordCloudState> {
fontSize={`${w.size}px`} fontSize={`${w.size}px`}
fontWeight={w.weight} fontWeight={w.weight}
fontFamily={w.font} fontFamily={w.font}
fill={colorFn(getValueFromDatum(w) as string, sliceId)} fill={colorFn(
getValueFromDatum(w) as string,
sliceId,
colorScheme,
)}
textAnchor="middle" textAnchor="middle"
transform={`translate(${w.x}, ${w.y}) rotate(${w.rotate})`} transform={`translate(${w.x}, ${w.y}) rotate(${w.rotate})`}
> >

View File

@ -80,5 +80,6 @@ export default function transformProps(chartProps: ChartProps): WordCloudProps {
rotation, rotation,
width, width,
sliceId, sliceId,
colorScheme,
}; };
} }

View File

@ -32,18 +32,7 @@ const config: ControlPanelConfig = {
['metric'], ['metric'],
['adhoc_filters'], ['adhoc_filters'],
['row_limit'], ['row_limit'],
[ ['sort_by_metric'],
{
name: 'sort_by_metric',
config: {
type: 'CheckboxControl',
label: t('Sort by metric'),
description: t(
'Whether to sort results by the selected metric in descending order.',
),
},
},
],
], ],
}, },
{ {

View File

@ -23,7 +23,8 @@ import { WordCloudFormData } from '../types';
export default function transformProps(chartProps: ChartProps): WordCloudProps { export default function transformProps(chartProps: ChartProps): WordCloudProps {
const { width, height, formData, queriesData } = chartProps; const { width, height, formData, queriesData } = chartProps;
const { encoding, rotation, sliceId } = formData as WordCloudFormData; const { encoding, rotation, sliceId, colorScheme } =
formData as WordCloudFormData;
return { return {
data: queriesData[0].data, data: queriesData[0].data,
@ -32,5 +33,6 @@ export default function transformProps(chartProps: ChartProps): WordCloudProps {
rotation, rotation,
width, width,
sliceId, sliceId,
colorScheme,
}; };
} }

View File

@ -68,6 +68,7 @@ describe('WordCloud transformProps', () => {
}, },
}, },
rotation: 'square', rotation: 'square',
colorScheme: 'bnbColors',
data: [{ name: 'Hulk', sum__num: 1 }], data: [{ name: 'Hulk', sum__num: 1 }],
}); });
}); });

View File

@ -446,4 +446,82 @@ describe('ResultSet', () => {
}), }),
).toBe(null); ).toBe(null);
}); });
test('should allow download as CSV when user has permission to export data', async () => {
const { queryByTestId } = setup(
mockedProps,
mockStore({
...initialState,
user: {
...user,
roles: {
sql_lab: [['can_export_csv', 'SQLLab']],
},
},
sqlLab: {
...initialState.sqlLab,
queries: {
[queries[0].id]: queries[0],
},
},
}),
);
expect(queryByTestId('export-csv-button')).toBeInTheDocument();
});
test('should not allow download as CSV when user does not have permission to export data', async () => {
const { queryByTestId } = setup(
mockedProps,
mockStore({
...initialState,
user,
sqlLab: {
...initialState.sqlLab,
queries: {
[queries[0].id]: queries[0],
},
},
}),
);
expect(queryByTestId('export-csv-button')).not.toBeInTheDocument();
});
test('should allow copy to clipboard when user has permission to export data', async () => {
const { queryByTestId } = setup(
mockedProps,
mockStore({
...initialState,
user: {
...user,
roles: {
sql_lab: [['can_export_csv', 'SQLLab']],
},
},
sqlLab: {
...initialState.sqlLab,
queries: {
[queries[0].id]: queries[0],
},
},
}),
);
expect(queryByTestId('copy-to-clipboard-button')).toBeInTheDocument();
});
test('should not allow copy to clipboard when user does not have permission to export data', async () => {
const { queryByTestId } = setup(
mockedProps,
mockStore({
...initialState,
user,
sqlLab: {
...initialState.sqlLab,
queries: {
[queries[0].id]: queries[0],
},
},
}),
);
expect(queryByTestId('copy-to-clipboard-button')).not.toBeInTheDocument();
});
}); });

View File

@ -78,6 +78,7 @@ import {
LOG_ACTIONS_SQLLAB_DOWNLOAD_CSV, LOG_ACTIONS_SQLLAB_DOWNLOAD_CSV,
} from 'src/logger/LogUtils'; } from 'src/logger/LogUtils';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
import { findPermission } from 'src/utils/findPermission';
import ExploreCtasResultsButton from '../ExploreCtasResultsButton'; import ExploreCtasResultsButton from '../ExploreCtasResultsButton';
import ExploreResultsButton from '../ExploreResultsButton'; import ExploreResultsButton from '../ExploreResultsButton';
import HighlightedSql from '../HighlightedSql'; import HighlightedSql from '../HighlightedSql';
@ -309,6 +310,12 @@ const ResultSet = ({
schema: query?.schema, schema: query?.schema,
}; };
const canExportData = findPermission(
'can_export_csv',
'SQLLab',
user?.roles,
);
return ( return (
<ResultSetControls> <ResultSetControls>
<SaveDatasetModal <SaveDatasetModal
@ -328,29 +335,35 @@ const ResultSet = ({
onClick={createExploreResultsOnClick} onClick={createExploreResultsOnClick}
/> />
)} )}
{csv && ( {csv && canExportData && (
<Button <Button
buttonSize="small" buttonSize="small"
href={getExportCsvUrl(query.id)} href={getExportCsvUrl(query.id)}
data-test="export-csv-button"
onClick={() => logAction(LOG_ACTIONS_SQLLAB_DOWNLOAD_CSV, {})} onClick={() => logAction(LOG_ACTIONS_SQLLAB_DOWNLOAD_CSV, {})}
> >
<i className="fa fa-file-text-o" /> {t('Download to CSV')} <i className="fa fa-file-text-o" /> {t('Download to CSV')}
</Button> </Button>
)} )}
<CopyToClipboard {canExportData && (
text={prepareCopyToClipboardTabularData(data, columns)} <CopyToClipboard
wrapped={false} text={prepareCopyToClipboardTabularData(data, columns)}
copyNode={ wrapped={false}
<Button buttonSize="small"> copyNode={
<i className="fa fa-clipboard" /> {t('Copy to Clipboard')} <Button
</Button> buttonSize="small"
} data-test="copy-to-clipboard-button"
hideTooltip >
onCopyEnd={() => <i className="fa fa-clipboard" /> {t('Copy to Clipboard')}
logAction(LOG_ACTIONS_SQLLAB_COPY_RESULT_TO_CLIPBOARD, {}) </Button>
} }
/> hideTooltip
onCopyEnd={() =>
logAction(LOG_ACTIONS_SQLLAB_COPY_RESULT_TO_CLIPBOARD, {})
}
/>
)}
</ResultSetButtons> </ResultSetButtons>
{search && ( {search && (
<input <input

View File

@ -303,7 +303,10 @@ const SqlEditor: FC<Props> = ({
); );
const [showCreateAsModal, setShowCreateAsModal] = useState(false); const [showCreateAsModal, setShowCreateAsModal] = useState(false);
const [createAs, setCreateAs] = useState(''); const [createAs, setCreateAs] = useState('');
const [showEmptyState, setShowEmptyState] = useState(false); const showEmptyState = useMemo(
() => !database || isEmpty(database),
[database],
);
const sqlEditorRef = useRef<HTMLDivElement>(null); const sqlEditorRef = useRef<HTMLDivElement>(null);
const northPaneRef = useRef<HTMLDivElement>(null); const northPaneRef = useRef<HTMLDivElement>(null);
@ -562,12 +565,6 @@ const SqlEditor: FC<Props> = ({
// TODO: Remove useEffectEvent deps once https://github.com/facebook/react/pull/25881 is released // TODO: Remove useEffectEvent deps once https://github.com/facebook/react/pull/25881 is released
}, [onBeforeUnload, loadQueryEditor, isActive]); }, [onBeforeUnload, loadQueryEditor, isActive]);
useEffect(() => {
if (!database || isEmpty(database)) {
setShowEmptyState(true);
}
}, [database]);
useEffect(() => { useEffect(() => {
// setup hotkeys // setup hotkeys
const hotkeys = getHotkeyConfig(); const hotkeys = getHotkeyConfig();
@ -911,7 +908,6 @@ const SqlEditor: FC<Props> = ({
<SqlEditorLeftBar <SqlEditorLeftBar
database={database} database={database}
queryEditorId={queryEditor.id} queryEditorId={queryEditor.id}
setEmptyState={bool => setShowEmptyState(bool)}
/> />
</StyledSidebar> </StyledSidebar>
)} )}

View File

@ -16,14 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { import { useEffect, useCallback, useMemo, useState } from 'react';
useEffect,
useCallback,
useMemo,
useState,
Dispatch,
SetStateAction,
} from 'react';
import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import querystring from 'query-string'; import querystring from 'query-string';
@ -60,7 +53,6 @@ export interface SqlEditorLeftBarProps {
queryEditorId: string; queryEditorId: string;
height?: number; height?: number;
database?: DatabaseObject; database?: DatabaseObject;
setEmptyState?: Dispatch<SetStateAction<boolean>>;
} }
const StyledScrollbarContainer = styled.div` const StyledScrollbarContainer = styled.div`
@ -108,7 +100,6 @@ const SqlEditorLeftBar = ({
database, database,
queryEditorId, queryEditorId,
height = 500, height = 500,
setEmptyState,
}: SqlEditorLeftBarProps) => { }: SqlEditorLeftBarProps) => {
const tables = useSelector<SqlLabRootState, Table[]>( const tables = useSelector<SqlLabRootState, Table[]>(
({ sqlLab }) => ({ sqlLab }) =>
@ -148,7 +139,6 @@ const SqlEditorLeftBar = ({
}, []); }, []);
const onDbChange = ({ id: dbId }: { id: number }) => { const onDbChange = ({ id: dbId }: { id: number }) => {
setEmptyState?.(false);
dispatch(queryEditorSetDb(queryEditor, dbId)); dispatch(queryEditorSetDb(queryEditor, dbId));
}; };

View File

@ -102,6 +102,7 @@ export default function getInitialState({
id: id.toString(), id: id.toString(),
loaded: false, loaded: false,
name: label, name: label,
dbId: undefined,
}; };
} }
queryEditors = { queryEditors = {

View File

@ -54,8 +54,8 @@ const propTypes = {
// formData contains chart's own filter parameter // formData contains chart's own filter parameter
// and merged with extra filter that current dashboard applying // and merged with extra filter that current dashboard applying
formData: PropTypes.object.isRequired, formData: PropTypes.object.isRequired,
labelColors: PropTypes.object, labelsColor: PropTypes.object,
sharedLabelColors: PropTypes.object, labelsColorMap: PropTypes.object,
width: PropTypes.number, width: PropTypes.number,
height: PropTypes.number, height: PropTypes.number,
setControlValue: PropTypes.func, setControlValue: PropTypes.func,

View File

@ -18,6 +18,7 @@
*/ */
import { import {
forwardRef, forwardRef,
Key,
ReactNode, ReactNode,
RefObject, RefObject,
useCallback, useCallback,
@ -96,6 +97,9 @@ const ChartContextMenu = (
const canDatasourceSamples = useSelector((state: RootState) => const canDatasourceSamples = useSelector((state: RootState) =>
findPermission('can_samples', 'Datasource', state.user?.roles), findPermission('can_samples', 'Datasource', state.user?.roles),
); );
const canDownload = useSelector((state: RootState) =>
findPermission('can_csv', 'Superset', state.user?.roles),
);
const canDrill = useSelector((state: RootState) => const canDrill = useSelector((state: RootState) =>
findPermission('can_drill', 'Dashboard', state.user?.roles), findPermission('can_drill', 'Dashboard', state.user?.roles),
); );
@ -104,6 +108,7 @@ const ChartContextMenu = (
const crossFiltersEnabled = useSelector<RootState, boolean>( const crossFiltersEnabled = useSelector<RootState, boolean>(
({ dashboardInfo }) => dashboardInfo.crossFiltersEnabled, ({ dashboardInfo }) => dashboardInfo.crossFiltersEnabled,
); );
const [openKeys, setOpenKeys] = useState<Key[]>([]);
const isDisplayed = (item: ContextMenuItem) => const isDisplayed = (item: ContextMenuItem) =>
displayedItems === ContextMenuItem.All || displayedItems === ContextMenuItem.All ||
@ -254,6 +259,9 @@ const ChartContextMenu = (
formData={formData} formData={formData}
contextMenuY={clientY} contextMenuY={clientY}
submenuIndex={submenuIndex} submenuIndex={submenuIndex}
canDownload={canDownload}
open={openKeys.includes('drill-by-submenu')}
key="drill-by-submenu"
{...(additionalConfig?.drillBy || {})} {...(additionalConfig?.drillBy || {})}
/>, />,
); );
@ -288,7 +296,13 @@ const ChartContextMenu = (
return ReactDOM.createPortal( return ReactDOM.createPortal(
<Dropdown <Dropdown
overlay={ overlay={
<Menu className="chart-context-menu" data-test="chart-context-menu"> <Menu
className="chart-context-menu"
data-test="chart-context-menu"
onOpenChange={openKeys => {
setOpenKeys(openKeys);
}}
>
{menuItems.length ? ( {menuItems.length ? (
menuItems menuItems
) : ( ) : (

View File

@ -41,8 +41,8 @@ const propTypes = {
initialValues: PropTypes.object, initialValues: PropTypes.object,
formData: PropTypes.object.isRequired, formData: PropTypes.object.isRequired,
latestQueryFormData: PropTypes.object, latestQueryFormData: PropTypes.object,
labelColors: PropTypes.object, labelsColor: PropTypes.object,
sharedLabelColors: PropTypes.object, labelsColorMap: PropTypes.object,
height: PropTypes.number, height: PropTypes.number,
width: PropTypes.number, width: PropTypes.number,
setControlValue: PropTypes.func, setControlValue: PropTypes.func,
@ -153,8 +153,8 @@ class ChartRenderer extends Component {
nextProps.height !== this.props.height || nextProps.height !== this.props.height ||
nextProps.width !== this.props.width || nextProps.width !== this.props.width ||
nextProps.triggerRender || nextProps.triggerRender ||
nextProps.labelColors !== this.props.labelColors || nextProps.labelsColor !== this.props.labelsColor ||
nextProps.sharedLabelColors !== this.props.sharedLabelColors || nextProps.labelsColorMap !== this.props.labelsColorMap ||
nextProps.formData.color_scheme !== this.props.formData.color_scheme || nextProps.formData.color_scheme !== this.props.formData.color_scheme ||
nextProps.formData.stack !== this.props.formData.stack || nextProps.formData.stack !== this.props.formData.stack ||
nextProps.cacheBusterProp !== this.props.cacheBusterProp || nextProps.cacheBusterProp !== this.props.cacheBusterProp ||

View File

@ -31,11 +31,17 @@ import { DrillByMenuItems, DrillByMenuItemsProps } from './DrillByMenuItems';
/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect*"] }] */ /* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect*"] }] */
const DATASET_ENDPOINT = 'glob:*/api/v1/dataset/7'; const DATASET_ENDPOINT = 'glob:*/api/v1/dataset/7*';
const CHART_DATA_ENDPOINT = 'glob:*/api/v1/chart/data*'; const CHART_DATA_ENDPOINT = 'glob:*/api/v1/chart/data*';
const FORM_DATA_KEY_ENDPOINT = 'glob:*/api/v1/explore/form_data'; const FORM_DATA_KEY_ENDPOINT = 'glob:*/api/v1/explore/form_data';
const { form_data: defaultFormData } = chartQueries[sliceId]; const { form_data: defaultFormData } = chartQueries[sliceId];
jest.mock('lodash/debounce', () => (fn: Function & { debounce: Function }) => {
// eslint-disable-next-line no-param-reassign
fn.debounce = jest.fn();
return fn;
});
const defaultColumns = [ const defaultColumns = [
{ column_name: 'col1', groupby: true }, { column_name: 'col1', groupby: true },
{ column_name: 'col2', groupby: true }, { column_name: 'col2', groupby: true },
@ -68,6 +74,8 @@ const renderMenu = ({
<DrillByMenuItems <DrillByMenuItems
formData={formData ?? defaultFormData} formData={formData ?? defaultFormData}
drillByConfig={drillByConfig} drillByConfig={drillByConfig}
canDownload
open
{...rest} {...rest}
/> />
</Menu>, </Menu>,

View File

@ -18,11 +18,12 @@
*/ */
import { import {
ChangeEvent, CSSProperties,
ReactNode, ReactNode,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from 'react'; } from 'react';
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
@ -31,12 +32,20 @@ import {
Behavior, Behavior,
Column, Column,
ContextMenuFilters, ContextMenuFilters,
FAST_DEBOUNCE,
JsonResponse,
css, css,
ensureIsArray, ensureIsArray,
getChartMetadataRegistry, getChartMetadataRegistry,
getExtensionsRegistry,
logging,
t, t,
useTheme, useTheme,
} from '@superset-ui/core'; } from '@superset-ui/core';
import rison from 'rison';
import { debounce } from 'lodash';
import { FixedSizeList as List } from 'react-window';
import { AntdInput } from 'src/components';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
import { Input } from 'src/components/Input'; import { Input } from 'src/components/Input';
import { useToasts } from 'src/components/MessageToasts/withToasts'; import { useToasts } from 'src/components/MessageToasts/withToasts';
@ -52,7 +61,7 @@ import { getSubmenuYOffset } from '../utils';
import { MenuItemWithTruncation } from '../MenuItemWithTruncation'; import { MenuItemWithTruncation } from '../MenuItemWithTruncation';
import { Dataset } from '../types'; import { Dataset } from '../types';
const MAX_SUBMENU_HEIGHT = 200; const SUBMENU_HEIGHT = 200;
const SHOW_COLUMNS_SEARCH_THRESHOLD = 10; const SHOW_COLUMNS_SEARCH_THRESHOLD = 10;
const SEARCH_INPUT_HEIGHT = 48; const SEARCH_INPUT_HEIGHT = 48;
@ -65,8 +74,29 @@ export interface DrillByMenuItemsProps {
onClick?: (event: MouseEvent) => void; onClick?: (event: MouseEvent) => void;
openNewModal?: boolean; openNewModal?: boolean;
excludedColumns?: Column[]; excludedColumns?: Column[];
canDownload: boolean;
open: boolean;
} }
const loadDrillByOptions = getExtensionsRegistry().get('load.drillby.options');
const queryString = rison.encode({
columns: [
'table_name',
'owners.first_name',
'owners.last_name',
'created_by.first_name',
'created_by.last_name',
'created_on_humanized',
'changed_by.first_name',
'changed_by.last_name',
'changed_on_humanized',
'columns.column_name',
'columns.verbose_name',
'columns.groupby',
],
});
export const DrillByMenuItems = ({ export const DrillByMenuItems = ({
drillByConfig, drillByConfig,
formData, formData,
@ -76,6 +106,8 @@ export const DrillByMenuItems = ({
onClick = () => {}, onClick = () => {},
excludedColumns, excludedColumns,
openNewModal = true, openNewModal = true,
canDownload,
open,
...rest ...rest
}: DrillByMenuItemsProps) => { }: DrillByMenuItemsProps) => {
const theme = useTheme(); const theme = useTheme();
@ -86,6 +118,9 @@ export const DrillByMenuItems = ({
const [columns, setColumns] = useState<Column[]>([]); const [columns, setColumns] = useState<Column[]>([]);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [currentColumn, setCurrentColumn] = useState(); const [currentColumn, setCurrentColumn] = useState();
const ref = useRef<AntdInput>(null);
const showSearch =
loadDrillByOptions || columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD;
const handleSelection = useCallback( const handleSelection = useCallback(
(event, column) => { (event, column) => {
onClick(event); onClick(event);
@ -102,10 +137,14 @@ export const DrillByMenuItems = ({
}, []); }, []);
useEffect(() => { useEffect(() => {
// Input is displayed only when columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD if (open) {
// Reset search input in case Input gets removed ref.current?.input.focus();
setSearchInput(''); } else {
}, [columns.length]); // Reset search input when menu is closed
ref.current?.setValue('');
setSearchInput('');
}
}, [open]);
const hasDrillBy = drillByConfig?.groupbyFieldName; const hasDrillBy = drillByConfig?.groupbyFieldName;
@ -119,51 +158,59 @@ export const DrillByMenuItems = ({
const verboseMap = useVerboseMap(dataset); const verboseMap = useVerboseMap(dataset);
useEffect(() => { useEffect(() => {
async function loadOptions() {
const datasetId = Number(formData.datasource.split('__')[0]);
try {
setIsLoadingColumns(true);
let response: JsonResponse;
if (loadDrillByOptions) {
response = await loadDrillByOptions(datasetId, formData);
} else {
response = await cachedSupersetGet({
endpoint: `/api/v1/dataset/${datasetId}?q=${queryString}`,
});
}
const { json } = response;
const { result } = json;
setDataset(result);
setColumns(
ensureIsArray(result.columns)
.filter(column => column.groupby)
.filter(
column =>
!ensureIsArray(
formData[drillByConfig?.groupbyFieldName ?? ''],
).includes(column.column_name) &&
column.column_name !== formData.x_axis &&
ensureIsArray(excludedColumns)?.every(
excludedCol => excludedCol.column_name !== column.column_name,
),
),
);
} catch (error) {
logging.error(error);
supersetGetCache.delete(`/api/v1/dataset/${datasetId}`);
addDangerToast(t('Failed to load dimensions for drill by'));
} finally {
setIsLoadingColumns(false);
}
}
if (handlesDimensionContextMenu && hasDrillBy) { if (handlesDimensionContextMenu && hasDrillBy) {
const datasetId = formData.datasource.split('__')[0]; loadOptions();
cachedSupersetGet({
endpoint: `/api/v1/dataset/${datasetId}`,
})
.then(({ json: { result } }) => {
setDataset(result);
setColumns(
ensureIsArray(result.columns)
.filter(column => column.groupby)
.filter(
column =>
!ensureIsArray(
formData[drillByConfig.groupbyFieldName ?? ''],
).includes(column.column_name) &&
column.column_name !== formData.x_axis &&
ensureIsArray(excludedColumns)?.every(
excludedCol =>
excludedCol.column_name !== column.column_name,
),
),
);
})
.catch(() => {
supersetGetCache.delete(`/api/v1/dataset/${datasetId}`);
addDangerToast(t('Failed to load dimensions for drill by'));
})
.finally(() => {
setIsLoadingColumns(false);
});
} }
}, [ }, [
addDangerToast, addDangerToast,
drillByConfig?.groupbyFieldName,
excludedColumns, excludedColumns,
formData, formData,
drillByConfig?.groupbyFieldName,
handlesDimensionContextMenu, handlesDimensionContextMenu,
hasDrillBy, hasDrillBy,
]); ]);
const handleInput = useCallback((e: ChangeEvent<HTMLInputElement>) => { const handleInput = debounce(
e.stopPropagation(); (value: string) => setSearchInput(value),
const input = e?.target?.value; FAST_DEBOUNCE,
setSearchInput(input); );
}, []);
const filteredColumns = useMemo( const filteredColumns = useMemo(
() => () =>
@ -181,12 +228,10 @@ export const DrillByMenuItems = ({
contextMenuY, contextMenuY,
filteredColumns.length || 1, filteredColumns.length || 1,
submenuIndex, submenuIndex,
MAX_SUBMENU_HEIGHT, SUBMENU_HEIGHT,
columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD showSearch ? SEARCH_INPUT_HEIGHT : 0,
? SEARCH_INPUT_HEIGHT
: 0,
), ),
[contextMenuY, filteredColumns.length, submenuIndex, columns.length], [contextMenuY, filteredColumns.length, submenuIndex, showSearch],
); );
let tooltip: ReactNode; let tooltip: ReactNode;
@ -208,27 +253,53 @@ export const DrillByMenuItems = ({
); );
} }
const Row = ({
index,
data,
style,
}: {
index: number;
data: { columns: Column[] };
style: CSSProperties;
}) => {
const { columns, ...rest } = data;
const column = columns[index];
return (
<MenuItemWithTruncation
key={`drill-by-item-${column.column_name}`}
tooltipText={column.verbose_name || column.column_name}
{...rest}
onClick={e => handleSelection(e, column)}
style={style}
>
{column.verbose_name || column.column_name}
</MenuItemWithTruncation>
);
};
return ( return (
<> <>
<Menu.SubMenu <Menu.SubMenu
title={t('Drill by')} title={t('Drill by')}
key="drill-by-submenu"
popupClassName="chart-context-submenu" popupClassName="chart-context-submenu"
popupOffset={[0, submenuYOffset]} popupOffset={[0, submenuYOffset]}
{...rest} {...rest}
> >
<div data-test="drill-by-submenu"> <div data-test="drill-by-submenu">
{columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD && ( {showSearch && (
<Input <Input
ref={ref}
prefix={ prefix={
<Icons.Search <Icons.Search
iconSize="l" iconSize="l"
iconColor={theme.colors.grayscale.light1} iconColor={theme.colors.grayscale.light1}
/> />
} }
onChange={handleInput} onChange={e => {
e.stopPropagation();
handleInput(e.target.value);
}}
placeholder={t('Search columns')} placeholder={t('Search columns')}
value={searchInput}
onClick={e => { onClick={e => {
// prevent closing menu when clicking on input // prevent closing menu when clicking on input
e.nativeEvent.stopImmediatePropagation(); e.nativeEvent.stopImmediatePropagation();
@ -251,23 +322,16 @@ export const DrillByMenuItems = ({
<Loading position="inline-centered" /> <Loading position="inline-centered" />
</div> </div>
) : filteredColumns.length ? ( ) : filteredColumns.length ? (
<div <List
css={css` width="100%"
max-height: ${MAX_SUBMENU_HEIGHT}px; height={SUBMENU_HEIGHT}
overflow: auto; itemSize={35}
`} itemCount={filteredColumns.length}
itemData={{ columns: filteredColumns, ...rest }}
overscanCount={20}
> >
{filteredColumns.map(column => ( {Row}
<MenuItemWithTruncation </List>
key={`drill-by-item-${column.column_name}`}
tooltipText={column.verbose_name || column.column_name}
{...rest}
onClick={e => handleSelection(e, column)}
>
{column.verbose_name || column.column_name}
</MenuItemWithTruncation>
))}
</div>
) : ( ) : (
<Menu.Item disabled key="no-drill-by-columns-found" {...rest}> <Menu.Item disabled key="no-drill-by-columns-found" {...rest}>
{t('No columns found')} {t('No columns found')}
@ -282,6 +346,7 @@ export const DrillByMenuItems = ({
formData={formData} formData={formData}
onHideModal={closeModal} onHideModal={closeModal}
dataset={{ ...dataset!, verbose_map: verboseMap }} dataset={{ ...dataset!, verbose_map: verboseMap }}
canDownload={canDownload}
/> />
)} )}
</> </>

View File

@ -86,6 +86,7 @@ const renderModal = async (
onHideModal={() => setShowModal(false)} onHideModal={() => setShowModal(false)}
dataset={dataset} dataset={dataset}
drillByConfig={{ groupbyFieldName: 'groupby', filters: [] }} drillByConfig={{ groupbyFieldName: 'groupby', filters: [] }}
canDownload
{...modalProps} {...modalProps}
/> />
)} )}

View File

@ -151,6 +151,7 @@ export interface DrillByModalProps {
drillByConfig: Required<ContextMenuFilters>['drillBy']; drillByConfig: Required<ContextMenuFilters>['drillBy'];
formData: BaseFormData & { [key: string]: any }; formData: BaseFormData & { [key: string]: any };
onHideModal: () => void; onHideModal: () => void;
canDownload: boolean;
} }
type DrillByConfigs = (ContextMenuFilters['drillBy'] & { column?: Column })[]; type DrillByConfigs = (ContextMenuFilters['drillBy'] & { column?: Column })[];
@ -161,6 +162,7 @@ export default function DrillByModal({
drillByConfig, drillByConfig,
formData, formData,
onHideModal, onHideModal,
canDownload,
}: DrillByModalProps) { }: DrillByModalProps) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const theme = useTheme(); const theme = useTheme();
@ -200,6 +202,7 @@ export default function DrillByModal({
const resultsTable = useResultsTableView( const resultsTable = useResultsTableView(
chartDataResult, chartDataResult,
formData.datasource, formData.datasource,
canDownload,
); );
const [currentFormData, setCurrentFormData] = useState(formData); const [currentFormData, setCurrentFormData] = useState(formData);

View File

@ -65,7 +65,7 @@ const MOCK_CHART_DATA_RESULT = [
test('Displays results table for 1 query', () => { test('Displays results table for 1 query', () => {
const { result } = renderHook(() => const { result } = renderHook(() =>
useResultsTableView(MOCK_CHART_DATA_RESULT.slice(0, 1), '1__table'), useResultsTableView(MOCK_CHART_DATA_RESULT.slice(0, 1), '1__table', true),
); );
render(result.current, { useRedux: true }); render(result.current, { useRedux: true });
expect(screen.queryByRole('tablist')).not.toBeInTheDocument(); expect(screen.queryByRole('tablist')).not.toBeInTheDocument();
@ -76,7 +76,7 @@ test('Displays results table for 1 query', () => {
test('Displays results for 2 queries', async () => { test('Displays results for 2 queries', async () => {
const { result } = renderHook(() => const { result } = renderHook(() =>
useResultsTableView(MOCK_CHART_DATA_RESULT, '1__table'), useResultsTableView(MOCK_CHART_DATA_RESULT, '1__table', true),
); );
render(result.current, { useRedux: true }); render(result.current, { useRedux: true });
const getActiveTabElement = () => const getActiveTabElement = () =>

View File

@ -33,6 +33,7 @@ const PaginationContainer = styled.div`
export const useResultsTableView = ( export const useResultsTableView = (
chartDataResult: QueryData[] | undefined, chartDataResult: QueryData[] | undefined,
datasourceId: string, datasourceId: string,
canDownload: boolean,
) => { ) => {
if (!isDefined(chartDataResult)) { if (!isDefined(chartDataResult)) {
return <div />; return <div />;
@ -48,6 +49,7 @@ export const useResultsTableView = (
dataSize={DATA_SIZE} dataSize={DATA_SIZE}
datasourceId={datasourceId} datasourceId={datasourceId}
isVisible isVisible
canDownload={canDownload}
/> />
</PaginationContainer> </PaginationContainer>
); );
@ -65,6 +67,7 @@ export const useResultsTableView = (
dataSize={DATA_SIZE} dataSize={DATA_SIZE}
datasourceId={datasourceId} datasourceId={datasourceId}
isVisible isVisible
canDownload={canDownload}
/> />
</PaginationContainer> </PaginationContainer>
</Tabs.TabPane> </Tabs.TabPane>

View File

@ -17,7 +17,7 @@
* under the License. * under the License.
*/ */
import { ReactNode } from 'react'; import { ReactNode, CSSProperties } from 'react';
import { css, truncationCSS, useCSSTextTruncation } from '@superset-ui/core'; import { css, truncationCSS, useCSSTextTruncation } from '@superset-ui/core';
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
import { Tooltip } from 'src/components/Tooltip'; import { Tooltip } from 'src/components/Tooltip';
@ -27,6 +27,7 @@ export type MenuItemWithTruncationProps = {
tooltipText: ReactNode; tooltipText: ReactNode;
children: ReactNode; children: ReactNode;
onClick?: MenuProps['onClick']; onClick?: MenuProps['onClick'];
style?: CSSProperties;
}; };
export const MenuItemWithTruncation = ({ export const MenuItemWithTruncation = ({

View File

@ -17,12 +17,12 @@
* under the License. * under the License.
*/ */
import { styled } from '@superset-ui/core'; import { styled, useTheme } from '@superset-ui/core';
import { Tooltip } from 'src/components/Tooltip'; import { Tooltip } from 'src/components/Tooltip';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
export interface InfoTooltipProps { export interface InfoTooltipProps {
className?: string; iconStyle?: React.CSSProperties;
tooltip: string; tooltip: string;
placement?: placement?:
| 'bottom' | 'bottom'
@ -46,9 +46,6 @@ export interface InfoTooltipProps {
const StyledTooltip = styled(Tooltip)` const StyledTooltip = styled(Tooltip)`
cursor: pointer; cursor: pointer;
path:first-of-type {
fill: ${({ theme }) => theme.colors.grayscale.base};
}
`; `;
const StyledTooltipTitle = styled.span` const StyledTooltipTitle = styled.span`
@ -68,12 +65,18 @@ const defaultColor = 'rgba(0,0,0,0.9)';
export default function InfoTooltip({ export default function InfoTooltip({
tooltip, tooltip,
iconStyle = {},
placement = 'right', placement = 'right',
trigger = 'hover', trigger = 'hover',
overlayStyle = defaultOverlayStyle, overlayStyle = defaultOverlayStyle,
bgColor = defaultColor, bgColor = defaultColor,
viewBox = '0 -1 24 24', viewBox = '0 -1 24 24',
}: InfoTooltipProps) { }: InfoTooltipProps) {
const theme = useTheme();
const alteredIconStyle = {
...iconStyle,
color: iconStyle.color || theme.colors.grayscale.base,
};
return ( return (
<StyledTooltip <StyledTooltip
title={<StyledTooltipTitle>{tooltip}</StyledTooltipTitle>} title={<StyledTooltipTitle>{tooltip}</StyledTooltipTitle>}
@ -82,7 +85,7 @@ export default function InfoTooltip({
overlayStyle={overlayStyle} overlayStyle={overlayStyle}
color={bgColor} color={bgColor}
> >
<Icons.InfoSolidSmall className="info-solid-small" viewBox={viewBox} /> <Icons.InfoSolidSmall style={alteredIconStyle} viewBox={viewBox} />
</StyledTooltip> </StyledTooltip>
); );
} }

View File

@ -594,19 +594,19 @@ const shoppingData: ShoppingData[] = [
{ {
key: 1, key: 1,
item: 'Floppy Disk 10 pack', item: 'Floppy Disk 10 pack',
orderDate: Date.now(), orderDate: new Date('2015-07-02T16:16:00Z').getTime(),
price: 9.99, price: 9.99,
}, },
{ {
key: 2, key: 2,
item: 'DVD 100 pack', item: 'DVD 100 pack',
orderDate: Date.now(), orderDate: new Date('2015-07-02T16:16:00Z').getTime(),
price: 7.99, price: 7.99,
}, },
{ {
key: 3, key: 3,
item: '128 GB SSD', item: '128 GB SSD',
orderDate: Date.now(), orderDate: new Date('2015-07-02T16:16:00Z').getTime(),
price: 3.99, price: 3.99,
}, },
]; ];

View File

@ -30,7 +30,7 @@ export const Basic: ComponentStory<typeof TimeCell> = args => (
); );
Basic.args = { Basic.args = {
value: Date.now(), value: new Date('2015-07-02T16:16:00Z').getTime(),
}; };
Basic.argTypes = { Basic.argTypes = {

View File

@ -17,13 +17,7 @@
* under the License. * under the License.
*/ */
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
import { import { makeApi, t, getErrorText } from '@superset-ui/core';
makeApi,
CategoricalColorNamespace,
t,
getErrorText,
} from '@superset-ui/core';
import { isString } from 'lodash';
import { addDangerToast } from 'src/components/MessageToasts/actions'; import { addDangerToast } from 'src/components/MessageToasts/actions';
import { import {
ChartConfiguration, ChartConfiguration,
@ -36,39 +30,8 @@ import { onSave } from './dashboardState';
export const DASHBOARD_INFO_UPDATED = 'DASHBOARD_INFO_UPDATED'; export const DASHBOARD_INFO_UPDATED = 'DASHBOARD_INFO_UPDATED';
export function updateColorSchema(
metadata: Record<string, any>,
labelColors: Record<string, string>,
) {
const categoricalNamespace = CategoricalColorNamespace.getNamespace(
metadata?.color_namespace,
);
const colorMap = isString(labelColors)
? JSON.parse(labelColors)
: labelColors;
Object.keys(colorMap).forEach(label => {
categoricalNamespace.setColor(label, colorMap[label]);
});
}
// updates partially changed dashboard info // updates partially changed dashboard info
export function dashboardInfoChanged(newInfo: { metadata: any }) { export function dashboardInfoChanged(newInfo: { metadata: any }) {
const { metadata } = newInfo;
const categoricalNamespace = CategoricalColorNamespace.getNamespace(
metadata?.color_namespace,
);
categoricalNamespace.resetColors();
if (metadata?.shared_label_colors) {
updateColorSchema(metadata, metadata?.shared_label_colors);
}
if (metadata?.label_colors) {
updateColorSchema(metadata, metadata?.label_colors);
}
return { type: DASHBOARD_INFO_UPDATED, newInfo }; return { type: DASHBOARD_INFO_UPDATED, newInfo };
} }
export const SAVE_CHART_CONFIG_BEGIN = 'SAVE_CHART_CONFIG_BEGIN'; export const SAVE_CHART_CONFIG_BEGIN = 'SAVE_CHART_CONFIG_BEGIN';

View File

@ -23,10 +23,11 @@ import {
ensureIsArray, ensureIsArray,
isFeatureEnabled, isFeatureEnabled,
FeatureFlag, FeatureFlag,
getSharedLabelColor, getLabelsColorMap,
SupersetClient, SupersetClient,
t, t,
getClientErrorObject, getClientErrorObject,
getCategoricalSchemeRegistry,
} from '@superset-ui/core'; } from '@superset-ui/core';
import { import {
addChart, addChart,
@ -64,6 +65,13 @@ import { fetchDatasourceMetadata } from './datasources';
import { updateDirectPathToFilter } from './dashboardFilters'; import { updateDirectPathToFilter } from './dashboardFilters';
import { SET_FILTER_CONFIG_COMPLETE } from './nativeFilters'; import { SET_FILTER_CONFIG_COMPLETE } from './nativeFilters';
import getOverwriteItems from '../util/getOverwriteItems'; import getOverwriteItems from '../util/getOverwriteItems';
import {
applyColors,
isLabelsColorMapSynced,
getLabelsColorMapEntries,
getColorSchemeDomain,
getColorNamespace,
} from '../../utils/colorScheme';
export const SET_UNSAVED_CHANGES = 'SET_UNSAVED_CHANGES'; export const SET_UNSAVED_CHANGES = 'SET_UNSAVED_CHANGES';
export function setUnsavedChanges(hasUnsavedChanges) { export function setUnsavedChanges(hasUnsavedChanges) {
@ -261,7 +269,7 @@ export function saveDashboardRequest(data, id, saveType) {
slug: slug || null, slug: slug || null,
metadata: { metadata: {
...data.metadata, ...data.metadata,
color_namespace: data.metadata?.color_namespace || undefined, color_namespace: getColorNamespace(data.metadata?.color_namespace),
color_scheme: data.metadata?.color_scheme || '', color_scheme: data.metadata?.color_scheme || '',
color_scheme_domain: data.metadata?.color_scheme_domain || [], color_scheme_domain: data.metadata?.color_scheme_domain || [],
expanded_slices: data.metadata?.expanded_slices || {}, expanded_slices: data.metadata?.expanded_slices || {},
@ -584,7 +592,7 @@ export function removeSliceFromDashboard(id) {
return dispatch => { return dispatch => {
dispatch(removeSlice(id)); dispatch(removeSlice(id));
dispatch(removeChart(id)); dispatch(removeChart(id));
getSharedLabelColor().removeSlice(id); getLabelsColorMap().removeSlice(id);
}; };
} }
@ -657,3 +665,69 @@ export function setDatasetsStatus(status) {
status, status,
}; };
} }
const updateDashboardMetadata = async (id, metadata, dispatch) => {
await 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) => {
const {
dashboardInfo: { id, metadata },
} = 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 || [];
try {
const updatedMetadata = { ...metadata };
let updatedScheme = metadata?.color_scheme;
// 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) {
updatedMetadata.color_scheme_domain = freshColorSchemeDomain;
}
if (
(colorScheme && (!colorSchemeRegistry || !isRegistrySynced)) ||
!isMapSynced
) {
await updateDashboardMetadata(id, updatedMetadata, dispatch);
}
} catch (error) {
console.error('Failed to update dashboard color settings:', error);
}
};

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