mirror of https://github.com/apache/superset.git
Merge branch 'master' into uplift-cypress-to-v13
Signed-off-by: hainenber <dotronghai96@gmail.com>
This commit is contained in:
commit
b46baffb09
|
@ -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
|
||||
|
|
|
@ -5,7 +5,7 @@ on:
|
|||
- "superset/migrations/**"
|
||||
branches:
|
||||
- "master"
|
||||
- "[0-9].[0-9]"
|
||||
- "[0-9].[0-9]*"
|
||||
pull_request:
|
||||
paths:
|
||||
- "superset/migrations/**"
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -4,7 +4,7 @@ on:
|
|||
push:
|
||||
branches:
|
||||
- "master"
|
||||
- "[0-9].[0-9]"
|
||||
- "[0-9].[0-9]*"
|
||||
pull_request:
|
||||
branches:
|
||||
- "master"
|
||||
|
|
|
@ -4,7 +4,7 @@ on:
|
|||
push:
|
||||
branches:
|
||||
- "master"
|
||||
- "[0-9].[0-9]"
|
||||
- "[0-9].[0-9]*"
|
||||
|
||||
jobs:
|
||||
config:
|
||||
|
|
|
@ -4,7 +4,7 @@ on:
|
|||
push:
|
||||
branches:
|
||||
- "master"
|
||||
- "[0-9].[0-9]"
|
||||
- "[0-9].[0-9]*"
|
||||
|
||||
jobs:
|
||||
config:
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ on:
|
|||
push:
|
||||
branches:
|
||||
- "master"
|
||||
- "[0-9].[0-9]"
|
||||
- "[0-9].[0-9]*"
|
||||
paths:
|
||||
- "superset-frontend/src/**"
|
||||
pull_request:
|
||||
|
|
|
@ -4,7 +4,7 @@ on:
|
|||
push:
|
||||
branches:
|
||||
- "master"
|
||||
- "[0-9].[0-9]"
|
||||
- "[0-9].[0-9]*"
|
||||
|
||||
jobs:
|
||||
config:
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ on:
|
|||
push:
|
||||
branches:
|
||||
- "master"
|
||||
- "[0-9].[0-9]"
|
||||
- "[0-9].[0-9]*"
|
||||
paths:
|
||||
- "helm/**"
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ on:
|
|||
push:
|
||||
branches:
|
||||
- "master"
|
||||
- "[0-9].[0-9]"
|
||||
- "[0-9].[0-9]*"
|
||||
paths:
|
||||
- "superset-websocket/**"
|
||||
pull_request:
|
||||
|
|
|
@ -4,7 +4,7 @@ on:
|
|||
push:
|
||||
branches:
|
||||
- master
|
||||
- "[0-9].[0-9]"
|
||||
- "[0-9].[0-9]*"
|
||||
|
||||
jobs:
|
||||
config:
|
||||
|
|
|
@ -71,3 +71,4 @@ snowflake.svg
|
|||
# docs-related
|
||||
erd.puml
|
||||
erd.svg
|
||||
intro_header.txt
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
#
|
||||
|
|
|
@ -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 |
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
hide_title: true
|
||||
sidebar_position: 1
|
||||
---
|
|
@ -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"
|
||||
|
|
|
@ -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]))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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": {}
|
||||
},
|
||||
|
|
|
@ -30,7 +30,7 @@ export default eyesPlugin(
|
|||
video: false,
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 1024,
|
||||
projectId: 'ukwxzo',
|
||||
projectId: 'ud5x2f',
|
||||
retries: {
|
||||
runMode: 2,
|
||||
openMode: 0,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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)');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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)');
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
getColor(value?: string, sliceId?: number) {
|
||||
const cleanedValue = stringifyAndTrim(value);
|
||||
const sharedLabelColor = getSharedLabelColor();
|
||||
const sharedColorMap = sharedLabelColor.getColorMap();
|
||||
const sharedColor = sharedColorMap.get(cleanedValue);
|
||||
|
||||
// priority: parentForcedColors > forcedColors > labelColors
|
||||
let color =
|
||||
this.parentForcedColors?.[cleanedValue] ||
|
||||
this.forcedColors?.[cleanedValue] ||
|
||||
sharedColor;
|
||||
|
||||
if (isFeatureEnabled(FeatureFlag.UseAnalagousColors)) {
|
||||
/**
|
||||
* 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);
|
||||
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);
|
||||
const extendedColors = this.originColors.concat(newRange);
|
||||
|
||||
this.range(extendedColors);
|
||||
this.colors = extendedColors;
|
||||
}
|
||||
}
|
||||
|
||||
sharedLabelColor.addSlice(cleanedValue, color, sliceId);
|
||||
/**
|
||||
* 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);
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
@ -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])]);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -57,6 +57,7 @@ export enum FeatureFlag {
|
|||
TaggingSystem = 'TAGGING_SYSTEM',
|
||||
Thumbnails = 'THUMBNAILS',
|
||||
UseAnalagousColors = 'USE_ANALAGOUS_COLORS',
|
||||
ForceSqlLabRunAsync = 'SQLLAB_FORCE_RUN_ASYNC',
|
||||
}
|
||||
|
||||
export type ScheduleQueriesProps = {
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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: '' });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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.
|
||||
|
|
|
@ -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'],
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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'],
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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;
|
||||
})
|
||||
|
|
|
@ -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'],
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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'],
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'],
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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.',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'],
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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'],
|
||||
],
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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})`}
|
||||
>
|
||||
|
|
|
@ -80,5 +80,6 @@ export default function transformProps(chartProps: ChartProps): WordCloudProps {
|
|||
rotation,
|
||||
width,
|
||||
sliceId,
|
||||
colorScheme,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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'],
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -68,6 +68,7 @@ describe('WordCloud transformProps', () => {
|
|||
},
|
||||
},
|
||||
rotation: 'square',
|
||||
colorScheme: 'bnbColors',
|
||||
data: [{ name: 'Hulk', sum__num: 1 }],
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,21 +335,26 @@ 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>
|
||||
)}
|
||||
|
||||
{canExportData && (
|
||||
<CopyToClipboard
|
||||
text={prepareCopyToClipboardTabularData(data, columns)}
|
||||
wrapped={false}
|
||||
copyNode={
|
||||
<Button buttonSize="small">
|
||||
<Button
|
||||
buttonSize="small"
|
||||
data-test="copy-to-clipboard-button"
|
||||
>
|
||||
<i className="fa fa-clipboard" /> {t('Copy to Clipboard')}
|
||||
</Button>
|
||||
}
|
||||
|
@ -351,6 +363,7 @@ const ResultSet = ({
|
|||
logAction(LOG_ACTIONS_SQLLAB_COPY_RESULT_TO_CLIPBOARD, {})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ResultSetButtons>
|
||||
{search && (
|
||||
<input
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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));
|
||||
};
|
||||
|
||||
|
|
|
@ -102,6 +102,7 @@ export default function getInitialState({
|
|||
id: id.toString(),
|
||||
loaded: false,
|
||||
name: label,
|
||||
dbId: undefined,
|
||||
};
|
||||
}
|
||||
queryEditors = {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
) : (
|
||||
|
|
|
@ -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 ||
|
||||
|
|
|
@ -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>,
|
||||
|
|
|
@ -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
|
||||
if (open) {
|
||||
ref.current?.input.focus();
|
||||
} else {
|
||||
// Reset search input when menu is closed
|
||||
ref.current?.setValue('');
|
||||
setSearchInput('');
|
||||
}, [columns.length]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const hasDrillBy = drillByConfig?.groupbyFieldName;
|
||||
|
||||
|
@ -119,12 +158,20 @@ export const DrillByMenuItems = ({
|
|||
const verboseMap = useVerboseMap(dataset);
|
||||
|
||||
useEffect(() => {
|
||||
if (handlesDimensionContextMenu && hasDrillBy) {
|
||||
const datasetId = formData.datasource.split('__')[0];
|
||||
cachedSupersetGet({
|
||||
endpoint: `/api/v1/dataset/${datasetId}`,
|
||||
})
|
||||
.then(({ json: { result } }) => {
|
||||
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)
|
||||
|
@ -132,38 +179,38 @@ export const DrillByMenuItems = ({
|
|||
.filter(
|
||||
column =>
|
||||
!ensureIsArray(
|
||||
formData[drillByConfig.groupbyFieldName ?? ''],
|
||||
formData[drillByConfig?.groupbyFieldName ?? ''],
|
||||
).includes(column.column_name) &&
|
||||
column.column_name !== formData.x_axis &&
|
||||
ensureIsArray(excludedColumns)?.every(
|
||||
excludedCol =>
|
||||
excludedCol.column_name !== column.column_name,
|
||||
excludedCol => excludedCol.column_name !== column.column_name,
|
||||
),
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
} catch (error) {
|
||||
logging.error(error);
|
||||
supersetGetCache.delete(`/api/v1/dataset/${datasetId}`);
|
||||
addDangerToast(t('Failed to load dimensions for drill by'));
|
||||
})
|
||||
.finally(() => {
|
||||
} finally {
|
||||
setIsLoadingColumns(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (handlesDimensionContextMenu && hasDrillBy) {
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -86,6 +86,7 @@ const renderModal = async (
|
|||
onHideModal={() => setShowModal(false)}
|
||||
dataset={dataset}
|
||||
drillByConfig={{ groupbyFieldName: 'groupby', filters: [] }}
|
||||
canDownload
|
||||
{...modalProps}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 = () =>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 = ({
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue