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.
contexts:
- lint-check
- cypress-matrix (0, chrome)
- cypress-matrix (1, chrome)
- cypress-matrix (2, chrome)
- cypress-matrix (3, chrome)
- cypress-matrix (4, chrome)
- cypress-matrix (5, chrome)
- frontend-build
- pre-commit
- python-lint

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,13 +4,13 @@ on:
push:
branches:
- "master"
- "[0-9].[0-9]"
- "[0-9].[0-9]*"
pull_request:
types: [synchronize, opened, reopened, ready_for_review]
workflow_dispatch:
inputs:
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
default: 'false'
ref:
@ -130,12 +130,12 @@ jobs:
CYPRESS_BROWSER: ${{ matrix.browser }}
PARALLEL_ID: ${{ matrix.parallel_id }}
PARALLELISM: 6
CYPRESS_KEY: YjljODE2MzAtODcwOC00NTA3LWE4NmMtMTU3YmFmMjIzOTRhCg==
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
with:
run: cypress-run-all ${{ env.USE_DASHBOARD }}
- name: Upload Artifacts
uses: actions/upload-artifact@v4
if: github.event_name == 'workflow_dispatch' && (steps.check.outputs.python || steps.check.outputs.frontend)
with:
name: 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:
branches:
- "master"
- "[0-9].[0-9]"
- "[0-9].[0-9]*"
pull_request:
types: [synchronize, opened, reopened, ready_for_review]

View File

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

View File

@ -5,7 +5,7 @@ on:
push:
branches:
- "master"
- "[0-9].[0-9]"
- "[0-9].[0-9]*"
pull_request:
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
services:
mysql:
image: mysql:5.7
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
ports:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -71,3 +71,4 @@ snowflake.svg
# docs-related
erd.puml
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
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"
- AVOID_COLORS_COLLISION
- DASHBOARD_CROSS_FILTERS
- ENABLE_JAVASCRIPT_CONTROLS
- KV_STORE

View File

@ -154,6 +154,7 @@ Join our growing community!
### Healthcare
- [Amino](https://amino.com) [@shkr]
- [Beans](https://www.beans.fi) [@kakoni]
- [Bluesquare](https://www.bluesquarehub.com/) [@madewulf]
- [Care](https://www.getcare.io/)[@alandao2021]
- [Living Goods](https://www.livinggoods.org) [@chelule]
- [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]
### Travel
- [Agoda](https://www.agoda.com/) [@lostseaway, @maiake, @obombayo]
- [Skyscanner](https://www.skyscanner.net/) [@cleslie, @stanhoucke]
### Others

View File

@ -24,6 +24,10 @@ assists people when migrating to a new version.
## 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
`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

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/
# The base URL for the email report hyperlinks.
WEBDRIVER_BASEURL_USER_FRIENDLY = WEBDRIVER_BASEURL
SQLLAB_CTAS_NO_LIMIT = True
#

View File

@ -2,6 +2,12 @@
title: CVEs fixed by release
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
| CVE | Title | Affected |

View File

@ -5,13 +5,13 @@
"license": "Apache-2.0",
"scripts": {
"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",
"build": "npm run _init && docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"serve": "docusaurus serve",
"serve": "npm run _init && docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"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"
ws@^7.3.1:
version "7.5.9"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591"
integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==
version "7.5.10"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9"
integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==
ws@^8.13.0:
version "8.17.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.0.tgz#d145d18eca2ed25aaf791a183903f7be5e295fea"
integrity sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==
version "8.17.1"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b"
integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==
xdg-basedir@^5.0.1, xdg-basedir@^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' "
REPO = os.getenv("GITHUB_REPOSITORY") or "apache/superset"
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:
@ -55,13 +56,7 @@ def get_cypress_cmd(
if use_dashboard:
# Run using cypress.io service
cypress_key = os.getenv("CYPRESS_KEY")
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 = "*/**/*"
spec: str = "cypress/e2e/*/**/*"
cmd = (
f"{XVFB_PRE_CMD} "
f'{cypress_cmd} --spec "{spec}" --browser {browser} '
@ -70,7 +65,7 @@ def get_cypress_cmd(
)
else:
# 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))
if _filter:
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_USER="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_CONFIG=${SUPERSET_CONFIG:-tests.integration_tests.superset_test_config}
RUN_INIT=1
RUN_RESET_DB=1

View File

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

View File

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

View File

@ -124,7 +124,7 @@ function selectColorScheme(color: string) {
)
.first()
.click();
cy.getBySel(color).click();
cy.getBySel(color).click({ force: true });
}
function applyChanges() {
@ -169,6 +169,7 @@ function writeMetadata(metadata: string) {
function openExplore(chartName: string) {
interceptExploreJson();
interceptGet();
cy.get(
`[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',
)
.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', () => {
@ -231,7 +232,7 @@ describe('Dashboard edit', { testIsolation: false }, () => {
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(0, 234, 162)');
.should('have.css', 'fill', 'rgb(50, 0, 167)');
// open 2nd main tab
openTab(0, 1);
@ -240,7 +241,7 @@ describe('Dashboard edit', { testIsolation: false }, () => {
// label Anthony
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.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', () => {
@ -261,7 +262,7 @@ describe('Dashboard edit', { testIsolation: false }, () => {
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(69, 78, 124)');
.should('have.css', 'fill', 'rgb(31, 168, 201)');
// open 2nd main tab
openTab(0, 1);
@ -270,7 +271,7 @@ describe('Dashboard edit', { testIsolation: false }, () => {
// label Anthony
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.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', () => {
@ -384,17 +385,17 @@ describe('Dashboard edit', { testIsolation: false }, () => {
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(69, 78, 124)');
.should('have.css', 'fill', 'rgb(31, 168, 201)');
cy.get(
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.eq(1)
.should('have.css', 'fill', 'rgb(224, 67, 85)');
.should('have.css', 'fill', 'rgb(69, 78, 124)');
cy.get(
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.eq(2)
.should('have.css', 'fill', 'rgb(163, 143, 121)');
.should('have.css', 'fill', 'rgb(90, 193, 137)');
openProperties();
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',
)
.first()
.should('have.css', 'fill', 'rgb(69, 78, 124)');
.should('have.css', 'fill', 'rgb(31, 168, 201)');
cy.get(
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.eq(1)
.should('have.css', 'fill', 'rgb(224, 67, 85)');
.should('have.css', 'fill', 'rgb(69, 78, 124)');
cy.get(
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.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', () => {
@ -459,12 +460,6 @@ describe('Dashboard edit', { testIsolation: false }, () => {
)
.first()
.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');
@ -472,10 +467,6 @@ describe('Dashboard edit', { testIsolation: false }, () => {
cy.get('[data-test="chart-container"] .line .nv-legend-symbol')
.first()
.should('have.css', 'fill', 'rgb(255, 0, 0)');
// label Christopher
cy.get('[data-test="chart-container"] .line .nv-legend-symbol')
.eq(1)
.should('have.css', 'fill', 'rgb(172, 32, 119)');
});
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',
)
.first()
.should('have.css', 'fill', 'rgb(51, 61, 71)');
.should('have.css', 'fill', 'rgb(234, 11, 140)');
// open 2nd main tab
openTab(0, 1);
@ -505,7 +496,7 @@ describe('Dashboard edit', { testIsolation: false }, () => {
// label Anthony
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.eq(2)
.should('have.css', 'fill', 'rgb(51, 61, 71)');
.should('have.css', 'fill', 'rgb(234, 11, 140)');
editDashboard();
openProperties();
@ -516,7 +507,7 @@ describe('Dashboard edit', { testIsolation: false }, () => {
// label Anthony
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.eq(2)
.should('have.css', 'fill', 'rgb(244, 176, 42)');
.should('have.css', 'fill', 'rgb(41, 105, 107)');
// open main tab and nested tab
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',
)
.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', () => {
@ -542,7 +533,7 @@ describe('Dashboard edit', { testIsolation: false }, () => {
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.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', () => {
@ -558,7 +549,7 @@ describe('Dashboard edit', { testIsolation: false }, () => {
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.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
editDashboard();
@ -588,7 +579,7 @@ describe('Dashboard edit', { testIsolation: false }, () => {
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(51, 61, 71)');
.should('have.css', 'fill', 'rgb(234, 11, 140)');
// open another nested tab
openTab(2, 1);

View File

@ -97,8 +97,5 @@ describe('Visualization > Compare', { testIsolation: false }, () => {
cy.get(
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="supersetColors"]',
).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(
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="bnbColors"]',
).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": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.1.tgz",
"integrity": "sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==",
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.5.tgz",
"integrity": "sha512-Nsdud2X65Dz+1RHjAIP0t8z5e2ff/IRbei6BqFrl1urT8sDVzM1HMQ+R0XcU5ceRfyO3I6ayeqIfh+6Wb8LGTw==",
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/utils": "^0.2.1"
"@floating-ui/core": "^1.0.0",
"@floating-ui/utils": "^0.2.0"
}
},
"node_modules/@floating-ui/react-dom": {
@ -32060,9 +32060,9 @@
}
},
"node_modules/dayjs": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.7.tgz",
"integrity": "sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig=="
"version": "1.11.10",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz",
"integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="
},
"node_modules/deasync": {
"version": "0.1.29",
@ -68993,7 +68993,7 @@
"d3-array": "^1.2.4",
"d3-color": "^1.4.1",
"d3-scale": "^3.0.0",
"deck.gl": "9.0.6",
"deck.gl": "9.0.12",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"mousetrap": "^1.6.5",
@ -75280,12 +75280,12 @@
}
},
"@floating-ui/dom": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.1.tgz",
"integrity": "sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==",
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.5.tgz",
"integrity": "sha512-Nsdud2X65Dz+1RHjAIP0t8z5e2ff/IRbei6BqFrl1urT8sDVzM1HMQ+R0XcU5ceRfyO3I6ayeqIfh+6Wb8LGTw==",
"requires": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/utils": "^0.2.1"
"@floating-ui/core": "^1.0.0",
"@floating-ui/utils": "^0.2.0"
}
},
"@floating-ui/react-dom": {
@ -87294,7 +87294,7 @@
"d3-array": "^1.2.4",
"d3-color": "^1.4.1",
"d3-scale": "^3.0.0",
"deck.gl": "9.0.6",
"deck.gl": "9.0.12",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"mousetrap": "^1.6.5",
@ -97251,9 +97251,9 @@
"dev": true
},
"dayjs": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.7.tgz",
"integrity": "sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig=="
"version": "1.11.10",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz",
"integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="
},
"deasync": {
"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 {
metrics: dndAdhocMetricsControl,
metric: dndAdhocMetricControl,
@ -400,4 +408,5 @@ export default {
show_empty_columns,
temporal_columns_lookup,
currency_format,
sort_by_metric,
};

View File

@ -17,19 +17,18 @@
* under the License.
*/
/* eslint-disable no-dupe-class-members */
import { scaleOrdinal, ScaleOrdinal } from 'd3-scale';
import { ExtensibleFunction } from '../models';
import { ColorsInitLookup, ColorsLookup } from './types';
import stringifyAndTrim from './stringifyAndTrim';
import getSharedLabelColor from './SharedLabelColorSingleton';
import getLabelsColorMap from './LabelsColorMapSingleton';
import { getAnalogousColors } from './utils';
import { FeatureFlag, isFeatureEnabled } from '../utils';
// Use type augmentation to correct the fact that
// an instance of CategoricalScale is also a function
interface CategoricalColorScale {
(x: { toString(): string }, y?: number): string;
(x: { toString(): string }, y?: number, w?: string): string;
}
class CategoricalColorScale extends ExtensibleFunction {
@ -39,101 +38,183 @@ class CategoricalColorScale extends ExtensibleFunction {
scale: ScaleOrdinal<{ toString(): string }, string>;
parentForcedColors: ColorsLookup;
forcedColors: ColorsLookup;
labelsColorMapInstance: ReturnType<typeof getLabelsColorMap>;
chartLabelsColorMap: Map<string, string>;
multiple: number;
/**
* Constructor
* @param {*} colors an array of colors
* @param {*} parentForcedColors optional parameter that comes from parent
* (usually CategoricalColorNamespace) and supersede this.forcedColors
* @param {*} forcedColors optional parameter that comes from parent
* (usually CategoricalColorNamespace)
*/
constructor(colors: string[], parentForcedColors: ColorsInitLookup = {}) {
super((value: string, sliceId?: number) => this.getColor(value, sliceId));
constructor(colors: string[], forcedColors: ColorsInitLookup = {}) {
super((value: string, sliceId?: number, colorScheme?: string) =>
this.getColor(value, sliceId, colorScheme),
);
// holds original color scheme colors
this.originColors = colors;
// holds the extended color range (includes analagous 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.range(colors);
// 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') {
// 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
this.parentForcedColors = parentForcedColors as ColorsLookup;
this.forcedColors = {};
this.multiple = 0;
// forced colors from parent (usually CategoricalColorNamespace)
// currently used in dashboards to set custom label colors
this.forcedColors = forcedColors as ColorsLookup;
}
removeSharedLabelColorFromRange(
sharedColorMap: Map<string, string>,
cleanedValue: string,
) {
// make sure we don't overwrite the origin colors
const updatedRange = new Set(this.originColors);
// remove the color option from shared color
sharedColorMap.forEach((value: string, key: string) => {
if (key !== cleanedValue) {
updatedRange.delete(value);
}
});
// remove the color option from forced colors
Object.entries(this.parentForcedColors).forEach(([key, value]) => {
if (key !== cleanedValue) {
updatedRange.delete(value);
}
});
this.range(updatedRange.size > 0 ? [...updatedRange] : this.originColors);
/**
* Increment the color range with analogous colors
*/
incrementColorRange() {
const multiple = Math.floor(
this.domain().length / this.originColors.length,
);
// the domain has grown larger than the original range
// increments the range with analogous colors
if (multiple > this.multiple) {
this.multiple = multiple;
const newRange = getAnalogousColors(this.originColors, multiple);
const extendedColors = this.originColors.concat(newRange);
this.range(extendedColors);
this.colors = extendedColors;
}
}
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 sharedLabelColor = getSharedLabelColor();
const sharedColorMap = sharedLabelColor.getColorMap();
const sharedColor = sharedColorMap.get(cleanedValue);
// priority: forced color (i.e. custom label colors) > shared color > scale color
const forcedColor = this.forcedColors?.[cleanedValue];
const isExistingLabel = this.chartLabelsColorMap.has(cleanedValue);
let color = forcedColor || this.scale(cleanedValue);
// priority: parentForcedColors > forcedColors > labelColors
let color =
this.parentForcedColors?.[cleanedValue] ||
this.forcedColors?.[cleanedValue] ||
sharedColor;
// a forced color will always be used independently of the usage count
if (!forcedColor && !isExistingLabel) {
if (isFeatureEnabled(FeatureFlag.UseAnalagousColors)) {
this.incrementColorRange();
}
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)) {
const multiple = Math.floor(
this.domain().length / this.originColors.length,
// keep track of values in this slice
this.chartLabelsColorMap.set(cleanedValue, color);
// 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;
}
/**
* 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 {*} forcedColor forcedColor
* @returns {CategoricalColorScale}
*/
setColor(value: string, forcedColor: string) {
this.forcedColors[stringifyAndTrim(value)] = forcedColor;
@ -142,6 +223,7 @@ class CategoricalColorScale extends ExtensibleFunction {
/**
* 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
*/
getColorMap() {
@ -153,22 +235,23 @@ class CategoricalColorScale extends ExtensibleFunction {
return {
...colorMap,
...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() {
const copy = new CategoricalColorScale(
this.scale.range(),
this.parentForcedColors,
this.forcedColors,
);
copy.forcedColors = { ...this.forcedColors };
copy.domain(this.domain());
copy.unknown(this.unknown());
return copy;
}

View File

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

View File

@ -34,9 +34,9 @@ export * from './colorSchemes';
export * from './utils';
export * from './types';
export {
default as getSharedLabelColor,
SharedLabelColor,
SharedLabelColorSource,
} from './SharedLabelColorSingleton';
default as getLabelsColorMap,
LabelsColorMap,
LabelsColorMapSource,
} from './LabelsColorMapSingleton';
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) {
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 analogousColors = colors.map(color => {
// returns an array of tinycolor instances
const result = tinycolor(color).analogous(results + ext);
// remove the first three colors to avoid the same or very close colors
return result.slice(ext);
});

View File

@ -17,9 +17,12 @@
* 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);
useEffect(() => {
if (isMountedRef.current) {
@ -27,5 +30,6 @@ export const useComponentDidUpdate = (effect: EffectCallback) => {
} else {
isMountedRef.current = true;
}
}, [effect]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...(deps || [effect])]);
};

View File

@ -23,6 +23,8 @@ import {
ComponentType,
} from 'react';
import type { Editor } from 'brace';
import { BaseFormData } from '../query';
import { JsonResponse } from '../connection';
/**
* 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;
/**
* 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.
* 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<{
'alertsreports.header.icon': ComponentType;
'load.drillby.options': LoadDrillByOptions;
'embedded.documentation.configuration_details': ComponentType<ConfigDetailsProps>;
'embedded.documentation.description': ReturningDisplayable;
'embedded.documentation.url': string;

View File

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

View File

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

View File

@ -18,50 +18,76 @@
*/
import { ScaleOrdinal } from 'd3-scale';
import {
CategoricalColorScale,
FeatureFlag,
getSharedLabelColor,
} from '@superset-ui/core';
import { CategoricalColorScale, FeatureFlag } from '@superset-ui/core';
describe('CategoricalColorScale', () => {
beforeEach(() => {
window.featureFlags = {};
});
it('exists', () => {
expect(CategoricalColorScale !== undefined).toBe(true);
});
describe('new CategoricalColorScale(colors, parentForcedColors)', () => {
it('can create new scale when parentForcedColors is not given', () => {
describe('new CategoricalColorScale(colors, forcedColors)', () => {
it('can create new scale when forcedColors is not given', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
expect(scale).toBeInstanceOf(CategoricalColorScale);
});
it('can create new scale when parentForcedColors is given', () => {
const parentForcedColors = {};
it('can create new scale when forcedColors is given', () => {
const forcedColors = {};
const scale = new CategoricalColorScale(
['blue', 'red', 'green'],
parentForcedColors,
forcedColors,
);
expect(scale).toBeInstanceOf(CategoricalColorScale);
expect(scale.parentForcedColors).toBe(parentForcedColors);
expect(scale.forcedColors).toBe(forcedColors);
});
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(
['blue', 'red', 'green'],
parentForcedColors,
forcedColors,
);
expect(scale.getColor('pig')).toEqual('red');
expect(parentForcedColors.pig).toEqual('red');
expect(forcedColors.pig).toEqual('red');
// can loop around the scale
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', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
const scale = new CategoricalColorScale(['blue', 'red', 'green'], {
pig: 'red',
horse: 'green',
});
const c1 = scale.getColor('pig');
const c2 = scale.getColor('horse');
const c3 = scale.getColor('pig');
@ -82,9 +108,6 @@ describe('CategoricalColorScale', () => {
expect(c3).not.toBe(c1);
});
it('recycles colors when number of items exceed available colors', () => {
window.featureFlags = {
[FeatureFlag.UseAnalagousColors]: false,
};
const colorSet: { [key: string]: number } = {};
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
const colors = [
@ -118,43 +141,70 @@ describe('CategoricalColorScale', () => {
scale.getColor('cow');
scale.getColor('donkey');
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 = {
[FeatureFlag.AvoidColorsCollision]: true,
};
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
const color1 = scale.getColor('a', 1);
expect(scale.range()).toHaveLength(3);
const color2 = scale.getColor('a', 2);
expect(color1).toBe(color2);
scale.getColor('b', 2);
expect(scale.range()).toHaveLength(2);
scale.getColor('c', 2);
expect(scale.range()).toHaveLength(1);
scale.getColor('testValue1');
scale.getColor('testValue2');
scale.getColor('testValue1');
scale.getColor('testValue3');
scale.getColor('testValue4');
expect(getNextAvailableColorSpy).toHaveBeenCalledWith('blue');
getNextAvailableColorSpy.mockClear();
window.featureFlags = {
[FeatureFlag.AvoidColorsCollision]: false,
};
scale.getColor('testValue3');
expect(getNextAvailableColorSpy).not.toHaveBeenCalled();
});
window.featureFlags = {
[FeatureFlag.AvoidColorsCollision]: false,
};
});
describe('.setColor(value, forcedColor)', () => {
it('overrides default color', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
scale.setColor('pig', 'pink');
expect(scale.getColor('pig')).toBe('pink');
});
it('does not override parentForcedColors', () => {
it('does override forcedColors', () => {
const scale1 = new CategoricalColorScale(['blue', 'red', 'green']);
scale1.setColor('pig', 'black');
const scale2 = new CategoricalColorScale(
['blue', 'red', 'green'],
scale1.forcedColors,
);
const scale2 = new CategoricalColorScale(['blue', 'red', 'green']);
scale2.setColor('pig', 'pink');
expect(scale2.getColor('pig')).toBe('pink');
expect(scale1.getColor('pig')).toBe('black');
expect(scale2.getColor('pig')).toBe('black');
});
it('returns the scale', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
@ -163,7 +213,7 @@ describe('CategoricalColorScale', () => {
});
});
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']);
scale1.setColor('cow', 'black');
const scale2 = new CategoricalColorScale(
@ -177,7 +227,7 @@ describe('CategoricalColorScale', () => {
expect(scale2.getColorMap()).toEqual({
cow: 'black',
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', () => {
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']);
expect(scale.getColor('pig')).toBe(scale('pig'));
expect(scale.getColor('cat')).toBe(scale('cat'));
expect(scale.getColor('pig')).toBe('blue');
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');
});
});
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')
.attr('id', (d, i) => `group${i}`)
.attr('d', arc)
.style('fill', (d, i) => colorFn(nodes[i], sliceId));
.style('fill', (d, i) => colorFn(nodes[i], sliceId, colorScheme));
// Add a text label.
const groupText = group.append('text').attr('x', 6).attr('dy', 15);
@ -121,7 +121,7 @@ function Chord(element, props) {
.on('mouseover', d => {
chord.classed('fade', p => p !== d);
})
.style('fill', d => colorFn(nodes[d.source.index], sliceId))
.style('fill', d => colorFn(nodes[d.source.index], sliceId, colorScheme))
.attr('d', path);
// Add an elaborate mouseover title for each chord.

View File

@ -33,18 +33,7 @@ const config: ControlPanelConfig = {
['metric'],
['adhoc_filters'],
['row_limit'],
[
{
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.',
),
},
},
],
['sort_by_metric'],
],
},
{

View File

@ -64,18 +64,7 @@ const config: ControlPanelConfig = {
['metric'],
['adhoc_filters'],
['row_limit'],
[
{
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.',
),
},
},
],
['sort_by_metric'],
],
},
{

View File

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

View File

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

View File

@ -120,10 +120,16 @@ function Rose(element, props) {
.map(v => ({
key: v.name,
value: v.value,
color: colorFn(v.name, sliceId),
color: colorFn(v.name, sliceId, colorScheme),
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 {
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);
tooltip.headerFormatter(timeFormat).valueFormatter(format);
@ -379,7 +385,7 @@ function Rose(element, props) {
const arcs = ae
.append('path')
.attr('class', 'arc')
.attr('fill', d => colorFn(d.name, sliceId))
.attr('fill', d => colorFn(d.name, sliceId, colorScheme))
.attr('d', arc);
function mousemove() {

View File

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

View File

@ -49,18 +49,7 @@ const config: ControlPanelConfig = {
},
},
],
[
{
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.',
),
},
},
],
['sort_by_metric'],
],
},
{

View File

@ -54,18 +54,7 @@ const config: ControlPanelConfig = {
['metric'],
['adhoc_filters'],
['row_limit'],
[
{
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.',
),
},
},
],
['sort_by_metric'],
],
},
{

View File

@ -31,7 +31,7 @@
"d3-array": "^1.2.4",
"d3-color": "^1.4.1",
"d3-scale": "^3.0.0",
"deck.gl": "9.0.6",
"deck.gl": "9.0.12",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"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)) {
let color;
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 {
color = fixedColor;
}
@ -134,7 +137,10 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
return data.map(d => {
let color;
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 };
}

View File

@ -658,7 +658,9 @@ function nvd3Vis(element, props) {
} else if (vizType !== 'bullet') {
const colorFn = getScale(colorScheme);
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`],
],
itemStyle: {
color: colorFn(groupbyLabel, sliceId),
color: colorFn(groupbyLabel, sliceId, colorScheme),
opacity: isFiltered ? OpacityEnum.SemiTransparent : 0.6,
borderColor: colorFn(groupbyLabel, sliceId),
borderColor: colorFn(groupbyLabel, sliceId, colorScheme),
},
};
});
@ -149,7 +149,7 @@ export default function transformProps(
},
},
itemStyle: {
color: colorFn(groupbyLabel, sliceId),
color: colorFn(groupbyLabel, sliceId, colorScheme),
opacity: isFiltered
? OpacityEnum.SemiTransparent
: OpacityEnum.NonTransparent,

View File

@ -62,12 +62,8 @@ const config: ControlPanelConfig = {
{
name: 'sort_by_metric',
config: {
...sharedControls.sort_by_metric,
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,
name,
itemStyle: {
color: colorFn(name, sliceId),
color: colorFn(name, sliceId, colorScheme),
opacity: isFiltered
? OpacityEnum.SemiTransparent
: OpacityEnum.NonTransparent,

View File

@ -53,18 +53,7 @@ const config: ControlPanelConfig = {
},
},
],
[
{
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.',
),
},
},
],
['sort_by_metric'],
],
},
{

View File

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

View File

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

View File

@ -26,6 +26,7 @@ import {
D3_FORMAT_OPTIONS,
D3_TIME_FORMAT_OPTIONS,
getStandardizedControls,
sharedControls,
} from '@superset-ui/chart-controls';
import { DEFAULT_FORM_DATA } from './types';
import { legendSection } from '../controls';
@ -56,12 +57,8 @@ const config: ControlPanelConfig = {
{
name: 'sort_by_metric',
config: {
...sharedControls.sort_by_metric,
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,
name,
itemStyle: {
color: colorFn(name, sliceId),
color: colorFn(name, sliceId, colorScheme),
opacity: isFiltered
? OpacityEnum.SemiTransparent
: OpacityEnum.NonTransparent,

View File

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

View File

@ -42,18 +42,7 @@ const config: ControlPanelConfig = {
['secondary_metric'],
['adhoc_filters'],
['row_limit'],
[
{
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.',
),
},
},
],
['sort_by_metric'],
],
},
{

View File

@ -40,18 +40,7 @@ const config: ControlPanelConfig = {
['groupby'],
['metric'],
['row_limit'],
[
{
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.',
),
},
},
],
['sort_by_metric'],
['adhoc_filters'],
],
},

View File

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

View File

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

View File

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

View File

@ -32,18 +32,7 @@ const config: ControlPanelConfig = {
['metric'],
['adhoc_filters'],
['row_limit'],
[
{
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.',
),
},
},
],
['sort_by_metric'],
],
},
{

View File

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

View File

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

View File

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

View File

@ -303,7 +303,10 @@ const SqlEditor: FC<Props> = ({
);
const [showCreateAsModal, setShowCreateAsModal] = useState(false);
const [createAs, setCreateAs] = useState('');
const [showEmptyState, setShowEmptyState] = useState(false);
const showEmptyState = useMemo(
() => !database || isEmpty(database),
[database],
);
const sqlEditorRef = 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
}, [onBeforeUnload, loadQueryEditor, isActive]);
useEffect(() => {
if (!database || isEmpty(database)) {
setShowEmptyState(true);
}
}, [database]);
useEffect(() => {
// setup hotkeys
const hotkeys = getHotkeyConfig();
@ -911,7 +908,6 @@ const SqlEditor: FC<Props> = ({
<SqlEditorLeftBar
database={database}
queryEditorId={queryEditor.id}
setEmptyState={bool => setShowEmptyState(bool)}
/>
</StyledSidebar>
)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,11 +31,17 @@ import { DrillByMenuItems, DrillByMenuItemsProps } from './DrillByMenuItems';
/* 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 FORM_DATA_KEY_ENDPOINT = 'glob:*/api/v1/explore/form_data';
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 = [
{ column_name: 'col1', groupby: true },
{ column_name: 'col2', groupby: true },
@ -68,6 +74,8 @@ const renderMenu = ({
<DrillByMenuItems
formData={formData ?? defaultFormData}
drillByConfig={drillByConfig}
canDownload
open
{...rest}
/>
</Menu>,

View File

@ -18,11 +18,12 @@
*/
import {
ChangeEvent,
CSSProperties,
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Menu } from 'src/components/Menu';
@ -31,12 +32,20 @@ import {
Behavior,
Column,
ContextMenuFilters,
FAST_DEBOUNCE,
JsonResponse,
css,
ensureIsArray,
getChartMetadataRegistry,
getExtensionsRegistry,
logging,
t,
useTheme,
} 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 { Input } from 'src/components/Input';
import { useToasts } from 'src/components/MessageToasts/withToasts';
@ -52,7 +61,7 @@ import { getSubmenuYOffset } from '../utils';
import { MenuItemWithTruncation } from '../MenuItemWithTruncation';
import { Dataset } from '../types';
const MAX_SUBMENU_HEIGHT = 200;
const SUBMENU_HEIGHT = 200;
const SHOW_COLUMNS_SEARCH_THRESHOLD = 10;
const SEARCH_INPUT_HEIGHT = 48;
@ -65,8 +74,29 @@ export interface DrillByMenuItemsProps {
onClick?: (event: MouseEvent) => void;
openNewModal?: boolean;
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 = ({
drillByConfig,
formData,
@ -76,6 +106,8 @@ export const DrillByMenuItems = ({
onClick = () => {},
excludedColumns,
openNewModal = true,
canDownload,
open,
...rest
}: DrillByMenuItemsProps) => {
const theme = useTheme();
@ -86,6 +118,9 @@ export const DrillByMenuItems = ({
const [columns, setColumns] = useState<Column[]>([]);
const [showModal, setShowModal] = useState(false);
const [currentColumn, setCurrentColumn] = useState();
const ref = useRef<AntdInput>(null);
const showSearch =
loadDrillByOptions || columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD;
const handleSelection = useCallback(
(event, column) => {
onClick(event);
@ -102,10 +137,14 @@ export const DrillByMenuItems = ({
}, []);
useEffect(() => {
// Input is displayed only when columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD
// Reset search input in case Input gets removed
setSearchInput('');
}, [columns.length]);
if (open) {
ref.current?.input.focus();
} else {
// Reset search input when menu is closed
ref.current?.setValue('');
setSearchInput('');
}
}, [open]);
const hasDrillBy = drillByConfig?.groupbyFieldName;
@ -119,51 +158,59 @@ export const DrillByMenuItems = ({
const verboseMap = useVerboseMap(dataset);
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) {
const datasetId = formData.datasource.split('__')[0];
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);
});
loadOptions();
}
}, [
addDangerToast,
drillByConfig?.groupbyFieldName,
excludedColumns,
formData,
drillByConfig?.groupbyFieldName,
handlesDimensionContextMenu,
hasDrillBy,
]);
const handleInput = useCallback((e: ChangeEvent<HTMLInputElement>) => {
e.stopPropagation();
const input = e?.target?.value;
setSearchInput(input);
}, []);
const handleInput = debounce(
(value: string) => setSearchInput(value),
FAST_DEBOUNCE,
);
const filteredColumns = useMemo(
() =>
@ -181,12 +228,10 @@ export const DrillByMenuItems = ({
contextMenuY,
filteredColumns.length || 1,
submenuIndex,
MAX_SUBMENU_HEIGHT,
columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD
? SEARCH_INPUT_HEIGHT
: 0,
SUBMENU_HEIGHT,
showSearch ? SEARCH_INPUT_HEIGHT : 0,
),
[contextMenuY, filteredColumns.length, submenuIndex, columns.length],
[contextMenuY, filteredColumns.length, submenuIndex, showSearch],
);
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 (
<>
<Menu.SubMenu
title={t('Drill by')}
key="drill-by-submenu"
popupClassName="chart-context-submenu"
popupOffset={[0, submenuYOffset]}
{...rest}
>
<div data-test="drill-by-submenu">
{columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD && (
{showSearch && (
<Input
ref={ref}
prefix={
<Icons.Search
iconSize="l"
iconColor={theme.colors.grayscale.light1}
/>
}
onChange={handleInput}
onChange={e => {
e.stopPropagation();
handleInput(e.target.value);
}}
placeholder={t('Search columns')}
value={searchInput}
onClick={e => {
// prevent closing menu when clicking on input
e.nativeEvent.stopImmediatePropagation();
@ -251,23 +322,16 @@ export const DrillByMenuItems = ({
<Loading position="inline-centered" />
</div>
) : filteredColumns.length ? (
<div
css={css`
max-height: ${MAX_SUBMENU_HEIGHT}px;
overflow: auto;
`}
<List
width="100%"
height={SUBMENU_HEIGHT}
itemSize={35}
itemCount={filteredColumns.length}
itemData={{ columns: filteredColumns, ...rest }}
overscanCount={20}
>
{filteredColumns.map(column => (
<MenuItemWithTruncation
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>
{Row}
</List>
) : (
<Menu.Item disabled key="no-drill-by-columns-found" {...rest}>
{t('No columns found')}
@ -282,6 +346,7 @@ export const DrillByMenuItems = ({
formData={formData}
onHideModal={closeModal}
dataset={{ ...dataset!, verbose_map: verboseMap }}
canDownload={canDownload}
/>
)}
</>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,13 +17,7 @@
* under the License.
*/
import { Dispatch } from 'redux';
import {
makeApi,
CategoricalColorNamespace,
t,
getErrorText,
} from '@superset-ui/core';
import { isString } from 'lodash';
import { makeApi, t, getErrorText } from '@superset-ui/core';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import {
ChartConfiguration,
@ -36,39 +30,8 @@ import { onSave } from './dashboardState';
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
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 };
}
export const SAVE_CHART_CONFIG_BEGIN = 'SAVE_CHART_CONFIG_BEGIN';

View File

@ -23,10 +23,11 @@ import {
ensureIsArray,
isFeatureEnabled,
FeatureFlag,
getSharedLabelColor,
getLabelsColorMap,
SupersetClient,
t,
getClientErrorObject,
getCategoricalSchemeRegistry,
} from '@superset-ui/core';
import {
addChart,
@ -64,6 +65,13 @@ import { fetchDatasourceMetadata } from './datasources';
import { updateDirectPathToFilter } from './dashboardFilters';
import { SET_FILTER_CONFIG_COMPLETE } from './nativeFilters';
import getOverwriteItems from '../util/getOverwriteItems';
import {
applyColors,
isLabelsColorMapSynced,
getLabelsColorMapEntries,
getColorSchemeDomain,
getColorNamespace,
} from '../../utils/colorScheme';
export const SET_UNSAVED_CHANGES = 'SET_UNSAVED_CHANGES';
export function setUnsavedChanges(hasUnsavedChanges) {
@ -261,7 +269,7 @@ export function saveDashboardRequest(data, id, saveType) {
slug: slug || null,
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_domain: data.metadata?.color_scheme_domain || [],
expanded_slices: data.metadata?.expanded_slices || {},
@ -584,7 +592,7 @@ export function removeSliceFromDashboard(id) {
return dispatch => {
dispatch(removeSlice(id));
dispatch(removeChart(id));
getSharedLabelColor().removeSlice(id);
getLabelsColorMap().removeSlice(id);
};
}
@ -657,3 +665,69 @@ export function setDatasetsStatus(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