Compare commits

...

50 Commits

Author SHA1 Message Date
Mehmet Salih Yavuz
9c7835a244 docs(api): Improve api documentation for dashboard endpoints(filter_state, permalink, embedded) (#32142) 2025-02-13 19:02:45 +02:00
gpchandran
ad057324b7 fix: upgrade to 3.11.11-slim-bookworm to address critical vulnerabilities (#32240) 2025-02-13 08:32:48 -03:00
Beto Dealmeida
2c583d1584 feat: recursive metric definitions (#32228) 2025-02-12 22:00:44 -05:00
Elizabeth Thompson
15fbb195e9 fix: remove sort values on stacked totals (#31333) 2025-02-12 16:56:53 -08:00
Maxime Beauchemin
5867b87680 docs: adding notes about using uv instead of raw pip (#32239) 2025-02-12 15:37:54 -08:00
Fardin Mustaque
52563d3eea fix: Update 'Last modified' time when modifying RLS rules (#32227)
Co-authored-by: Fardin Mustaque <fardinmustaque@Fardins-Mac-mini.local>
2025-02-12 12:22:15 -08:00
Đỗ Trọng Hải
21348c418a chore(backend): replace insecure shortid usage for native filter migration with native uuid Python implementation (#32235)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2025-02-12 13:15:51 -03:00
Levis Mbote
af3589fe91 fix(Scope): Correct issue where filters appear out of scope when sort is unchecked. (#32115) 2025-02-12 14:32:20 +01:00
JUST.in DO IT
937d40cdde fix(sqllab): close the table tab (#32224) 2025-02-11 13:13:47 -08:00
Evan Rusackas
319a860f23 chore: Working toward killing enzyme and cleaning up test noise. (#32207) 2025-02-11 12:14:36 -07:00
Maxime Beauchemin
d3b854a833 fix: set Rich tooltip -> 'Show percentage' to false by default (#32212) 2025-02-11 10:58:49 -08:00
Enzo Martellucci
650fa5ccfb fix(SaveDatasetModal): repairs field alignment in the SaveDatasetModal component (#32222)
Co-authored-by: Geido <60598000+geido@users.noreply.github.com>
2025-02-11 19:16:54 +01:00
Đỗ Trọng Hải
db70c7912c chore(fe): migrate 4 Enzyme-based tests to RTL (#31634)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2025-02-11 09:01:59 -07:00
xavier-GitHub76
3160607aaf docs: Permissions 'can this form get on UserInfoEditView' and 'can this form get on UserInfoEditView' are not associated with Aplha and Gamma by default (#32180)
Co-authored-by: Xavier RICHARD <xavier.richard@developpement-durable.gouv.fr>
2025-02-11 14:52:13 +00:00
Beto Dealmeida
eec54affc3 fix: hidrate datasetsStatus (#32211) 2025-02-11 09:50:45 -05:00
Daniel Vaz Gaspar
31d6f5a639 chore(ci): fix ephemeral env null issue number (v2) (#32221) 2025-02-11 14:32:01 +00:00
Daniel Vaz Gaspar
60424c4ccd chore(ci): fix ephemeral env null issue number (#32220) 2025-02-11 14:06:43 +00:00
Mehmet Salih Yavuz
60bbd72028 feat(dropdown accessibility): Wrap dropdown triggers with buttons for accessibility (#32189) 2025-02-11 13:09:35 +02:00
Đỗ Trọng Hải
a78968c68e chore(ci): consolidate Node version reference in CI to associated .nvmrc (#32192)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2025-02-10 11:52:36 -08:00
Maxime Beauchemin
1c3ec21e0f chore: migrating easy-to-migrate AntD vanilla components (#32010) 2025-02-10 10:46:28 -08:00
Maxime Beauchemin
8d1fb9c82d fix: false negative on critical security related to eslint-plugin-translation-vars (#32018) 2025-02-10 10:45:54 -08:00
Jonathan Morales Vélez
f01493277f docs(docker-compose): remove extra backticks (#32206) 2025-02-10 15:26:34 -03:00
Damian Pendrak
0f6bd5ea83 fix: handlebars html and css templates reset on dataset update (#32195) 2025-02-10 16:40:55 +01:00
Alexandru Soare
0030f46d2d refactor(Popover): Upgrade Popover to Antd5 (#31973)
Co-authored-by: Geido <60598000+geido@users.noreply.github.com>
2025-02-10 16:38:17 +02:00
Alex Duan
06f8f8e608 fix: TDengine move tdengine.png to databases/ subfolder (#32176) 2025-02-07 10:39:54 -08:00
Michael S. Molina
a144464506 fix: Adds an entry to UPDATING.md about DISABLE_LEGACY_DATASOURCE_EDITOR (#32185) 2025-02-07 15:30:18 -03:00
Levis Mbote
2770bc0865 fix(sqllab): correct URL format for SQL Lab permalinks (#32154) 2025-02-07 19:57:07 +02:00
Mehmet Salih Yavuz
bcc61bd933 refactor(Dropdown): Migrate Dropdown to Ant Design 5 (#31972) 2025-02-07 18:38:04 +01:00
asritha
38c46fcafd docs(typo): PostgresQL corrected to PostgreSQL (#32188) 2025-02-07 12:29:22 -05:00
Jack
f3e7c64de6 fix(virtual dataset sync): Sync virtual dataset columns when changing the SQL query (#30903)
Co-authored-by: Kamil Gabryjelski <kamil.gabryjelski@gmail.com>
2025-02-07 18:16:44 +01:00
Levis Mbote
f9f8c5d07a fix(sqllab): correct URL format for SQL Lab permalinks (#32154) 2025-02-07 18:53:29 +02:00
Adrian Koszałka
c5f4a7f302 feat: Add parseJson Handlebars Helper to Support Processing Nested JSON Data (#31998)
Co-authored-by: AdrianKoszalka <adrian.koszalka@techminers.com>
2025-02-06 15:44:49 -07:00
Elizabeth Thompson
389aae270b chore: add query context data tests (#32157) 2025-02-06 14:33:38 -08:00
dependabot[bot]
e97eb71a52 chore(deps): bump less from 4.2.1 to 4.2.2 in /docs (#32085)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-06 14:22:27 -08:00
EmmanuelCbd
5a8488af36 fix(docker): Docker python-translation-build (#32163) 2025-02-06 12:49:36 -08:00
Adrian Koszałka
205cff3a94 feat: Add parseJson Handlebars Helper to Support Processing Nested JSON Data (#31998)
Co-authored-by: AdrianKoszalka <adrian.koszalka@techminers.com>
2025-02-06 12:48:28 -08:00
Alex Duan
649a0dec6c feat: add TDengine.py driver to db_engine (#32041)
Co-authored-by: Ville Brofeldt <33317356+villebro@users.noreply.github.com>
2025-02-06 12:45:55 -08:00
Beto Dealmeida
e8990f4a36 fix: ScreenshotCachePayload serialization (#32156) 2025-02-06 15:13:40 -05:00
Chris Chinchilla
acf91e1f60 docs: fix typo in docker compose (#32171) 2025-02-06 13:01:23 -05:00
Antonio Rivero
6ed9dae2f7 fix(migrations): Handle no params in time comparison migration (#32155) 2025-02-05 23:00:22 +01:00
Đỗ Trọng Hải
ea5879bf2b fix(releasing): fix borked SVN-based image building process (#32151)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2025-02-05 13:06:21 -03:00
Beto Dealmeida
c7c3b1b0e9 fix: move oauth2 capture to get_sqla_engine (#32137) 2025-02-04 18:24:05 -05:00
Michael S. Molina
c64018d421 fix: Local tarball Docker container is missing zstd dependency (#32135) 2025-02-04 16:02:01 -03:00
Michael S. Molina
53d944d013 fix: No virtual environment when running Docker translation compiler (#32133) 2025-02-04 10:43:25 -03:00
Benjami
9aa8b09505 docs: incorrect psycopg2 package in k8s install instructions (#31999) 2025-02-04 02:33:43 -08:00
Elizabeth Thompson
8984f88a3e chore(timeseries charts): adjust legend width by padding (#32030) 2025-02-03 14:11:09 -08:00
Michael S. Molina
386aa93e24 fix: Histogram examples config (#32122) 2025-02-03 13:53:34 -03:00
Daniel Vaz Gaspar
0cd0fcdecb fix(ci): ephemeral env, handle different label, create comment (#32040) 2025-02-03 16:13:22 +00:00
Mehmet Salih Yavuz
cde2d49c95 fix(datepicker): Full width datepicker on filter value select (#32064) 2025-02-03 17:51:05 +02:00
AdheipSingh
9e5876dc17 feat: add connector for Parseable (#32052) 2025-01-31 14:36:48 -08:00
420 changed files with 8029 additions and 9254 deletions

View File

@@ -31,7 +31,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version-file: './superset-embedded-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm run ci:release

View File

@@ -21,7 +21,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version-file: './superset-embedded-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm test

View File

@@ -50,17 +50,45 @@ jobs:
echo "result=up" >> $GITHUB_OUTPUT
else
echo "result=noop" >> $GITHUB_OUTPUT
exit 1
fi
- name: Get event SHA
id: get-sha
run: |
echo "sha=${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT
if: steps.eval-label.outputs.result == 'up'
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
let prSha;
// If event is workflow_dispatch, use the issue_number from inputs
if (context.eventName === "workflow_dispatch") {
const prNumber = "${{ github.event.inputs.issue_number }}";
if (!prNumber) {
console.log("No PR number found.");
return;
}
// Fetch PR details using the provided issue_number
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
prSha = pr.head.sha;
} else {
// If it's not workflow_dispatch, use the PR head sha from the event
prSha = context.payload.pull_request.head.sha;
}
console.log(`PR SHA: ${prSha}`);
core.setOutput("sha", prSha);
- name: Looking for feature flags in PR description
uses: actions/github-script@v7
id: eval-feature-flags
if: steps.eval-label.outputs.result == 'up'
with:
script: |
const description = context.payload.pull_request
@@ -81,6 +109,7 @@ jobs:
- name: Reply with confirmation comment
uses: actions/github-script@v7
if: steps.eval-label.outputs.result == 'up'
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
@@ -161,8 +190,9 @@ jobs:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: superset-ci
IMAGE_TAG: apache/superset:${{ needs.ephemeral-env-label.outputs.sha }}-ci
PR_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
run: |
docker tag $IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:pr-${{ github.event.inputs.issue_number || github.event.issue.number }}-ci
docker tag $IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:pr-$PR_NUMBER-ci
docker push -a $ECR_REGISTRY/$ECR_REPOSITORY
ephemeral-env-up:
@@ -193,11 +223,13 @@ jobs:
- name: Check target image exists in ECR
id: check-image
continue-on-error: true
env:
PR_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
run: |
aws ecr describe-images \
--registry-id $(echo "${{ steps.login-ecr.outputs.registry }}" | grep -Eo "^[0-9]+") \
--repository-name superset-ci \
--image-ids imageTag=pr-${{ github.event.inputs.issue_number || github.event.issue.number }}-ci
--image-ids imageTag=pr-$PR_NUMBER-ci
- name: Fail on missing container image
if: steps.check-image.outcome == 'failure'
@@ -207,7 +239,7 @@ jobs:
script: |
const errMsg = '@${{ github.event.comment.user.login }} Container image not yet published for this PR. Please try again when build is complete.';
github.rest.issues.createComment({
issue_number: ${{ github.event.inputs.issue_number || github.event.issue.number }},
issue_number: ${{ github.event.inputs.issue_number || github.event.pull_request.number }},
owner: context.repo.owner,
repo: context.repo.repo,
body: errMsg
@@ -220,7 +252,7 @@ jobs:
with:
task-definition: .github/workflows/ecs-task-definition.json
container-name: superset-ci
image: ${{ steps.login-ecr.outputs.registry }}/superset-ci:pr-${{ github.event.inputs.issue_number || github.event.issue.number }}-ci
image: ${{ steps.login-ecr.outputs.registry }}/superset-ci:pr-${{ github.event.inputs.issue_number || github.event.pull_request.number }}-ci
- name: Update env vars in the Amazon ECS task definition
run: |
@@ -229,29 +261,30 @@ jobs:
- name: Describe ECS service
id: describe-services
run: |
echo "active=$(aws ecs describe-services --cluster superset-ci --services pr-${{ github.event.inputs.issue_number || github.event.issue.number }}-service | jq '.services[] | select(.status == "ACTIVE") | any')" >> $GITHUB_OUTPUT
echo "active=$(aws ecs describe-services --cluster superset-ci --services pr-${{ github.event.inputs.issue_number || github.event.pull_request.number }}-service | jq '.services[] | select(.status == "ACTIVE") | any')" >> $GITHUB_OUTPUT
- name: Create ECS service
id: create-service
if: steps.describe-services.outputs.active != 'true'
env:
ECR_SUBNETS: subnet-0e15a5034b4121710,subnet-0e8efef4a72224974
ECR_SECURITY_GROUP: sg-092ff3a6ae0574d91
PR_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
run: |
aws ecs create-service \
--cluster superset-ci \
--service-name pr-${{ github.event.inputs.issue_number || github.event.issue.number }}-service \
--service-name pr-$PR_NUMBER-service \
--task-definition superset-ci \
--launch-type FARGATE \
--desired-count 1 \
--platform-version LATEST \
--network-configuration "awsvpcConfiguration={subnets=[$ECR_SUBNETS],securityGroups=[$ECR_SECURITY_GROUP],assignPublicIp=ENABLED}" \
--tags key=pr,value=${{ github.event.inputs.issue_number || github.event.issue.number }} key=github_user,value=${{ github.actor }}
--tags key=pr,value=$PR_NUMBER key=github_user,value=${{ github.actor }}
- name: Deploy Amazon ECS task definition
id: deploy-task
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: pr-${{ github.event.inputs.issue_number || github.event.issue.number }}-service
service: pr-${{ github.event.inputs.issue_number || github.event.pull_request.number }}-service
cluster: superset-ci
wait-for-service-stability: true
wait-for-minutes: 10
@@ -259,7 +292,7 @@ jobs:
- name: List tasks
id: list-tasks
run: |
echo "task=$(aws ecs list-tasks --cluster superset-ci --service-name pr-${{ github.event.inputs.issue_number || github.event.issue.number }}-service | jq '.taskArns | first')" >> $GITHUB_OUTPUT
echo "task=$(aws ecs list-tasks --cluster superset-ci --service-name pr-${{ github.event.inputs.issue_number || github.event.pull_request.number }}-service | jq '.taskArns | first')" >> $GITHUB_OUTPUT
- name: Get network interface
id: get-eni
run: |
@@ -274,20 +307,22 @@ jobs:
with:
github-token: ${{github.token}}
script: |
const issue_number = context.payload.inputs?.issue_number || context.issue.number;
github.rest.issues.createComment({
issue_number: ${{ github.event.inputs.issue_number || github.event.issue.number }},
issue_number: issue_number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '@${{ github.actor }} Ephemeral environment spinning up at http://${{ steps.get-ip.outputs.ip }}:8080. Credentials are `admin`/`admin`. Please allow several minutes for bootstrapping and startup.'
})
body: `@${{ github.actor }} Ephemeral environment spinning up at http://${{ steps.get-ip.outputs.ip }}:8080. Credentials are 'admin'/'admin'. Please allow several minutes for bootstrapping and startup.`
});
- name: Comment (failure)
if: ${{ failure() }}
uses: actions/github-script@v7
with:
github-token: ${{github.token}}
script: |
const issue_number = context.payload.inputs?.issue_number || context.issue.number;
github.rest.issues.createComment({
issue_number: ${{ github.event.inputs.issue_number || github.event.issue.number }},
issue_number: issue_number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '@${{ github.event.inputs.user_login || github.event.comment.user.login }} Ephemeral environment creation failed. Please check the Actions logs for details.'

View File

@@ -24,13 +24,7 @@ jobs:
needs: config
if: needs.config.outputs.has-secrets
name: Bump version and publish package(s)
runs-on: ubuntu-24.04
strategy:
matrix:
node-version: [20]
steps:
- uses: actions/checkout@v4
with:
@@ -46,11 +40,11 @@ jobs:
git fetch --prune --unshallow
git tag -d `git tag | grep -E '^trigger-'`
- name: Use Node.js ${{ matrix.node-version }}
- name: Install Node.js
if: env.HAS_TAGS
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
node-version-file: './superset-frontend/.nvmrc'
- name: Cache npm
if: env.HAS_TAGS

View File

@@ -26,7 +26,6 @@ jobs:
fail-fast: false
matrix:
browser: ["chrome"]
node: [20]
env:
SUPERSET_ENV: development
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
@@ -66,7 +65,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
node-version-file: './superset-frontend/.nvmrc'
- name: Install npm dependencies
uses: ./.github/actions/cached-dependencies
with:

View File

@@ -28,9 +28,6 @@ jobs:
needs: config
if: needs.config.outputs.has-secrets
runs-on: ubuntu-24.04
strategy:
matrix:
node: [20]
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@v4
@@ -41,7 +38,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
node-version-file: './superset-frontend/.nvmrc'
- name: Install eyes-storybook dependencies
uses: ./.github/actions/cached-dependencies
with:

View File

@@ -35,10 +35,10 @@ jobs:
with:
persist-credentials: false
submodules: recursive
- name: Set up Node.js 20
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version-file: './docs/.nvmrc'
- name: Setup Python
uses: ./.github/actions/setup-backend/
- uses: actions/setup-java@v4

View File

@@ -60,10 +60,10 @@ jobs:
with:
persist-credentials: false
submodules: recursive
- name: Set up Node.js 20
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version-file: './docs/.nvmrc'
- name: yarn install
run: |
yarn install --check-cache

View File

@@ -109,7 +109,7 @@ jobs:
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: actions/setup-node@v4
with:
node-version: "20"
node-version-file: './superset-frontend/.nvmrc'
- name: Install npm dependencies
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies

View File

@@ -33,7 +33,7 @@ jobs:
if: steps.check.outputs.frontend
uses: actions/setup-node@v4
with:
node-version: '18'
node-version-file: './superset-frontend/.nvmrc'
- name: Install dependencies
if: steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies

View File

@@ -32,7 +32,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version-file: './superset-frontend/.nvmrc'
- name: Install Dependencies
run: npm install

1
.gitignore vendored
View File

@@ -21,6 +21,7 @@
*.swp
__pycache__
.aider*
.local
.cache
.bento*

View File

@@ -18,16 +18,19 @@
######################################################################
# Node stage to deal with static asset construction
######################################################################
ARG PY_VER=3.11-slim-bookworm
ARG PY_VER=3.11.11-slim-bookworm
# If BUILDPLATFORM is null, set it to 'amd64' (or leave as is otherwise).
ARG BUILDPLATFORM=${BUILDPLATFORM:-amd64}
# Include translations in the final build
ARG BUILD_TRANSLATIONS="false"
######################################################################
# superset-node-ci used as a base for building frontend assets and CI
######################################################################
FROM --platform=${BUILDPLATFORM} node:20-bullseye-slim AS superset-node-ci
ARG BUILD_TRANSLATIONS="false" # Include translations in the final build
ARG BUILD_TRANSLATIONS
ENV BUILD_TRANSLATIONS=${BUILD_TRANSLATIONS}
ARG DEV_MODE="false" # Skip frontend build in dev mode
ENV DEV_MODE=${DEV_MODE}
@@ -122,10 +125,13 @@ ENV PATH="/app/.venv/bin:${PATH}"
######################################################################
FROM python-base AS python-translation-compiler
ARG BUILD_TRANSLATIONS
ENV BUILD_TRANSLATIONS=${BUILD_TRANSLATIONS}
# Install Python dependencies using docker/pip-install.sh
COPY requirements/translations.txt requirements/
RUN --mount=type=cache,target=/root/.cache/uv \
/app/docker/pip-install.sh --requires-build-essential -r requirements/translations.txt
. /app/.venv/bin/activate && /app/docker/pip-install.sh --requires-build-essential -r requirements/translations.txt
COPY superset/translations/ /app/translations_mo/
RUN if [ "$BUILD_TRANSLATIONS" = "true" ]; then \

View File

@@ -137,6 +137,7 @@ Here are some of the major database solutions that are supported:
<img src="https://superset.apache.org/img/databases/sap-hana.png" alt="oceanbase" border="0" width="220" />
<img src="https://superset.apache.org/img/databases/denodo.png" alt="denodo" border="0" width="200" />
<img src="https://superset.apache.org/img/databases/ydb.svg" alt="ydb" border="0" width="200" />
<img src="https://superset.apache.org/img/databases/tdengine.png" alt="TDengine" border="0" width="200" />
</p>
**A more comprehensive list of supported databases** along with the configuration instructions can be found [here](https://superset.apache.org/docs/configuration/databases).

View File

@@ -30,12 +30,12 @@ RUN apt-get install -y apt-transport-https apt-utils
# Install superset dependencies
# https://superset.apache.org/docs/installation/installing-superset-from-scratch
RUN apt-get install -y build-essential libssl-dev \
libffi-dev python3-dev libsasl2-dev libldap2-dev libxi-dev chromium
libffi-dev python3-dev libsasl2-dev libldap2-dev libxi-dev chromium zstd
# Install nodejs for custom build
# https://nodejs.org/en/download/package-manager/
RUN set -eux; \
curl -sL https://deb.nodesource.com/setup_18.x | bash -; \
curl -sL https://deb.nodesource.com/setup_20.x | bash -; \
apt-get install -y nodejs; \
node --version;
RUN if ! which npm; then apt-get install -y npm; fi
@@ -64,7 +64,7 @@ RUN pip install --upgrade setuptools pip \
RUN flask fab babel-compile --target superset/translations
ENV PATH=/home/superset/superset/bin:$PATH \
PYTHONPATH=/home/superset/superset/:$PYTHONPATH \
PYTHONPATH=/home/superset/superset/ \
SUPERSET_TESTENV=true
COPY from_tarball_entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -29,13 +29,16 @@ RUN apt-get install -y apt-transport-https apt-utils
# Install superset dependencies
# https://superset.apache.org/docs/installation/installing-superset-from-scratch
RUN apt-get install -y build-essential libssl-dev \
libffi-dev python3-dev libsasl2-dev libldap2-dev libxi-dev chromium
RUN apt-get install -y subversion build-essential libssl-dev \
libffi-dev python3-dev libsasl2-dev libldap2-dev libxi-dev chromium zstd
# Install nodejs for custom build
# https://nodejs.org/en/download/package-manager/
RUN curl -sL https://deb.nodesource.com/setup_16.x | bash - \
&& apt-get install -y nodejs
RUN set -eux; \
curl -sL https://deb.nodesource.com/setup_20.x | bash -; \
apt-get install -y nodejs; \
node --version;
RUN if ! which npm; then apt-get install -y npm; fi
RUN mkdir -p /home/superset
RUN chown superset /home/superset
@@ -46,14 +49,12 @@ ARG VERSION
# Can fetch source from svn or copy tarball from local mounted directory
RUN svn co https://dist.apache.org/repos/dist/dev/superset/$VERSION ./
RUN tar -xvf *.tar.gz
WORKDIR apache-superset-$VERSION
WORKDIR /home/superset/apache-superset-$VERSION/superset-frontend
RUN cd superset-frontend \
&& npm ci \
RUN npm ci \
&& npm run build \
&& rm -rf node_modules
WORKDIR /home/superset/apache-superset-$VERSION
RUN pip install --upgrade setuptools pip \
&& pip install -r requirements/base.txt \
@@ -62,6 +63,6 @@ RUN pip install --upgrade setuptools pip \
RUN flask fab babel-compile --target superset/translations
ENV PATH=/home/superset/superset/bin:$PATH \
PYTHONPATH=/home/superset/superset/:$PYTHONPATH
PYTHONPATH=/home/superset/superset/
COPY from_tarball_entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -43,8 +43,8 @@ under the License.
| can this form post on ResetPasswordView |:heavy_check_mark:|O|O|O|
| can this form get on ResetMyPasswordView |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| can this form post on ResetMyPasswordView |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| can this form get on UserInfoEditView |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| can this form post on UserInfoEditView |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| can this form get on UserInfoEditView |:heavy_check_mark:|O|O|O|
| can this form post on UserInfoEditView |:heavy_check_mark:|O|O|O|
| can show on UserDBModelView |:heavy_check_mark:|O|O|O|
| can edit on UserDBModelView |:heavy_check_mark:|O|O|O|
| can delete on UserDBModelView |:heavy_check_mark:|O|O|O|
@@ -65,7 +65,6 @@ under the License.
| can get on MenuApi |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| can list on AsyncEventsRestApi |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| can invalidate on CacheRestApi |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| can function names on Database |:heavy_check_mark:|O|O|O|
| can csv upload on Database |:heavy_check_mark:|O|O|O|
| can excel upload on Database |:heavy_check_mark:|O|O|O|
| can query form data on Api |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
@@ -76,7 +75,6 @@ under the License.
| can get on Datasource |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| can my queries on SqlLab |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|
| can log on Superset |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| can schemas access for csv upload on Superset |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| can import dashboards on Superset |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| can schemas on Superset |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| can sqllab history on Superset |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|
@@ -118,8 +116,6 @@ under the License.
| menu access on Data |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| menu access on Databases |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| menu access on Datasets |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| menu access on Upload a CSV |:heavy_check_mark:|:heavy_check_mark:|O|O|
| menu access on Upload Excel |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| menu access on Charts |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| menu access on Dashboards |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| menu access on SQL Lab |:heavy_check_mark:|O|O|:heavy_check_mark:|
@@ -129,13 +125,6 @@ under the License.
| all datasource access on all_datasource_access |:heavy_check_mark:|:heavy_check_mark:|O|O|
| all database access on all_database_access |:heavy_check_mark:|:heavy_check_mark:|O|O|
| all query access on all_query_access |:heavy_check_mark:|O|O|O|
| can edit on UserOAuthModelView |:heavy_check_mark:|O|O|O|
| can list on UserOAuthModelView |:heavy_check_mark:|O|O|O|
| can show on UserOAuthModelView |:heavy_check_mark:|O|O|O|
| can userinfo on UserOAuthModelView |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| can add on UserOAuthModelView |:heavy_check_mark:|O|O|O|
| can delete on UserOAuthModelView |:heavy_check_mark:|O|O|O|
| userinfoedit on UserOAuthModelView |:heavy_check_mark:|O|O|O|
| can write on DynamicPlugin |:heavy_check_mark:|O|O|O|
| can edit on DynamicPlugin |:heavy_check_mark:|O|O|O|
| can list on DynamicPlugin |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
@@ -192,7 +181,6 @@ under the License.
| can share chart on Superset |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| can this form get on ColumnarToDatabaseView |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| can this form post on ColumnarToDatabaseView |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| menu access on Upload a Columnar file |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| can export on Chart |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| can write on DashboardFilterStateRestApi |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
| can read on DashboardFilterStateRestApi |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|

View File

@@ -24,8 +24,9 @@ assists people when migrating to a new version.
## Next
- [31976](https://github.com/apache/superset/pull/31976) Removed the `DISABLE_LEGACY_DATASOURCE_EDITOR` feature flag. The previous value of the feature flag was `True` and now the feature is permanently removed.
- [31959](https://github.com/apache/superset/pull/32000) Removes CSV_UPLOAD_MAX_SIZE config, use your web server to control file upload size.
- [31959](https://github.com/apache/superset/pull/31959) Removes the following endpoints from data uploads: /api/v1/database/<id>/<file type>_upload and /api/v1/database/<file type>_metadata, in favour of new one (Details on the PR). And simplifies permissions.
- [31959](https://github.com/apache/superset/pull/31959) Removes the following endpoints from data uploads: `/api/v1/database/<id>/<file type>_upload` and `/api/v1/database/<file type>_metadata`, in favour of new one (Details on the PR). And simplifies permissions.
- [31844](https://github.com/apache/superset/pull/31844) The `ALERT_REPORTS_EXECUTE_AS` and `THUMBNAILS_EXECUTE_AS` config parameters have been renamed to `ALERT_REPORTS_EXECUTORS` and `THUMBNAILS_EXECUTORS` respectively. A new config flag `CACHE_WARMUP_EXECUTORS` has also been introduced to be able to control which user is used to execute cache warmup tasks. Finally, the config flag `THUMBNAILS_SELENIUM_USER` has been removed. To use a fixed executor for async tasks, use the new `FixedExecutor` class. See the config and docs for more info on setting up different executor profiles.
- [31894](https://github.com/apache/superset/pull/31894) Domain sharding is deprecated in favor of HTTP2. The `SUPERSET_WEBSERVER_DOMAINS` configuration will be removed in the next major version (6.0)
- [31794](https://github.com/apache/superset/pull/31794) Removed the previously deprecated `DASHBOARD_CROSS_FILTERS` feature flag
@@ -45,7 +46,7 @@ assists people when migrating to a new version.
- [25166](https://github.com/apache/superset/pull/25166) Changed the default configuration of `UPLOAD_FOLDER` from `/app/static/uploads/` to `/static/uploads/`. It also removed the unused `IMG_UPLOAD_FOLDER` and `IMG_UPLOAD_URL` configuration options.
- [30284](https://github.com/apache/superset/pull/30284) Deprecated GLOBAL_ASYNC_QUERIES_REDIS_CONFIG in favor of the new GLOBAL_ASYNC_QUERIES_CACHE_BACKEND configuration. To leverage Redis Sentinel, set CACHE_TYPE to RedisSentinelCache, or use RedisCache for standalone Redis
- [31961](https://github.com/apache/superset/pull/31961) Upgraded React from version 16.13.1 to 17.0.2. If you are using custom frontend extensions or plugins, you may need to update them to be compatible with React 17.
- [31260](https://github.com/apache/superset/pull/31260) Docker images now use `uv pip install` instead of `pip install` to manage the python envrionment. Most docker-based deployments will be affected, whether you derive one of the published images, or have custom bootstrap script that install python libraries (drivers)
### Potential Downtime

View File

@@ -69,6 +69,7 @@ are compatible with Superset.
| [MySQL](/docs/configuration/databases#mysql) | `pip install mysqlclient` | `mysql://<UserName>:<DBPassword>@<Database Host>/<Database Name>` |
| [OceanBase](/docs/configuration/databases#oceanbase) | `pip install oceanbase_py` | `oceanbase://<UserName>:<DBPassword>@<Database Host>/<Database Name>` |
| [Oracle](/docs/configuration/databases#oracle) | `pip install cx_Oracle` | `oracle://` |
| [Parseable](/docs/configuration/databases#parseable) | `pip install sqlalchemy-parseable` | `parseable://<UserName>:<DBPassword>@<Database Host>/<Stream Name>` |
| [PostgreSQL](/docs/configuration/databases#postgres) | `pip install psycopg2` | `postgresql://<UserName>:<DBPassword>@<Database Host>/<Database Name>` |
| [Presto](/docs/configuration/databases#presto) | `pip install pyhive` | `presto://` |
| [Rockset](/docs/configuration/databases#rockset) | `pip install rockset-sqlalchemy` | `rockset://<api_key>:@<api_server>` |
@@ -77,6 +78,7 @@ are compatible with Superset.
| [Snowflake](/docs/configuration/databases#snowflake) | `pip install snowflake-sqlalchemy` | `snowflake://{user}:{password}@{account}.{region}/{database}?role={role}&warehouse={warehouse}` |
| SQLite | No additional library needed | `sqlite://path/to/file.db?check_same_thread=false` |
| [SQL Server](/docs/configuration/databases#sql-server) | `pip install pymssql` | `mssql+pymssql://` |
| [TDengine](/docs/configuration/databases#tdengine) | `pip install taospy` `pip install taos-ws-py` | `taosws://<user>:<password>@<host>:<port>` |
| [Teradata](/docs/configuration/databases#teradata) | `pip install teradatasqlalchemy` | `teradatasql://{user}:{password}@{host}` |
| [TimescaleDB](/docs/configuration/databases#timescaledb) | `pip install psycopg2` | `postgresql://<UserName>:<DBPassword>@<Database Host>:<Port>/<Database Name>` |
| [Trino](/docs/configuration/databases#trino) | `pip install trino` | `trino://{username}:{password}@{hostname}:{port}/{catalog}` |
@@ -1074,6 +1076,23 @@ The connection string is formatted as follows:
oracle://<username>:<password>@<hostname>:<port>
```
#### Parseable
[Parseable](https://www.parseable.io) is a distributed log analytics database that provides SQL-like query interface for log data. The recommended connector library is [sqlalchemy-parseable](https://github.com/parseablehq/sqlalchemy-parseable).
The connection string is formatted as follows:
```
parseable://<username>:<password>@<hostname>:<port>/<stream_name>
```
For example:
```
parseable://admin:admin@demo.parseable.com:443/ingress-nginx
```
Note: The stream_name in the URI represents the Parseable logstream you want to query. You can use both HTTP (port 80) and HTTPS (port 443) connections.
#### Apache Pinot
@@ -1336,6 +1355,24 @@ starrocks://<User>:<Password>@<Host>:<Port>/<Catalog>.<Database>
StarRocks maintains their Superset docuementation [here](https://docs.starrocks.io/docs/integrations/BI_integrations/Superset/).
:::
#### TDengine
[TDengine](https://www.tdengine.com) is a High-Performance, Scalable Time-Series Database for Industrial IoT and provides SQL-like query interface.
The recommended connector library for TDengine is [taospy](https://pypi.org/project/taospy/) and [taos-ws-py](https://pypi.org/project/taos-ws-py/)
The expected connection string is formatted as follows:
```
taosws://<user>:<password>@<host>:<port>
```
For example:
```
taosws://root:taosdata@127.0.0.1:6041
```
#### Teradata
The recommended connector library is

View File

@@ -45,7 +45,7 @@ This is the core application. Superset operates like this:
This is where chart and dashboard definitions, user information, logs, etc. are stored. Superset is tested to work with PostgreSQL and MySQL databases as the metadata database (not be confused with a data source like your data warehouse, which could be a much greater variety of options like Snowflake, Redshift, etc.).
Some installation methods like our Quickstart and PyPI come configured by default to use a SQLite on-disk database. And in a Docker Compose installation, the data would be stored in a PostgresQL container volume. Neither of these cases are recommended for production instances of Superset.
Some installation methods like our Quickstart and PyPI come configured by default to use a SQLite on-disk database. And in a Docker Compose installation, the data would be stored in a PostgreSQL container volume. Neither of these cases are recommended for production instances of Superset.
For production, a properly-configured, managed, standalone database is recommended. No matter what database you use, you should plan to back it up regularly.

View File

@@ -17,7 +17,7 @@ Since `docker compose` is primarily designed to run a set of containers on **a s
and can't support requirements for **high availability**, we do not support nor recommend
using our `docker compose` constructs to support production-type use-cases. For single host
environments, we recommend using [minikube](https://minikube.sigs.k8s.io/docs/start/) along
our [installing on k8s](https://superset.apache.org/docs/installation/running-on-kubernetes)
with our [installing on k8s](https://superset.apache.org/docs/installation/running-on-kubernetes)
documentation.
:::
@@ -43,7 +43,6 @@ Note that there are 3 major ways we support to run `docker compose`:
`export TAG=4.0.0-dev` or `export TAG=3.0.0-dev`, with `latest-dev` being the default.
That's because The `dev` builds happen to package the `psycopg2-binary` required to connect
to the Postgres database launched as part of the `docker compose` builds.
``
More on these two approaches after setting up the requirements for either.

View File

@@ -150,6 +150,9 @@ Superset requires a Python DB-API database driver and a SQLAlchemy
dialect to be installed for each datastore you want to connect to.
See [Install Database Drivers](/docs/configuration/databases) for more information.
It is recommended that you refer to versions listed in
[pyproject.toml](https://github.com/apache/superset/blob/master/pyproject.toml)
instead of hard-coding them in your bootstrap script, as seen below.
:::
@@ -157,9 +160,9 @@ The following example installs the drivers for BigQuery and Elasticsearch, allow
```yaml
bootstrapScript: |
#!/bin/bash
pip install psycopg2==2.9.6 \
sqlalchemy-bigquery==1.6.1 \
elasticsearch-dbapi==0.2.5 &&\
uv pip install .[postgres] \
.[bigquery] \
.[elasticsearch] &&\
if [ ! -f ~/bootstrap ]; then echo "Running Superset with uid {{ .Values.runAsUser }}" > ~/bootstrap; fi
```

View File

@@ -34,7 +34,7 @@
"clsx": "^2.1.1",
"docusaurus-plugin-less": "^2.0.2",
"file-loader": "^6.2.0",
"less": "^4.2.1",
"less": "^4.2.2",
"less-loader": "^11.0.0",
"prism-react-renderer": "^2.4.1",
"react": "^18.3.1",

View File

@@ -137,4 +137,9 @@ export const Databases = [
href: 'https://www.denodo.com/',
imgName: 'denodo.png',
},
{
title: 'TDengine',
href: 'https://www.tdengine.com/',
imgName: 'tdengine.png',
},
];

BIN
docs/static/img/databases/tdengine.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -6359,10 +6359,10 @@ less-loader@^11.0.0:
resolved "https://registry.yarnpkg.com/less-loader/-/less-loader-11.1.4.tgz#e8a070844efaefbe59b978acaf57b9d3e868cf08"
integrity sha512-6/GrYaB6QcW6Vj+/9ZPgKKs6G10YZai/l/eJ4SLwbzqNTBsAqt5hSLVF47TgsiBxV1P6eAU0GYRH3YRuQU9V3A==
less@^4.2.1:
version "4.2.1"
resolved "https://registry.yarnpkg.com/less/-/less-4.2.1.tgz#fe4c9848525ab44614c0cf2c00abd8d031bb619a"
integrity sha512-CasaJidTIhWmjcqv0Uj5vccMI7pJgfD9lMkKtlnTHAdJdYK/7l8pM9tumLyJ0zhbD4KJLo/YvTj+xznQd5NBhg==
less@^4.2.2:
version "4.2.2"
resolved "https://registry.yarnpkg.com/less/-/less-4.2.2.tgz#4b59ede113933b58ab152190edf9180fc36846d8"
integrity sha512-tkuLHQlvWUTeQ3doAqnHbNn8T6WX1KA8yvbKG9x4VtKtIjHsVKQZCH11zRgAfbDAXC2UNIg/K9BYAAcEzUIrNg==
dependencies:
copy-anything "^2.0.1"
parse-node-version "^1.0.1"

View File

@@ -87,7 +87,6 @@ dependencies = [
"redis>=4.6.0, <5.0",
"selenium>=4.14.0, <5.0",
"shillelagh[gsheetsapi]>=1.2.18, <2.0",
"shortid",
"sshtunnel>=0.4.0, <0.5",
"simplejson>=3.15.0",
"slack_sdk>=3.19.0, <4",
@@ -156,6 +155,7 @@ ocient = [
"geojson",
]
oracle = ["cx-Oracle>8.0.0, <8.1"]
parseable = ["sqlalchemy-parseable>=0.1.3,<0.2.0"]
pinot = ["pinotdb>=5.0.0, <6.0.0"]
playwright = ["playwright>=1.37.0, <2"]
postgres = ["psycopg2-binary==2.9.6"]
@@ -172,6 +172,10 @@ spark = [
"tableschema",
"thrift>=0.14.1, <1",
]
tdengine = [
"taospy>=2.7.21",
"taos-ws-py>=0.3.8"
]
teradata = ["teradatasql>=16.20.0.23"]
thumbnails = ["Pillow>=10.0.1, <11"]
vertica = ["sqlalchemy-vertica-python>=0.5.9, < 0.6"]

View File

@@ -329,8 +329,6 @@ selenium==4.27.1
# via apache-superset (pyproject.toml)
shillelagh==1.2.18
# via apache-superset (pyproject.toml)
shortid==0.1.2
# via apache-superset (pyproject.toml)
simplejson==3.19.3
# via apache-superset (pyproject.toml)
six==1.16.0

View File

@@ -738,10 +738,6 @@ shillelagh==1.2.18
# via
# -c requirements/base.txt
# apache-superset
shortid==0.1.2
# via
# -c requirements/base.txt
# apache-superset
simplejson==3.19.3
# via
# -c requirements/base.txt

View File

@@ -0,0 +1 @@
v20.16.0

View File

@@ -74,7 +74,7 @@ module.exports = {
'file-progress',
'lodash',
'theme-colors',
'translation-vars',
'i18n-strings',
'react-prefer-function-component',
'prettier',
],
@@ -284,7 +284,7 @@ module.exports = {
],
rules: {
'theme-colors/no-literal-colors': 0,
'translation-vars/no-template-vars': 0,
'i18n-strings/no-template-vars': 0,
'no-restricted-imports': 0,
'react/no-void-elements': 0,
},
@@ -292,7 +292,7 @@ module.exports = {
],
rules: {
'theme-colors/no-literal-colors': 'error',
'translation-vars/no-template-vars': ['error', true],
'i18n-strings/no-template-vars': ['error', true],
camelcase: [
'error',
{
@@ -354,6 +354,14 @@ module.exports = {
name: 'lodash/memoize',
message: 'Lodash Memoize is unsafe! Please use memoize-one instead',
},
{
name: '@testing-library/react',
message: 'Please use spec/helpers/testing-library instead',
},
{
name: '@testing-library/react-dom-utils',
message: 'Please use spec/helpers/testing-library instead',
},
],
patterns: ['antd/*'],
},

View File

@@ -47,12 +47,12 @@ describe.skip('Dashboard top-level controls', () => {
// Solution: pause the network before clicking, assert, then unpause network.
cy.get('[data-test="refresh-chart-menu-item"]').should(
'have.class',
'ant-dropdown-menu-item-disabled',
'antd5-dropdown-menu-item-disabled',
);
waitForChartLoad(mapSpec);
cy.get('[data-test="refresh-chart-menu-item"]').should(
'not.have.class',
'ant-dropdown-menu-item-disabled',
'antd5-dropdown-menu-item-disabled',
);
});
});
@@ -65,7 +65,7 @@ describe.skip('Dashboard top-level controls', () => {
cy.get('[aria-label="more-horiz"]').click();
cy.get('[data-test="refresh-dashboard-menu-item"]').should(
'not.have.class',
'ant-dropdown-menu-item-disabled',
'antd5-dropdown-menu-item-disabled',
);
cy.get('[data-test="refresh-dashboard-menu-item"]').click({
@@ -73,7 +73,7 @@ describe.skip('Dashboard top-level controls', () => {
});
cy.get('[data-test="refresh-dashboard-menu-item"]').should(
'have.class',
'ant-dropdown-menu-item-disabled',
'antd5-dropdown-menu-item-disabled',
);
// wait all charts force refreshed.
@@ -94,7 +94,7 @@ describe.skip('Dashboard top-level controls', () => {
cy.get('[aria-label="more-horiz"]').click();
cy.get('[data-test="refresh-dashboard-menu-item"]').and(
'not.have.class',
'ant-dropdown-menu-item-disabled',
'antd5-dropdown-menu-item-disabled',
);
});
});

View File

@@ -54,15 +54,14 @@ const drillBy = (targetDrillByColumn: string, isLegacy = false) => {
interceptV1ChartData();
}
cy.get('.ant-dropdown:not(.ant-dropdown-hidden)')
.first()
cy.get('.antd5-dropdown:not(.antd5-dropdown-hidden)')
.should('be.visible')
.find("[role='menu'] [role='menuitem']")
.contains(/^Drill by$/)
.trigger('mouseover', { force: true });
cy.get(
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
'.antd5-dropdown-menu-submenu:not(.antd5-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
)
.should('be.visible')
.find('[role="menuitem"]')

View File

@@ -61,15 +61,14 @@ function drillToDetail(targetMenuItem: string) {
const drillToDetailBy = (targetDrill: string) => {
interceptSamples();
cy.get('.ant-dropdown:not(.ant-dropdown-hidden)')
.first()
cy.get('.antd5-dropdown:not(.antd5-dropdown-hidden)')
.should('be.visible')
.find("[role='menu'] [role='menuitem']")
.contains(/^Drill to detail by$/)
.trigger('mouseover', { force: true });
cy.get(
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-to-detail-by-submenu"]',
'.antd5-dropdown-menu-submenu:not(.antd5-dropdown-menu-submenu-hidden) [data-test="drill-to-detail-by-submenu"]',
)
.should('be.visible')
.find('[role="menuitem"]')

View File

@@ -57,16 +57,16 @@ function setFilterBarOrientation(orientation: 'vertical' | 'horizontal') {
.trigger('mouseover');
if (orientation === 'vertical') {
cy.get('.antd5-menu-item-selected')
cy.get('.antd5-dropdown-menu-item-selected')
.contains('Horizontal (Top)')
.should('exist');
cy.get('.antd5-menu-item').contains('Vertical (Left)').click();
cy.get('.antd5-dropdown-menu-item').contains('Vertical (Left)').click();
cy.getBySel('dashboard-filters-panel').should('exist');
} else {
cy.get('.antd5-menu-item-selected')
cy.get('.antd5-dropdown-menu-item-selected')
.contains('Vertical (Left)')
.should('exist');
cy.get('.antd5-menu-item').contains('Horizontal (Top)').click();
cy.get('.antd5-dropdown-menu-item').contains('Horizontal (Top)').click();
cy.getBySel('loading-indicator').should('exist');
cy.getBySel('filter-bar').should('exist');
cy.getBySel('dashboard-filters-panel').should('not.exist');
@@ -161,7 +161,7 @@ describe('Horizontal FilterBar', () => {
cy.getBySel('filter-control-name')
.contains('test_12')
.should('not.be.visible');
cy.get('.ant-popover-inner-content').scrollTo('bottom');
cy.get('.antd5-popover-inner').scrollTo('bottom');
cy.getBySel('filter-control-name').contains('test_12').should('be.visible');
});
@@ -226,7 +226,7 @@ describe('Horizontal FilterBar', () => {
cy.getBySel('slice-header').within(() => {
cy.get('.filter-counts').trigger('mouseover');
});
cy.get('.filterStatusPopover').contains('test_9').click();
cy.getBySel('filter-status-popover').contains('test_9').click();
cy.getBySel('dropdown-content').should('be.visible');
cy.get('.ant-select-focused').should('be.visible');
});

View File

@@ -456,19 +456,19 @@ export function applyAdvancedTimeRangeFilterOnDashboard(
endRange?: string,
) {
cy.get('.control-label').contains('RANGE TYPE').should('be.visible');
cy.get('.ant-popover-content .ant-select-selector')
cy.get('.antd5-popover-content .ant-select-selector')
.should('be.visible')
.click();
cy.get(`[label="Advanced"]`).should('be.visible').click();
cy.get('.section-title').contains('Advanced Time Range').should('be.visible');
if (startRange) {
cy.get('.ant-popover-inner-content')
cy.get('.antd5-popover-inner-content')
.find('[class^=ant-input]')
.first()
.type(`${startRange}`);
}
if (endRange) {
cy.get('.ant-popover-inner-content')
cy.get('.antd5-popover-inner-content')
.find('[class^=ant-input]')
.last()
.type(`${endRange}`);

View File

@@ -31,35 +31,35 @@ const SAMPLE_DASHBOARDS_INDEXES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
function openDashboardsAddedTo() {
cy.getBySel('actions-trigger').click();
cy.get('.ant-dropdown-menu-submenu-title')
cy.get('.antd5-dropdown-menu-submenu-title')
.contains('On dashboards')
.trigger('mouseover', { force: true });
}
function closeDashboardsAddedTo() {
cy.get('.ant-dropdown-menu-submenu-title')
cy.get('.antd5-dropdown-menu-submenu-title')
.contains('On dashboards')
.trigger('mouseout', { force: true });
cy.getBySel('actions-trigger').click();
}
function verifyDashboardsSubmenuItem(dashboardName) {
cy.get('.ant-dropdown-menu-submenu-popup').contains(dashboardName);
cy.get('.antd5-dropdown-menu-submenu-popup').contains(dashboardName);
closeDashboardsAddedTo();
}
function verifyDashboardSearch() {
openDashboardsAddedTo();
cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover');
cy.get('.ant-dropdown-menu-submenu-popup')
cy.get('.antd5-dropdown-menu-submenu-popup').trigger('mouseover');
cy.get('.antd5-dropdown-menu-submenu-popup')
.find('input[placeholder="Search"]')
.type('1');
cy.get('.ant-dropdown-menu-submenu-popup').contains('1 - Sample dashboard');
cy.get('.ant-dropdown-menu-submenu-popup')
cy.get('.antd5-dropdown-menu-submenu-popup').contains('1 - Sample dashboard');
cy.get('.antd5-dropdown-menu-submenu-popup')
.find('input[placeholder="Search"]')
.type('Blahblah');
cy.get('.ant-dropdown-menu-submenu-popup').contains('No results found');
cy.get('.ant-dropdown-menu-submenu-popup')
cy.get('.antd5-dropdown-menu-submenu-popup').contains('No results found');
cy.get('.antd5-dropdown-menu-submenu-popup')
.find('[aria-label="close-circle"]')
.click();
closeDashboardsAddedTo();
@@ -68,8 +68,8 @@ function verifyDashboardSearch() {
function verifyDashboardLink() {
interceptDashboardGet();
openDashboardsAddedTo();
cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover');
cy.get('.ant-dropdown-menu-submenu-popup a')
cy.get('.antd5-dropdown-menu-submenu-popup').trigger('mouseover');
cy.get('.antd5-dropdown-menu-submenu-popup a')
.first()
.invoke('removeAttr', 'target')
.click();

View File

@@ -51,8 +51,8 @@ describe('Datasource control', () => {
)
.first()
.focus();
cy.focused().clear();
cy.focused().type(`${newMetricName}{enter}`);
cy.focused().clear({ force: true });
cy.focused().type(`${newMetricName}{enter}`, { force: true });
cy.get('[data-test="datasource-modal-save"]').click();
cy.get('.antd5-modal-confirm-btns button').contains('OK').click();

View File

@@ -36,10 +36,10 @@ describe('Download Chart > Bar chart', () => {
};
cy.visitChartByParams(formData);
cy.get('.header-with-actions .ant-dropdown-trigger').click();
cy.get(':nth-child(3) > .ant-dropdown-menu-submenu-title').click();
cy.get('.header-with-actions .antd5-dropdown-trigger').click();
cy.get(':nth-child(3) > .antd5-dropdown-menu-submenu-title').click();
cy.get(
'.ant-dropdown-menu-submenu > .ant-dropdown-menu li:nth-child(3)',
'.antd5-dropdown-menu-submenu > .antd5-dropdown-menu li:nth-child(3)',
).click();
cy.verifyDownload('.jpg', {
contains: true,

View File

@@ -80,9 +80,9 @@ describe('SqlLab query tabs', () => {
// configure some editor settings
cy.get(editorInput).type('some random query string', { force: true });
cy.get(queryLimitSelector).parent().click({ force: true });
cy.get('.ant-dropdown-menu')
cy.get('.antd5-dropdown-menu')
.last()
.find('.ant-dropdown-menu-item')
.find('.antd5-dropdown-menu-item')
.first()
.click({ force: true });

View File

@@ -158,10 +158,10 @@ export const sqlLabView = {
runButton: '.css-d3dxop',
},
rowsLimit: {
dropdown: '.ant-dropdown-menu',
limitButton: '.ant-dropdown-menu-item',
dropdown: '.antd5-dropdown-menu',
limitButton: '.antd5-dropdown-menu-item',
limitButtonText: '.css-151uxnz',
limitTextWithValue: '[class="ant-dropdown-trigger"]',
limitTextWithValue: '[class="antd5-dropdown-trigger"]',
},
renderedTableHeader: '.ReactVirtualized__Table__headerRow',
renderedTableRow: '.ReactVirtualized__Table__row',
@@ -555,7 +555,7 @@ export const exploreView = {
timeSection: {
timeRangeFilter: dataTestLocator('time-range-trigger'),
timeRangeFilterModal: {
container: '.ant-popover-content',
container: '.antd5-popover-content',
footer: '.footer',
cancelButton: dataTestLocator('cancel-button'),
configureLastTimeRange: {
@@ -633,7 +633,7 @@ export const dashboardView = {
refreshChart: dataTestLocator('refresh-chart-menu-item'),
},
threeDotsMenuIcon:
'.header-with-actions .right-button-panel .ant-dropdown-trigger',
'.header-with-actions .right-button-panel .antd5-dropdown-trigger',
threeDotsMenuDropdown: dataTestLocator('header-actions-menu'),
refreshDashboard: dataTestLocator('refresh-dashboard-menu-item'),
saveAsMenuOption: dataTestLocator('save-as-menu-item'),

View File

@@ -1,5 +1,5 @@
{
"name": "eslint-plugin-translation-vars",
"name": "eslint-plugin-i18n-strings",
"version": "1.0.0",
"description": "Warns about translation variables",
"main": "index.js",

View File

@@ -75,4 +75,5 @@ module.exports = {
},
],
],
testTimeout: 10000,
};

File diff suppressed because it is too large Load Diff

View File

@@ -68,8 +68,8 @@
"prod": "npm run build",
"prune": "rm -rf ./{packages,plugins}/*/{node_modules,lib,esm,tsconfig.tsbuildinfo,package-lock.json} ./.temp_cache",
"storybook": "cross-env NODE_ENV=development BABEL_ENV=development storybook dev -p 6006",
"tdd": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --watch",
"test": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --max-workers=50%",
"tdd": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --watch",
"test": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --max-workers=80% --silent",
"type": "tsc --noEmit",
"update-maps": "jupyter nbconvert --to notebook --execute --inplace 'plugins/legacy-plugin-chart-country-map/scripts/Country Map GeoJSON Generator.ipynb' -Xfrozen_modules=off",
"validate-release": "../RELEASING/validate_this_release.sh"
@@ -139,6 +139,7 @@
"dom-to-pdf": "^0.3.2",
"echarts": "^5.6.0",
"emotion-rgba": "0.0.12",
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
"fast-glob": "^3.3.2",
"fs-extra": "^11.2.0",
"fuse.js": "^7.0.0",
@@ -253,7 +254,7 @@
"@storybook/react-webpack5": "8.1.11",
"@svgr/webpack": "^8.1.0",
"@testing-library/dom": "^8.20.1",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^12.8.3",
@@ -301,6 +302,7 @@
"css-loader": "^7.1.2",
"css-minimizer-webpack-plugin": "^7.0.0",
"enzyme": "^3.11.0",
"enzyme-matchers": "^7.1.2",
"esbuild": "^0.20.0",
"esbuild-loader": "^4.2.2",
"eslint": "^8.56.0",
@@ -321,8 +323,7 @@
"eslint-plugin-react-prefer-function-component": "^3.3.0",
"eslint-plugin-storybook": "^0.8.0",
"eslint-plugin-testing-library": "^6.4.0",
"eslint-plugin-theme-colors": "file:tools/eslint-plugin-theme-colors",
"eslint-plugin-translation-vars": "file:tools/eslint-plugin-translation-vars",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"exports-loader": "^5.0.0",
"fetch-mock": "^7.7.3",
"fork-ts-checker-webpack-plugin": "^9.0.2",
@@ -331,9 +332,7 @@
"ignore-styles": "^5.0.1",
"imports-loader": "^5.0.0",
"jest": "^29.7.0",
"jest-environment-enzyme": "^7.1.2",
"jest-environment-jsdom": "^29.7.0",
"jest-enzyme": "^7.1.2",
"jest-html-reporter": "^3.10.2",
"jest-websocket-mock": "^2.5.0",
"jsdom": "^26.0.0",

View File

@@ -17,9 +17,9 @@
* under the License.
*/
import { useEffect, useState } from 'react';
import { Popover } from 'antd';
import { Popover } from 'antd-v5';
import type ReactAce from 'react-ace';
import type { PopoverProps } from 'antd/lib/popover';
import type { PopoverProps } from 'antd-v5/lib/popover';
import { CalculatorOutlined } from '@ant-design/icons';
import { css, styled, useTheme, t } from '@superset-ui/core';
@@ -72,7 +72,7 @@ export const SQLPopover = (props: PopoverProps & { sqlExpression: string }) => {
/>
}
placement="bottomLeft"
arrowPointAtCenter
arrow={{ pointAtCenter: true }}
title={t('SQL expression')}
{...props}
>

View File

@@ -84,6 +84,12 @@ export interface Dataset {
filter_select?: boolean;
filter_select_enabled?: boolean;
column_names?: string[];
catalog?: string;
schema?: string;
table_name?: string;
database?: Record<string, unknown>;
normalize_columns?: boolean;
always_filter_main_dttm?: boolean;
}
export interface ControlPanelState {
@@ -515,6 +521,13 @@ export enum SortSeriesType {
Avg = 'avg',
}
export type LegendPaddingType = {
top?: number;
bottom?: number;
left?: number;
right?: number;
};
export type SortSeriesData = {
sort_series_type: SortSeriesType;
sort_series_ascending: boolean;

View File

@@ -19,17 +19,23 @@
import { QueryFormMetric, isSavedMetric, isAdhocMetricSimple } from './types';
export default function getMetricLabel(metric: QueryFormMetric): string {
export default function getMetricLabel(
metric: QueryFormMetric,
index?: number,
queryFormMetrics?: QueryFormMetric[],
verboseMap?: Record<string, string>,
): string {
let label = '';
if (isSavedMetric(metric)) {
return metric;
}
if (metric.label) {
return metric.label;
}
if (isAdhocMetricSimple(metric)) {
return `${metric.aggregate}(${
label = metric;
} else if (metric.label) {
({ label } = metric);
} else if (isAdhocMetricSimple(metric)) {
label = `${metric.aggregate}(${
metric.column.columnName || metric.column.column_name
})`;
} else {
label = metric.sqlExpression;
}
return metric.sqlExpression;
return verboseMap?.[label] || label;
}

View File

@@ -17,7 +17,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Maybe, QueryFormMetric } from '../../types';
import { Currency, Maybe, QueryFormMetric } from '../../types';
import { Column } from './Column';
export type Aggregate =
@@ -65,7 +65,7 @@ export interface Metric {
certification_details?: Maybe<string>;
certified_by?: Maybe<string>;
d3format?: Maybe<string>;
currency?: Maybe<string>;
currency?: Maybe<Currency>;
description?: Maybe<string>;
is_certified?: boolean;
verbose_name?: string;

View File

@@ -17,9 +17,9 @@
* under the License.
*/
import { mount, shallow } from 'enzyme';
import { triggerResizeObserver } from 'resize-observer-polyfill';
import { promiseTimeout, WithLegend } from '@superset-ui/core';
import { render } from '@testing-library/react';
let renderChart = jest.fn();
let renderLegend = jest.fn();
@@ -32,18 +32,18 @@ describe.skip('WithLegend', () => {
});
it('sets className', () => {
const wrapper = shallow(
const { container } = render(
<WithLegend
className="test-class"
renderChart={renderChart}
renderLegend={renderLegend}
/>,
);
expect(wrapper.hasClass('test-class')).toEqual(true);
expect(container.querySelectorAll('.test-class')).toHaveLength(1);
});
it('renders when renderLegend is not set', () => {
const wrapper = mount(
const { container } = render(
<WithLegend
debounceTime={1}
width={500}
@@ -56,13 +56,13 @@ describe.skip('WithLegend', () => {
// Have to delay more than debounceTime (1ms)
return promiseTimeout(() => {
expect(renderChart).toHaveBeenCalledTimes(1);
expect(wrapper.render().find('div.chart')).toHaveLength(1);
expect(wrapper.render().find('div.legend')).toHaveLength(0);
expect(container.querySelectorAll('div.chart')).toHaveLength(1);
expect(container.querySelectorAll('div.legend')).toHaveLength(0);
}, 100);
});
it('renders', () => {
const wrapper = mount(
const { container } = render(
<WithLegend
debounceTime={1}
width={500}
@@ -77,13 +77,13 @@ describe.skip('WithLegend', () => {
return promiseTimeout(() => {
expect(renderChart).toHaveBeenCalledTimes(1);
expect(renderLegend).toHaveBeenCalledTimes(1);
expect(wrapper.render().find('div.chart')).toHaveLength(1);
expect(wrapper.render().find('div.legend')).toHaveLength(1);
expect(container.querySelectorAll('div.chart')).toHaveLength(1);
expect(container.querySelectorAll('div.legend')).toHaveLength(1);
}, 100);
});
it('renders without width or height', () => {
const wrapper = mount(
const { container } = render(
<WithLegend
debounceTime={1}
renderChart={renderChart}
@@ -96,13 +96,13 @@ describe.skip('WithLegend', () => {
return promiseTimeout(() => {
expect(renderChart).toHaveBeenCalledTimes(1);
expect(renderLegend).toHaveBeenCalledTimes(1);
expect(wrapper.render().find('div.chart')).toHaveLength(1);
expect(wrapper.render().find('div.legend')).toHaveLength(1);
expect(container.querySelectorAll('div.chart')).toHaveLength(1);
expect(container.querySelectorAll('div.legend')).toHaveLength(1);
}, 100);
});
it('renders legend on the left', () => {
const wrapper = mount(
const { container } = render(
<WithLegend
debounceTime={1}
position="left"
@@ -116,13 +116,13 @@ describe.skip('WithLegend', () => {
return promiseTimeout(() => {
expect(renderChart).toHaveBeenCalledTimes(1);
expect(renderLegend).toHaveBeenCalledTimes(1);
expect(wrapper.render().find('div.chart')).toHaveLength(1);
expect(wrapper.render().find('div.legend')).toHaveLength(1);
expect(container.querySelectorAll('div.chart')).toHaveLength(1);
expect(container.querySelectorAll('div.legend')).toHaveLength(1);
}, 100);
});
it('renders legend on the right', () => {
const wrapper = mount(
const { container } = render(
<WithLegend
debounceTime={1}
position="right"
@@ -136,13 +136,13 @@ describe.skip('WithLegend', () => {
return promiseTimeout(() => {
expect(renderChart).toHaveBeenCalledTimes(1);
expect(renderLegend).toHaveBeenCalledTimes(1);
expect(wrapper.render().find('div.chart')).toHaveLength(1);
expect(wrapper.render().find('div.legend')).toHaveLength(1);
expect(container.querySelectorAll('div.chart')).toHaveLength(1);
expect(container.querySelectorAll('div.legend')).toHaveLength(1);
}, 100);
});
it('renders legend on the top', () => {
const wrapper = mount(
const { container } = render(
<WithLegend
debounceTime={1}
position="top"
@@ -156,13 +156,13 @@ describe.skip('WithLegend', () => {
return promiseTimeout(() => {
expect(renderChart).toHaveBeenCalledTimes(1);
expect(renderLegend).toHaveBeenCalledTimes(1);
expect(wrapper.render().find('div.chart')).toHaveLength(1);
expect(wrapper.render().find('div.legend')).toHaveLength(1);
expect(container.querySelectorAll('div.chart')).toHaveLength(1);
expect(container.querySelectorAll('div.legend')).toHaveLength(1);
}, 100);
});
it('renders legend on the bottom', () => {
const wrapper = mount(
const { container } = render(
<WithLegend
debounceTime={1}
position="bottom"
@@ -176,13 +176,13 @@ describe.skip('WithLegend', () => {
return promiseTimeout(() => {
expect(renderChart).toHaveBeenCalledTimes(1);
expect(renderLegend).toHaveBeenCalledTimes(1);
expect(wrapper.render().find('div.chart')).toHaveLength(1);
expect(wrapper.render().find('div.legend')).toHaveLength(1);
expect(container.querySelectorAll('div.chart')).toHaveLength(1);
expect(container.querySelectorAll('div.legend')).toHaveLength(1);
}, 100);
});
it('renders legend with justifyContent set', () => {
const wrapper = mount(
const { container } = render(
<WithLegend
debounceTime={1}
position="right"
@@ -197,8 +197,8 @@ describe.skip('WithLegend', () => {
return promiseTimeout(() => {
expect(renderChart).toHaveBeenCalledTimes(1);
expect(renderLegend).toHaveBeenCalledTimes(1);
expect(wrapper.render().find('div.chart')).toHaveLength(1);
expect(wrapper.render().find('div.legend')).toHaveLength(1);
expect(container.querySelectorAll('div.chart')).toHaveLength(1);
expect(container.querySelectorAll('div.legend')).toHaveLength(1);
}, 100);
});
});

View File

@@ -16,16 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ReactNode } from 'react';
import { shallow } from 'enzyme';
import '@testing-library/jest-dom';
import { render, screen, act } from '@testing-library/react';
import ChartClient from '../../../src/chart/clients/ChartClient';
import ChartDataProvider, {
ChartDataProviderProps,
} from '../../../src/chart/components/ChartDataProvider';
import { bigNumberFormData } from '../fixtures/formData';
// Note: the mock implementation of these function directly affects the expected results below
// Keep existing mock setup
const defaultMockLoadFormData = jest.fn(({ formData }: { formData: unknown }) =>
Promise.resolve(formData),
);
@@ -50,7 +49,6 @@ const mockLoadQueryData = jest.fn<Promise<unknown>, unknown[]>(
);
const actual = jest.requireActual('../../../src/chart/clients/ChartClient');
// ChartClient is now a mock
jest.spyOn(actual, 'default').mockImplementation(() => ({
loadDatasource: mockLoadDatasource,
loadFormData: mockLoadFormData,
@@ -62,7 +60,6 @@ const ChartClientMock = ChartClient as jest.Mock<ChartClient>;
describe('ChartDataProvider', () => {
beforeEach(() => {
ChartClientMock.mockClear();
mockLoadFormData = defaultMockLoadFormData;
mockLoadFormData.mockClear();
mockLoadDatasource.mockClear();
@@ -71,11 +68,17 @@ describe('ChartDataProvider', () => {
const props: ChartDataProviderProps = {
formData: { ...bigNumberFormData },
children: () => <div />,
children: ({ loading, payload, error }) => (
<div>
{loading && <span role="status">Loading...</span>}
{payload && <pre role="contentinfo">{JSON.stringify(payload)}</pre>}
{error && <div role="alert">{error.message}</div>}
</div>
),
};
function setup(overrideProps?: Partial<ChartDataProviderProps>) {
return shallow(<ChartDataProvider {...props} {...overrideProps} />);
return render(<ChartDataProvider {...props} {...overrideProps} />);
}
it('instantiates a new ChartClient()', () => {
@@ -86,7 +89,7 @@ describe('ChartDataProvider', () => {
describe('ChartClient.loadFormData', () => {
it('calls method on mount', () => {
setup();
expect(mockLoadFormData.mock.calls).toHaveLength(1);
expect(mockLoadFormData).toHaveBeenCalledTimes(1);
expect(mockLoadFormData.mock.calls[0][0]).toEqual({
sliceId: props.sliceId,
formData: props.formData,
@@ -96,234 +99,231 @@ describe('ChartDataProvider', () => {
it('should pass formDataRequestOptions to ChartClient.loadFormData', () => {
const options = { host: 'override' };
setup({ formDataRequestOptions: options });
expect(mockLoadFormData.mock.calls).toHaveLength(1);
expect(mockLoadFormData).toHaveBeenCalledTimes(1);
expect(mockLoadFormData.mock.calls[0][1]).toEqual(options);
});
it('calls ChartClient.loadFormData when formData or sliceId change', () => {
const wrapper = setup();
it('calls ChartClient.loadFormData when formData or sliceId change', async () => {
const { rerender } = setup();
const newProps = { sliceId: 123, formData: undefined };
expect(mockLoadFormData.mock.calls).toHaveLength(1);
expect(mockLoadFormData).toHaveBeenCalledTimes(1);
wrapper.setProps(newProps);
expect(mockLoadFormData.mock.calls).toHaveLength(2);
rerender(<ChartDataProvider {...props} {...newProps} />);
expect(mockLoadFormData).toHaveBeenCalledTimes(2);
expect(mockLoadFormData.mock.calls[1][0]).toEqual(newProps);
});
});
describe('ChartClient.loadDatasource', () => {
it('does not method if loadDatasource is false', () =>
new Promise(done => {
expect.assertions(1);
setup({ loadDatasource: false });
setTimeout(() => {
expect(mockLoadDatasource.mock.calls).toHaveLength(0);
done(undefined);
}, 0);
}));
it('does not call method if loadDatasource is false', async () => {
setup({ loadDatasource: false });
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(mockLoadDatasource).not.toHaveBeenCalled();
});
it('calls method on mount if loadDatasource is true', () =>
new Promise(done => {
expect.assertions(2);
setup({ loadDatasource: true });
setTimeout(() => {
expect(mockLoadDatasource.mock.calls).toHaveLength(1);
expect(mockLoadDatasource.mock.calls[0][0]).toEqual(
props.formData.datasource,
);
done(undefined);
}, 0);
}));
it('calls method on mount if loadDatasource is true', async () => {
setup({ loadDatasource: true });
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(mockLoadDatasource).toHaveBeenCalledTimes(1);
expect(mockLoadDatasource.mock.calls[0]).toEqual([
props.formData.datasource,
undefined,
]);
});
it('should pass datasourceRequestOptions to ChartClient.loadDatasource', () =>
new Promise(done => {
expect.assertions(2);
const options = { host: 'override' };
setup({ loadDatasource: true, datasourceRequestOptions: options });
setTimeout(() => {
expect(mockLoadDatasource.mock.calls).toHaveLength(1);
expect(mockLoadDatasource.mock.calls[0][1]).toEqual(options);
done(undefined);
}, 0);
}));
it('should pass datasourceRequestOptions to ChartClient.loadDatasource', async () => {
const options = { host: 'override' };
setup({ loadDatasource: true, datasourceRequestOptions: options });
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(mockLoadDatasource).toHaveBeenCalledTimes(1);
expect(mockLoadDatasource.mock.calls[0][1]).toEqual(options);
});
it('calls ChartClient.loadDatasource if loadDatasource is true and formData or sliceId change', () =>
new Promise(done => {
expect.assertions(3);
const newDatasource = 'test';
const wrapper = setup({ loadDatasource: true });
wrapper.setProps({
formData: { datasource: newDatasource },
sliceId: undefined,
});
it('calls ChartClient.loadDatasource if loadDatasource is true and formData or sliceId change', async () => {
const { rerender } = setup({ loadDatasource: true });
const newDatasource = 'test';
setTimeout(() => {
expect(mockLoadDatasource.mock.calls).toHaveLength(2);
expect(mockLoadDatasource.mock.calls[0][0]).toEqual(
props.formData.datasource,
);
expect(mockLoadDatasource.mock.calls[1][0]).toEqual(newDatasource);
done(undefined);
}, 0);
}));
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});
await act(async () => {
rerender(
<ChartDataProvider
{...props}
formData={{ ...props.formData, datasource: newDatasource }}
loadDatasource
/>,
);
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(mockLoadDatasource).toHaveBeenCalledTimes(2);
expect(mockLoadDatasource.mock.calls[0]).toEqual([
props.formData.datasource,
undefined,
]);
expect(mockLoadDatasource.mock.calls[1]).toEqual([
newDatasource,
undefined,
]);
});
});
describe('ChartClient.loadQueryData', () => {
it('calls method on mount', () =>
new Promise(done => {
expect.assertions(2);
setup();
setTimeout(() => {
expect(mockLoadQueryData.mock.calls).toHaveLength(1);
expect(mockLoadQueryData.mock.calls[0][0]).toEqual(props.formData);
done(undefined);
}, 0);
}));
it('calls method on mount', async () => {
setup();
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(mockLoadQueryData).toHaveBeenCalledTimes(1);
expect(mockLoadQueryData.mock.calls[0]).toEqual([
props.formData,
undefined,
]);
});
it('should pass queryDataRequestOptions to ChartClient.loadQueryData', () =>
new Promise(done => {
expect.assertions(2);
const options = { host: 'override' };
setup({ queryRequestOptions: options });
setTimeout(() => {
expect(mockLoadQueryData.mock.calls).toHaveLength(1);
expect(mockLoadQueryData.mock.calls[0][1]).toEqual(options);
done(undefined);
}, 0);
}));
it('should pass queryDataRequestOptions to ChartClient.loadQueryData', async () => {
const options = { host: 'override' };
setup({ queryRequestOptions: options });
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(mockLoadQueryData).toHaveBeenCalledTimes(1);
expect(mockLoadQueryData).toHaveBeenCalledWith(
expect.anything(),
options,
);
});
it('calls ChartClient.loadQueryData when formData or sliceId change', () =>
new Promise(done => {
expect.assertions(3);
const newFormData = { key: 'test' };
const wrapper = setup();
wrapper.setProps({ formData: newFormData, sliceId: undefined });
it('calls ChartClient.loadQueryData when formData or sliceId change', async () => {
const { rerender } = setup();
const newFormData = { key: 'test' };
setTimeout(() => {
expect(mockLoadQueryData.mock.calls).toHaveLength(2);
expect(mockLoadQueryData.mock.calls[0][0]).toEqual(props.formData);
expect(mockLoadQueryData.mock.calls[1][0]).toEqual(newFormData);
done(undefined);
}, 0);
}));
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});
await act(async () => {
rerender(<ChartDataProvider {...props} formData={newFormData} />);
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(mockLoadQueryData).toHaveBeenCalledTimes(2);
expect(mockLoadQueryData.mock.calls[0]).toEqual([
props.formData,
undefined,
]);
expect(mockLoadQueryData.mock.calls[1]).toEqual([newFormData, undefined]);
});
});
describe('children', () => {
it('calls children({ loading: true }) when loading', () => {
const children = jest.fn<ReactNode, unknown[]>();
setup({ children });
it('shows loading state initially', async () => {
mockLoadFormData.mockImplementation(() => new Promise(() => {}));
mockLoadQueryData.mockImplementation(() => new Promise(() => {}));
mockLoadDatasource.mockImplementation(() => new Promise(() => {}));
// during the first tick (before more promises resolve) loading is true
expect(children.mock.calls).toHaveLength(1);
expect(children.mock.calls[0][0]).toEqual({ loading: true });
setup();
await screen.findByRole('status');
});
it('calls children({ payload }) when loaded', () =>
new Promise(done => {
expect.assertions(2);
const children = jest.fn<ReactNode, unknown[]>();
setup({ children, loadDatasource: true });
it('shows payload when loaded', async () => {
mockLoadFormData.mockResolvedValue(props.formData);
mockLoadQueryData.mockResolvedValue([props.formData]);
mockLoadDatasource.mockResolvedValue(props.formData.datasource);
setTimeout(() => {
expect(children.mock.calls).toHaveLength(2);
expect(children.mock.calls[1][0]).toEqual({
payload: {
formData: props.formData,
datasource: props.formData.datasource,
queriesData: [props.formData],
},
});
done(undefined);
}, 0);
}));
setup({ loadDatasource: true });
it('calls children({ error }) upon request error', () =>
new Promise(done => {
expect.assertions(2);
const children = jest.fn<ReactNode, unknown[]>();
mockLoadFormData = jest.fn(() => Promise.reject(new Error('error')));
const payloadElement = await screen.findByRole('contentinfo');
const actualPayload = JSON.parse(payloadElement.textContent || '');
setup({ children });
expect(actualPayload).toEqual({
formData: props.formData,
datasource: props.formData.datasource,
queriesData: [props.formData],
});
});
setTimeout(() => {
expect(children.mock.calls).toHaveLength(2); // loading + error
expect(children.mock.calls[1][0]).toEqual({
error: new Error('error'),
});
done(undefined);
}, 0);
}));
it('shows error message upon request error', async () => {
const errorMessage = 'error';
mockLoadFormData.mockRejectedValue(new Error(errorMessage));
it('calls children({ error }) upon JS error', () =>
new Promise(done => {
expect.assertions(2);
const children = jest.fn<ReactNode, unknown[]>();
setup();
mockLoadFormData = jest.fn(() => {
throw new Error('non-async error');
});
const errorElement = await screen.findByRole('alert');
expect(errorElement).toHaveAttribute('role', 'alert');
expect(errorElement).toHaveTextContent(errorMessage);
});
setup({ children });
it('shows error message upon JS error', async () => {
mockLoadFormData.mockImplementation(() => {
throw new Error('non-async error');
});
setTimeout(() => {
expect(children.mock.calls).toHaveLength(2); // loading + error
expect(children.mock.calls[1][0]).toEqual({
error: new Error('non-async error'),
});
done(undefined);
}, 0);
}));
setup();
const errorElement = await screen.findByRole('alert');
expect(errorElement).toHaveAttribute('role', 'alert');
expect(errorElement).toHaveTextContent('non-async error');
});
});
describe('callbacks', () => {
it('calls onLoad(payload) when loaded', () =>
new Promise(done => {
expect.assertions(2);
const onLoaded = jest.fn<void, unknown[]>();
setup({ onLoaded, loadDatasource: true });
it('calls onLoaded when loaded', async () => {
const onLoaded = jest.fn();
mockLoadFormData.mockResolvedValue(props.formData);
mockLoadQueryData.mockResolvedValue([props.formData]);
mockLoadDatasource.mockResolvedValue(props.formData.datasource);
setTimeout(() => {
expect(onLoaded.mock.calls).toHaveLength(1);
expect(onLoaded.mock.calls[0][0]).toEqual({
formData: props.formData,
datasource: props.formData.datasource,
queriesData: [props.formData],
});
done(undefined);
}, 0);
}));
setup({ onLoaded, loadDatasource: true });
it('calls onError(error) upon request error', () =>
new Promise(done => {
expect.assertions(2);
const onError = jest.fn<void, unknown[]>();
mockLoadFormData = jest.fn(() => Promise.reject(new Error('error')));
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});
setup({ onError });
setTimeout(() => {
expect(onError.mock.calls).toHaveLength(1);
expect(onError.mock.calls[0][0]).toEqual(new Error('error'));
done(undefined);
}, 0);
}));
expect(onLoaded).toHaveBeenCalledTimes(1);
expect(onLoaded).toHaveBeenCalledWith({
formData: props.formData,
datasource: props.formData.datasource,
queriesData: [props.formData],
});
});
it('calls onError(error) upon JS error', () =>
new Promise(done => {
expect.assertions(2);
const onError = jest.fn<void, unknown[]>();
it('calls onError upon request error', async () => {
const onError = jest.fn();
mockLoadFormData.mockRejectedValue(new Error('error'));
mockLoadFormData = jest.fn(() => {
throw new Error('non-async error');
});
setup({ onError });
setup({ onError });
setTimeout(() => {
expect(onError.mock.calls).toHaveLength(1);
expect(onError.mock.calls[0][0]).toEqual(
new Error('non-async error'),
);
done(undefined);
}, 0);
}));
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(new Error('error'));
});
it('calls onError upon JS error', async () => {
const onError = jest.fn();
mockLoadFormData.mockImplementation(() => {
throw new Error('non-async error');
});
setup({ onError });
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(new Error('non-async error'));
});
});
});

View File

@@ -17,10 +17,12 @@
* under the License.
*/
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { ReactElement } from 'react';
import mockConsole, { RestoreConsole } from 'jest-mock-console';
import { triggerResizeObserver } from 'resize-observer-polyfill';
import ErrorBoundary from 'react-error-boundary';
import { ErrorBoundary } from 'react-error-boundary';
import {
promiseTimeout,
@@ -28,9 +30,7 @@ import {
supersetTheme,
ThemeProvider,
} from '@superset-ui/core';
import { mount as enzymeMount } from 'enzyme';
import { WrapperProps } from '../../../src/chart/components/SuperChart';
import NoResultsComponent from '../../../src/chart/components/NoResultsComponent';
import {
ChartKeys,
@@ -44,45 +44,39 @@ const DEFAULT_QUERIES_DATA = [
{ data: ['foo2', 'bar2'] },
];
function expectDimension(
renderedWrapper: cheerio.Cheerio,
width: number,
height: number,
) {
expect(renderedWrapper.find('.dimension').text()).toEqual(
[width, height].join('x'),
);
// Fix for expect outside test block - move expectDimension into a test utility
// Replace expectDimension function with a non-expect version
function getDimensionText(container: HTMLElement) {
const dimensionEl = container.querySelector('.dimension');
return dimensionEl?.textContent || '';
}
const mount = (component: ReactElement) =>
enzymeMount(component, {
wrappingComponent: ThemeProvider,
wrappingComponentProps: { theme: supersetTheme },
const renderWithTheme = (component: ReactElement) =>
render(component, {
wrapper: ({ children }) => (
<ThemeProvider theme={supersetTheme}>{children}</ThemeProvider>
),
});
// TODO: rewrite to rtl
describe.skip('SuperChart', () => {
describe('SuperChart', () => {
jest.setTimeout(5000);
let restoreConsole: RestoreConsole;
const plugins = [
new DiligentChartPlugin().configure({ key: ChartKeys.DILIGENT }),
new BuggyChartPlugin().configure({ key: ChartKeys.BUGGY }),
];
let restoreConsole: RestoreConsole;
beforeAll(() => {
plugins.forEach(p => {
p.unregister().register();
});
});
afterAll(() => {
plugins.forEach(p => {
p.unregister();
});
});
beforeEach(() => {
restoreConsole = mockConsole();
triggerResizeObserver([]); // Reset any pending resize observers
});
afterEach(() => {
@@ -105,14 +99,16 @@ describe.skip('SuperChart', () => {
afterEach(() => {
window.removeEventListener('error', onError);
// eslint-disable-next-line jest/no-standalone-expect
});
it('should have correct number of errors', () => {
expect(actualErrors).toBe(expectedErrors);
expectedErrors = 0;
});
it('renders default FallbackComponent', async () => {
expectedErrors = 1;
const wrapper = mount(
renderWithTheme(
<SuperChart
chartType={ChartKeys.BUGGY}
queriesData={[DEFAULT_QUERY_DATA]}
@@ -120,16 +116,19 @@ describe.skip('SuperChart', () => {
height="200"
/>,
);
await new Promise(resolve => setImmediate(resolve));
wrapper.update();
expect(wrapper.text()).toContain('Oops! An error occurred!');
expect(
await screen.findByText('Oops! An error occurred!'),
).toBeInTheDocument();
});
it('renders custom FallbackComponent', () => {
it('renders custom FallbackComponent', async () => {
expectedErrors = 1;
const CustomFallbackComponent = jest.fn(() => (
<div>Custom Fallback!</div>
));
const wrapper = mount(
renderWithTheme(
<SuperChart
chartType={ChartKeys.BUGGY}
queriesData={[DEFAULT_QUERY_DATA]}
@@ -139,15 +138,13 @@ describe.skip('SuperChart', () => {
/>,
);
return promiseTimeout(() => {
expect(wrapper.render().find('div.test-component')).toHaveLength(0);
expect(CustomFallbackComponent).toHaveBeenCalledTimes(1);
});
expect(await screen.findByText('Custom Fallback!')).toBeInTheDocument();
expect(CustomFallbackComponent).toHaveBeenCalledTimes(1);
});
it('call onErrorBoundary', () => {
it('call onErrorBoundary', async () => {
expectedErrors = 1;
const handleError = jest.fn();
mount(
renderWithTheme(
<SuperChart
chartType={ChartKeys.BUGGY}
queriesData={[DEFAULT_QUERY_DATA]}
@@ -157,17 +154,20 @@ describe.skip('SuperChart', () => {
/>,
);
return promiseTimeout(() => {
expect(handleError).toHaveBeenCalledTimes(1);
});
await screen.findByText('Oops! An error occurred!');
expect(handleError).toHaveBeenCalledTimes(1);
});
it('does not include ErrorBoundary if told so', () => {
// Update the test cases
it('does not include ErrorBoundary if told so', async () => {
expectedErrors = 1;
const inactiveErrorHandler = jest.fn();
const activeErrorHandler = jest.fn();
mount(
// @ts-ignore
<ErrorBoundary onError={activeErrorHandler}>
renderWithTheme(
<ErrorBoundary
fallbackRender={() => <div>Error!</div>}
onError={activeErrorHandler}
>
<SuperChart
disableErrorBoundary
chartType={ChartKeys.BUGGY}
@@ -179,15 +179,24 @@ describe.skip('SuperChart', () => {
</ErrorBoundary>,
);
return promiseTimeout(() => {
expect(activeErrorHandler).toHaveBeenCalledTimes(1);
expect(inactiveErrorHandler).toHaveBeenCalledTimes(0);
});
await screen.findByText('Error!');
expect(activeErrorHandler).toHaveBeenCalledTimes(1);
expect(inactiveErrorHandler).not.toHaveBeenCalled();
});
});
it('passes the props to renderer correctly', () => {
const wrapper = mount(
// Update the props tests to use className instead of data-testid
// Helper function to find elements by class name
const findByClassName = (container: HTMLElement, className: string) =>
container.querySelector(`.${className}`);
// Update test cases
// Update timeout for all async tests
jest.setTimeout(10000);
// Update the props test to wait for component to render
it('passes the props to renderer correctly', async () => {
const { container } = renderWithTheme(
<SuperChart
chartType={ChartKeys.DILIGENT}
queriesData={[DEFAULT_QUERY_DATA]}
@@ -197,15 +206,123 @@ describe.skip('SuperChart', () => {
/>,
);
return promiseTimeout(() => {
const renderedWrapper = wrapper.render();
expect(renderedWrapper.find('div.test-component')).toHaveLength(1);
expectDimension(renderedWrapper, 101, 118);
await promiseTimeout(() => {
const testComponent = findByClassName(container, 'test-component');
expect(testComponent).not.toBeNull();
expect(testComponent).toBeInTheDocument();
expect(getDimensionText(container)).toBe('101x118');
});
});
it('passes the props with multiple queries to renderer correctly', () => {
const wrapper = mount(
// Helper function to create a sized wrapper
const createSizedWrapper = () => {
const wrapper = document.createElement('div');
wrapper.style.width = '300px';
wrapper.style.height = '300px';
wrapper.style.position = 'relative';
wrapper.style.display = 'block';
return wrapper;
};
// Update dimension tests to wait for resize observer
// First, increase the timeout for all tests
jest.setTimeout(20000);
// Update the waitForDimensions helper to include a retry mechanism
// Update waitForDimensions to avoid await in loop
const waitForDimensions = async (
container: HTMLElement,
expectedWidth: number,
expectedHeight: number,
) => {
const maxAttempts = 5;
const interval = 100;
return new Promise<void>((resolve, reject) => {
let attempts = 0;
const checkDimension = () => {
const testComponent = container.querySelector('.test-component');
const dimensionEl = container.querySelector('.dimension');
if (!testComponent || !dimensionEl) {
if (attempts >= maxAttempts) {
reject(new Error('Elements not found'));
return;
}
attempts += 1;
setTimeout(checkDimension, interval);
return;
}
if (dimensionEl.textContent !== `${expectedWidth}x${expectedHeight}`) {
if (attempts >= maxAttempts) {
reject(new Error('Dimension mismatch'));
return;
}
attempts += 1;
setTimeout(checkDimension, interval);
return;
}
resolve();
};
checkDimension();
});
};
// Update the resize observer trigger to ensure it's called after component mount
it.skip('works when width and height are percent', async () => {
const { container } = renderWithTheme(
<SuperChart
chartType={ChartKeys.DILIGENT}
queriesData={[DEFAULT_QUERY_DATA]}
debounceTime={1}
width="100%"
height="100%"
/>,
);
// Wait for initial render
await new Promise(resolve => setTimeout(resolve, 50));
triggerResizeObserver([
{
contentRect: {
width: 300,
height: 300,
top: 0,
left: 0,
right: 300,
bottom: 300,
x: 0,
y: 0,
toJSON() {
return {
width: this.width,
height: this.height,
top: this.top,
left: this.left,
right: this.right,
bottom: this.bottom,
x: this.x,
y: this.y,
};
},
},
borderBoxSize: [{ blockSize: 300, inlineSize: 300 }],
contentBoxSize: [{ blockSize: 300, inlineSize: 300 }],
devicePixelContentBoxSize: [{ blockSize: 300, inlineSize: 300 }],
target: document.createElement('div'),
},
]);
await waitForDimensions(container, 300, 300);
});
it('passes the props with multiple queries to renderer correctly', async () => {
const { container } = renderWithTheme(
<SuperChart
chartType={ChartKeys.DILIGENT}
queriesData={DEFAULT_QUERIES_DATA}
@@ -215,42 +332,25 @@ describe.skip('SuperChart', () => {
/>,
);
return promiseTimeout(() => {
const renderedWrapper = wrapper.render();
expect(renderedWrapper.find('div.test-component')).toHaveLength(1);
expectDimension(renderedWrapper, 101, 118);
});
});
it('passes the props with multiple queries and single query to renderer correctly (backward compatibility)', () => {
const wrapper = mount(
<SuperChart
chartType={ChartKeys.DILIGENT}
queriesData={DEFAULT_QUERIES_DATA}
width={101}
height={118}
formData={{ abc: 1 }}
/>,
);
return promiseTimeout(() => {
const renderedWrapper = wrapper.render();
expect(renderedWrapper.find('div.test-component')).toHaveLength(1);
expectDimension(renderedWrapper, 101, 118);
await promiseTimeout(() => {
const testComponent = container.querySelector('.test-component');
expect(testComponent).not.toBeNull();
expect(testComponent).toBeInTheDocument();
expect(getDimensionText(container)).toBe('101x118');
});
});
describe('supports NoResultsComponent', () => {
it('renders NoResultsComponent when queriesData is missing', () => {
const wrapper = mount(
renderWithTheme(
<SuperChart chartType={ChartKeys.DILIGENT} width="200" height="200" />,
);
expect(wrapper.find(NoResultsComponent)).toHaveLength(1);
expect(screen.getByText('No Results')).toBeInTheDocument();
});
it('renders NoResultsComponent when queriesData data is null', () => {
const wrapper = mount(
renderWithTheme(
<SuperChart
chartType={ChartKeys.DILIGENT}
queriesData={[{ data: null }]}
@@ -259,116 +359,12 @@ describe.skip('SuperChart', () => {
/>,
);
expect(wrapper.find(NoResultsComponent)).toHaveLength(1);
expect(screen.getByText('No Results')).toBeInTheDocument();
});
});
describe('supports dynamic width and/or height', () => {
it('works with width and height that are numbers', () => {
const wrapper = mount(
<SuperChart
chartType={ChartKeys.DILIGENT}
queriesData={[DEFAULT_QUERY_DATA]}
width={100}
height={100}
/>,
);
return promiseTimeout(() => {
const renderedWrapper = wrapper.render();
expect(renderedWrapper.find('div.test-component')).toHaveLength(1);
expectDimension(renderedWrapper, 100, 100);
});
});
it('works when width and height are percent', () => {
const wrapper = mount(
<SuperChart
chartType={ChartKeys.DILIGENT}
queriesData={[DEFAULT_QUERY_DATA]}
debounceTime={1}
width="100%"
height="100%"
/>,
);
triggerResizeObserver();
return promiseTimeout(() => {
const renderedWrapper = wrapper.render();
expect(renderedWrapper.find('div.test-component')).toHaveLength(1);
expectDimension(renderedWrapper, 300, 300);
}, 100);
});
it('works when only width is percent', () => {
const wrapper = mount(
<SuperChart
chartType={ChartKeys.DILIGENT}
queriesData={[DEFAULT_QUERY_DATA]}
debounceTime={1}
width="50%"
height="125"
/>,
);
// @ts-ignore
triggerResizeObserver([{ contentRect: { height: 125, width: 150 } }]);
return promiseTimeout(() => {
const renderedWrapper = wrapper.render();
const boundingBox = renderedWrapper
.find('div.test-component')
.parent()
.parent()
.parent();
expect(boundingBox.css('width')).toEqual('50%');
expect(boundingBox.css('height')).toEqual('125px');
expect(renderedWrapper.find('div.test-component')).toHaveLength(1);
expectDimension(renderedWrapper, 150, 125);
}, 100);
});
it('works when only height is percent', () => {
const wrapper = mount(
<SuperChart
chartType={ChartKeys.DILIGENT}
queriesData={[DEFAULT_QUERY_DATA]}
debounceTime={1}
width="50"
height="25%"
/>,
);
// @ts-ignore
triggerResizeObserver([{ contentRect: { height: 75, width: 50 } }]);
return promiseTimeout(() => {
const renderedWrapper = wrapper.render();
const boundingBox = renderedWrapper
.find('div.test-component')
.parent()
.parent()
.parent();
expect(boundingBox.css('width')).toEqual('50px');
expect(boundingBox.css('height')).toEqual('25%');
expect(renderedWrapper.find('div.test-component')).toHaveLength(1);
expectDimension(renderedWrapper, 50, 75);
}, 100);
});
it('works when width and height are not specified', () => {
const wrapper = mount(
<SuperChart
chartType={ChartKeys.DILIGENT}
queriesData={[DEFAULT_QUERY_DATA]}
debounceTime={1}
/>,
);
triggerResizeObserver();
return promiseTimeout(() => {
const renderedWrapper = wrapper.render();
expect(renderedWrapper.find('div.test-component')).toHaveLength(1);
expectDimension(renderedWrapper, 300, 400);
}, 100);
});
});
describe('supports Wrapper', () => {
// Add MyWrapper component definition
function MyWrapper({ width, height, children }: WrapperProps) {
return (
<div>
@@ -380,50 +376,81 @@ describe.skip('SuperChart', () => {
);
}
it('works with width and height that are numbers', () => {
const wrapper = mount(
it('works with width and height that are numbers', async () => {
const { container } = renderWithTheme(
<SuperChart
chartType={ChartKeys.DILIGENT}
queriesData={[DEFAULT_QUERY_DATA]}
width={100}
height={100}
Wrapper={MyWrapper}
/>,
);
return promiseTimeout(() => {
const renderedWrapper = wrapper.render();
expect(renderedWrapper.find('div.wrapper-insert')).toHaveLength(1);
expect(renderedWrapper.find('div.wrapper-insert').text()).toEqual(
'100x100',
);
expect(renderedWrapper.find('div.test-component')).toHaveLength(1);
expectDimension(renderedWrapper, 100, 100);
}, 100);
await promiseTimeout(() => {
const testComponent = container.querySelector('.test-component');
expect(testComponent).not.toBeNull();
expect(testComponent).toBeInTheDocument();
expect(getDimensionText(container)).toBe('100x100');
});
});
it('works when width and height are percent', () => {
const wrapper = mount(
<SuperChart
chartType={ChartKeys.DILIGENT}
queriesData={[DEFAULT_QUERY_DATA]}
debounceTime={1}
width="100%"
height="100%"
Wrapper={MyWrapper}
/>,
it.skip('works when width and height are percent', async () => {
const wrapper = createSizedWrapper();
document.body.appendChild(wrapper);
const { container } = renderWithTheme(
<div style={{ width: '100%', height: '100%', position: 'absolute' }}>
<SuperChart
chartType={ChartKeys.DILIGENT}
queriesData={[DEFAULT_QUERY_DATA]}
debounceTime={1}
width="100%"
height="100%"
Wrapper={MyWrapper}
/>
</div>,
);
triggerResizeObserver();
return promiseTimeout(() => {
const renderedWrapper = wrapper.render();
expect(renderedWrapper.find('div.wrapper-insert')).toHaveLength(1);
expect(renderedWrapper.find('div.wrapper-insert').text()).toEqual(
'300x300',
);
expect(renderedWrapper.find('div.test-component')).toHaveLength(1);
expectDimension(renderedWrapper, 300, 300);
}, 100);
});
wrapper.appendChild(container);
// Wait for initial render
await new Promise(resolve => setTimeout(resolve, 100));
// Trigger resize
triggerResizeObserver([
{
contentRect: {
width: 300,
height: 300,
top: 0,
left: 0,
right: 300,
bottom: 300,
x: 0,
y: 0,
toJSON() {
return this;
},
},
borderBoxSize: [{ blockSize: 300, inlineSize: 300 }],
contentBoxSize: [{ blockSize: 300, inlineSize: 300 }],
devicePixelContentBoxSize: [{ blockSize: 300, inlineSize: 300 }],
target: wrapper,
},
]);
// Wait for resize to be processed
await new Promise(resolve => setTimeout(resolve, 200));
// Check dimensions
const wrapperInsert = container.querySelector('.wrapper-insert');
expect(wrapperInsert).not.toBeNull();
expect(wrapperInsert).toBeInTheDocument();
expect(wrapperInsert).toHaveTextContent('300x300');
await waitForDimensions(container, 300, 300);
document.body.removeChild(wrapper);
}, 30000);
});
});

View File

@@ -17,16 +17,11 @@
* under the License.
*/
import { ReactElement, ReactNode } from 'react';
import { mount } from 'enzyme';
import '@testing-library/jest-dom';
import { ReactElement } from 'react';
import mockConsole, { RestoreConsole } from 'jest-mock-console';
import {
ChartProps,
promiseTimeout,
supersetTheme,
SupersetTheme,
ThemeProvider,
} from '@superset-ui/core';
import { ChartProps, supersetTheme, ThemeProvider } from '@superset-ui/core';
import { render, screen, waitFor } from '@testing-library/react';
import SuperChartCore from '../../../src/chart/components/SuperChartCore';
import {
ChartKeys,
@@ -35,25 +30,11 @@ import {
SlowChartPlugin,
} from './MockChartPlugins';
const Wrapper = ({
theme,
children,
}: {
theme: SupersetTheme;
children: ReactNode;
}) => <ThemeProvider theme={theme}>{children}</ThemeProvider>;
const styledMount = (component: ReactElement) =>
mount(component, {
wrappingComponent: Wrapper,
wrappingComponentProps: {
theme: supersetTheme,
},
});
const renderWithTheme = (component: ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
describe('SuperChartCore', () => {
const chartProps = new ChartProps();
const plugins = [
new DiligentChartPlugin().configure({ key: ChartKeys.DILIGENT }),
new LazyChartPlugin().configure({ key: ChartKeys.LAZY }),
@@ -63,6 +44,7 @@ describe('SuperChartCore', () => {
let restoreConsole: RestoreConsole;
beforeAll(() => {
jest.setTimeout(30000);
plugins.forEach(p => {
p.unregister().register();
});
@@ -83,72 +65,83 @@ describe('SuperChartCore', () => {
});
describe('registered charts', () => {
it('renders registered chart', () => {
const wrapper = styledMount(
it('renders registered chart', async () => {
const { container } = renderWithTheme(
<SuperChartCore
chartType={ChartKeys.DILIGENT}
chartProps={chartProps}
/>,
);
return promiseTimeout(() => {
expect(wrapper.render().find('div.test-component')).toHaveLength(1);
await waitFor(() => {
expect(container.querySelector('.test-component')).toBeInTheDocument();
});
});
it('renders registered chart with lazy loading', () => {
const wrapper = styledMount(
it('renders registered chart with lazy loading', async () => {
const { container } = renderWithTheme(
<SuperChartCore chartType={ChartKeys.LAZY} />,
);
return promiseTimeout(() => {
expect(wrapper.render().find('div.test-component')).toHaveLength(1);
await waitFor(() => {
expect(container.querySelector('.test-component')).toBeInTheDocument();
});
});
it('does not render if chartType is not set', () => {
// Suppress warning
// @ts-ignore chartType is required
const wrapper = styledMount(<SuperChartCore />);
return promiseTimeout(() => {
expect(wrapper.render().children()).toHaveLength(0);
}, 5);
it('does not render if chartType is not set', async () => {
// @ts-ignore chartType is required
const { container } = renderWithTheme(<SuperChartCore />);
await waitFor(() => {
const testComponent = container.querySelector('.test-component');
expect(testComponent).not.toBeInTheDocument();
});
});
it('adds id to container if specified', () => {
const wrapper = styledMount(
it('adds id to container if specified', async () => {
const { container } = renderWithTheme(
<SuperChartCore chartType={ChartKeys.DILIGENT} id="the-chart" />,
);
return promiseTimeout(() => {
expect(wrapper.render().attr('id')).toEqual('the-chart');
await waitFor(() => {
const element = container.querySelector('#the-chart');
expect(element).toBeInTheDocument();
expect(element).toHaveAttribute('id', 'the-chart');
});
});
it('adds class to container if specified', () => {
const wrapper = styledMount(
it('adds class to container if specified', async () => {
const { container } = renderWithTheme(
<SuperChartCore chartType={ChartKeys.DILIGENT} className="the-chart" />,
);
return promiseTimeout(() => {
expect(wrapper.hasClass('the-chart')).toBeTruthy();
}, 0);
await waitFor(() => {
const element = container.querySelector('.the-chart');
expect(element).toBeInTheDocument();
expect(element).toHaveClass('the-chart');
});
});
it('uses overrideTransformProps when specified', () => {
const wrapper = styledMount(
it('uses overrideTransformProps when specified', async () => {
renderWithTheme(
<SuperChartCore
chartType={ChartKeys.DILIGENT}
overrideTransformProps={() => ({ message: 'hulk' })}
/>,
);
return promiseTimeout(() => {
expect(wrapper.render().find('.message').text()).toEqual('hulk');
await waitFor(() => {
expect(screen.getByText('hulk')).toBeInTheDocument();
});
});
it('uses preTransformProps when specified', () => {
it('uses preTransformProps when specified', async () => {
const chartPropsWithPayload = new ChartProps({
queriesData: [{ message: 'hulk' }],
theme: supersetTheme,
});
const wrapper = styledMount(
renderWithTheme(
<SuperChartCore
chartType={ChartKeys.DILIGENT}
preTransformProps={() => chartPropsWithPayload}
@@ -156,69 +149,77 @@ describe('SuperChartCore', () => {
/>,
);
return promiseTimeout(() => {
expect(wrapper.render().find('.message').text()).toEqual('hulk');
await waitFor(() => {
expect(screen.getByText('hulk')).toBeInTheDocument();
});
});
it('uses postTransformProps when specified', () => {
const wrapper = styledMount(
it('uses postTransformProps when specified', async () => {
renderWithTheme(
<SuperChartCore
chartType={ChartKeys.DILIGENT}
postTransformProps={() => ({ message: 'hulk' })}
/>,
);
return promiseTimeout(() => {
expect(wrapper.render().find('.message').text()).toEqual('hulk');
await waitFor(() => {
expect(screen.getByText('hulk')).toBeInTheDocument();
});
});
it('renders if chartProps is not specified', () => {
const wrapper = styledMount(
it('renders if chartProps is not specified', async () => {
const { container } = renderWithTheme(
<SuperChartCore chartType={ChartKeys.DILIGENT} />,
);
return promiseTimeout(() => {
expect(wrapper.render().find('div.test-component')).toHaveLength(1);
await waitFor(() => {
expect(container.querySelector('.test-component')).toBeInTheDocument();
});
});
it('does not render anything while waiting for Chart code to load', () => {
const wrapper = styledMount(
const { container } = renderWithTheme(
<SuperChartCore chartType={ChartKeys.SLOW} />,
);
return promiseTimeout(() => {
expect(wrapper.render().children()).toHaveLength(0);
});
const testComponent = container.querySelector('.test-component');
expect(testComponent).not.toBeInTheDocument();
});
it('eventually renders after Chart is loaded', () => {
// Suppress warning
const wrapper = styledMount(
it('eventually renders after Chart is loaded', async () => {
const { container } = renderWithTheme(
<SuperChartCore chartType={ChartKeys.SLOW} />,
);
return promiseTimeout(() => {
expect(wrapper.render().find('div.test-component')).toHaveLength(1);
}, 1500);
await waitFor(
() => {
expect(
container.querySelector('.test-component'),
).toBeInTheDocument();
},
{ timeout: 2000 },
);
});
it('does not render if chartProps is null', () => {
const wrapper = styledMount(
it('does not render if chartProps is null', async () => {
const { container } = renderWithTheme(
<SuperChartCore chartType={ChartKeys.DILIGENT} chartProps={null} />,
);
return promiseTimeout(() => {
expect(wrapper.render().find('div.test-component')).toHaveLength(0);
await waitFor(() => {
expect(container).toBeEmptyDOMElement();
});
});
});
describe('unregistered charts', () => {
it('renders error message', () => {
const wrapper = styledMount(
it('renders error message', async () => {
renderWithTheme(
<SuperChartCore chartType="4d-pie-chart" chartProps={chartProps} />,
);
return promiseTimeout(() => {
expect(wrapper.render().find('.alert')).toHaveLength(1);
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
});

View File

@@ -17,10 +17,11 @@
* under the License.
*/
import '@testing-library/jest-dom';
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { mount } from 'enzyme';
import { reactify } from '@superset-ui/core';
import { render, screen } from '@testing-library/react';
import { RenderFuncType } from '../../../src/chart/components/reactify';
describe('reactify(renderFn)', () => {
@@ -78,14 +79,18 @@ describe('reactify(renderFn)', () => {
it('returns a React component class', () =>
new Promise(done => {
const wrapper = mount(<TestComponent />);
render(<TestComponent />);
expect(renderFn).toHaveBeenCalledTimes(1);
expect(wrapper.html()).toEqual('<div id="test"><b>abc</b></div>');
expect(screen.getByText('abc')).toBeInTheDocument();
expect(screen.getByText('abc').parentNode).toHaveAttribute('id', 'test');
setTimeout(() => {
expect(renderFn).toHaveBeenCalledTimes(2);
expect(wrapper.html()).toEqual('<div id="test"><b>def</b></div>');
wrapper.unmount();
expect(screen.getByText('def')).toBeInTheDocument();
expect(screen.getByText('def').parentNode).toHaveAttribute(
'id',
'test',
);
done(undefined);
}, 20);
}));
@@ -119,8 +124,9 @@ describe('reactify(renderFn)', () => {
describe('defaultProps', () => {
it('has defaultProps if renderFn.defaultProps is defined', () => {
expect(TheChart.defaultProps).toBe(renderFn.defaultProps);
const wrapper = mount(<TheChart id="test" />);
expect(wrapper.html()).toEqual('<div id="test"><b>ghi</b></div>');
render(<TheChart id="test" />);
expect(screen.getByText('ghi')).toBeInTheDocument();
expect(screen.getByText('ghi').parentNode).toHaveAttribute('id', 'test');
});
it('does not have defaultProps if renderFn.defaultProps is not defined', () => {
const AnotherChart = reactify(() => {});
@@ -136,9 +142,9 @@ describe('reactify(renderFn)', () => {
});
it('calls willUnmount hook when it is provided', () =>
new Promise(done => {
const wrapper = mount(<AnotherTestComponent />);
const { unmount } = render(<AnotherTestComponent />);
setTimeout(() => {
wrapper.unmount();
unmount();
expect(willUnmountCb).toHaveBeenCalledTimes(1);
done(undefined);
}, 20);

View File

@@ -27,6 +27,7 @@ import {
ensureIsArray,
GenericDataType,
getCustomFormatter,
getMetricLabel,
getNumberFormatter,
getXAxisLabel,
isDefined,
@@ -291,12 +292,20 @@ export default function transformProps(
const showValueIndexesB = extractShowValueIndexes(rawSeriesB, {
stack,
});
const metricsLabels = metrics
.map(metric => getMetricLabel(metric, undefined, undefined, verboseMap))
.filter((label): label is string => label !== undefined);
const metricsLabelsB = metricsB.map((metric: QueryFormMetric) =>
getMetricLabel(metric, undefined, undefined, verboseMap),
);
const { totalStackedValues, thresholdValues } = extractDataTotalValues(
rebasedDataA,
{
stack,
percentageThreshold,
xAxisCol: xAxisLabel,
metricsLabels,
},
);
const {
@@ -305,7 +314,7 @@ export default function transformProps(
} = extractDataTotalValues(rebasedDataB, {
stack: Boolean(stackB),
percentageThreshold,
xAxisCol: xAxisLabel,
metricsLabels: metricsLabelsB,
});
annotationLayers

View File

@@ -215,14 +215,18 @@ export default function transformProps(
) {
xAxisLabel = verboseMap[xAxisLabel];
}
const metricsLabels = metrics
.map(metric => getMetricLabel(metric, undefined, undefined, verboseMap))
.filter((label): label is string => label !== undefined);
const isHorizontal = orientation === OrientationType.Horizontal;
const { totalStackedValues, thresholdValues } = extractDataTotalValues(
rebasedData,
{
stack,
percentageThreshold,
xAxisCol: xAxisLabel,
legendState,
metricsLabels,
},
);
const extraMetricLabels = extractExtraMetrics(chartProps.rawFormData).map(
@@ -296,7 +300,6 @@ export default function transformProps(
const entryName = String(entry.name || '');
const seriesName = inverted[entryName] || entryName;
const colorScaleKey = getOriginalSeries(seriesName, array);
const transformedSeries = transformSeries(
entry,
colorScale,
@@ -622,6 +625,7 @@ export default function transformProps(
theme,
zoomable,
legendState,
padding,
),
data: legendData as string[],
},

View File

@@ -230,7 +230,7 @@ const tooltipPercentageControl: ControlSetItem = {
type: 'CheckboxControl',
label: t('Show percentage'),
renderTrigger: true,
default: true,
default: false,
description: t('Whether to display the percentage value in the tooltip'),
visibility: ({ controls, form_data }: ControlPanelsContainerProps) =>
Boolean(controls?.rich_tooltip?.value) &&

View File

@@ -33,7 +33,7 @@ import {
TimeFormatter,
ValueFormatter,
} from '@superset-ui/core';
import { SortSeriesType } from '@superset-ui/chart-controls';
import { SortSeriesType, LegendPaddingType } from '@superset-ui/chart-controls';
import { format } from 'echarts/core';
import type { LegendComponentOption } from 'echarts/components';
import type { SeriesOption } from 'echarts';
@@ -60,8 +60,8 @@ export function extractDataTotalValues(
opts: {
stack: StackType;
percentageThreshold: number;
xAxisCol: string;
legendState?: LegendState;
metricsLabels: string[];
},
): {
totalStackedValues: number[];
@@ -69,11 +69,11 @@ export function extractDataTotalValues(
} {
const totalStackedValues: number[] = [];
const thresholdValues: number[] = [];
const { stack, percentageThreshold, xAxisCol, legendState } = opts;
const { stack, percentageThreshold, legendState, metricsLabels } = opts;
if (stack) {
data.forEach(datum => {
const values = Object.keys(datum).reduce((prev, curr) => {
if (curr === xAxisCol) {
if (!metricsLabels.includes(curr)) {
return prev;
}
if (legendState && !legendState[curr]) {
@@ -425,6 +425,7 @@ export function getLegendProps(
theme: SupersetTheme,
zoomable = false,
legendState?: LegendState,
padding?: LegendPaddingType,
): LegendComponentOption | LegendComponentOption[] {
const legend: LegendComponentOption | LegendComponentOption[] = {
orient: [LegendOrientation.Top, LegendOrientation.Bottom].includes(
@@ -443,13 +444,30 @@ export function getLegendProps(
borderColor: theme.colors.grayscale.base,
},
};
const MIN_LEGEND_WIDTH = 0;
const MARGIN_GUTTER = 45;
const getLegendWidth = (paddingWidth: number) =>
Math.max(paddingWidth - MARGIN_GUTTER, MIN_LEGEND_WIDTH);
switch (orientation) {
case LegendOrientation.Left:
legend.left = 0;
if (padding?.left) {
legend.textStyle = {
overflow: 'truncate',
width: getLegendWidth(padding.left),
};
}
break;
case LegendOrientation.Right:
legend.right = 0;
legend.top = zoomable ? TIMESERIES_CONSTANTS.legendRightTopOffset : 0;
if (padding?.right) {
legend.textStyle = {
overflow: 'truncate',
width: getLegendWidth(padding.right),
};
}
break;
case LegendOrientation.Bottom:
legend.bottom = 0;
@@ -467,7 +485,7 @@ export function getChartPadding(
show: boolean,
orientation: LegendOrientation,
margin?: string | number | null,
padding?: { top?: number; bottom?: number; left?: number; right?: number },
padding?: LegendPaddingType,
isHorizontal?: boolean,
): {
bottom: number;

View File

@@ -173,7 +173,7 @@ describe('BigNumberWithTrendline', () => {
label: 'value',
metric_name: 'value',
d3format: '.2f',
currency: `{symbol: 'USD', symbolPosition: 'prefix' }`,
currency: { symbol: 'USD', symbolPosition: 'prefix' },
},
],
},

View File

@@ -36,15 +36,25 @@ describe('EchartsTimeseries transformProps', () => {
colorScheme: 'bnbColors',
datasource: '3__table',
granularity_sqla: 'ds',
metric: 'sum__num',
metrics: ['sum__num'],
groupby: ['foo', 'bar'],
viz_type: 'my_viz',
};
const queriesData = [
{
data: [
{ 'San Francisco': 1, 'New York': 2, __timestamp: 599616000000 },
{ 'San Francisco': 3, 'New York': 4, __timestamp: 599916000000 },
{
'San Francisco': 1,
'New York': 2,
__timestamp: 599616000000,
sum__num: 4,
},
{
'San Francisco': 3,
'New York': 4,
__timestamp: 599916000000,
sum__num: 8,
},
],
},
];
@@ -64,7 +74,7 @@ describe('EchartsTimeseries transformProps', () => {
height: 600,
echartOptions: expect.objectContaining({
legend: expect.objectContaining({
data: ['San Francisco', 'New York'],
data: ['sum__num', 'San Francisco', 'New York'],
}),
series: expect.arrayContaining([
expect.objectContaining({
@@ -101,7 +111,7 @@ describe('EchartsTimeseries transformProps', () => {
height: 600,
echartOptions: expect.objectContaining({
legend: expect.objectContaining({
data: ['San Francisco', 'New York'],
data: ['sum__num', 'San Francisco', 'New York'],
}),
series: expect.arrayContaining([
expect.objectContaining({
@@ -146,7 +156,7 @@ describe('EchartsTimeseries transformProps', () => {
height: 600,
echartOptions: expect.objectContaining({
legend: expect.objectContaining({
data: ['San Francisco', 'New York', 'My Formula'],
data: ['sum__num', 'San Francisco', 'New York', 'My Formula'],
}),
series: expect.arrayContaining([
expect.objectContaining({
@@ -274,7 +284,7 @@ describe('EchartsTimeseries transformProps', () => {
expect.objectContaining({
echartOptions: expect.objectContaining({
legend: expect.objectContaining({
data: ['San Francisco', 'New York', 'My Line'],
data: ['sum__num', 'San Francisco', 'New York', 'My Line'],
}),
series: expect.arrayContaining([
expect.objectContaining({
@@ -420,7 +430,7 @@ describe('Does transformProps transform series correctly', () => {
colorScheme: 'bnbColors',
datasource: '3__table',
granularity_sqla: 'ds',
metric: 'sum__num',
metrics: ['sum__num'],
groupby: ['foo', 'bar'],
showValue: true,
stack: true,
@@ -435,24 +445,28 @@ describe('Does transformProps transform series correctly', () => {
'New York': 2,
Boston: 1,
__timestamp: 599616000000,
sum__num: 4,
},
{
'San Francisco': 3,
'New York': 4,
Boston: 1,
__timestamp: 599916000000,
sum__num: 8,
},
{
'San Francisco': 5,
'New York': 8,
Boston: 6,
__timestamp: 600216000000,
sum__num: 19,
},
{
'San Francisco': 2,
'New York': 7,
Boston: 2,
__timestamp: 600516000000,
sum__num: 11,
},
],
},
@@ -468,7 +482,7 @@ describe('Does transformProps transform series correctly', () => {
const totalStackedValues = queriesData[0].data.reduce(
(totals, currentStack) => {
const total = Object.keys(currentStack).reduce((stackSum, key) => {
if (key === '__timestamp') return stackSum;
if (key === '__timestamp' || key === 'sum__num') return stackSum;
return stackSum + currentStack[key as keyof typeof currentStack];
}, 0);
totals.push(total);
@@ -561,7 +575,6 @@ describe('Does transformProps transform series correctly', () => {
const expectedThresholds = totalStackedValues.map(
total => ((formData.percentageThreshold || 0) / 100) * total,
);
transformedSeries.forEach((series, seriesIndex) => {
expect(series.label.show).toBe(true);
series.data.forEach((value, dataIndex) => {
@@ -576,7 +589,6 @@ describe('Does transformProps transform series correctly', () => {
});
});
});
it('should not apply percentage threshold when showValue is true and stack is false', () => {
const updatedChartPropsConfig = {
...chartPropsConfig,

View File

@@ -29,6 +29,7 @@ import {
calculateLowerLogTick,
dedupSeries,
extractGroupbyLabel,
extractDataTotalValues,
extractSeries,
extractShowValueIndexes,
extractTooltipKeys,
@@ -1085,6 +1086,123 @@ const forecastValue = [
},
];
describe('extractDataTotalValues', () => {
it('test_extractDataTotalValues_withStack', () => {
const data: DataRecord[] = [
{ metric1: 10, metric2: 20, xAxisCol: '2021-01-01' },
{ metric1: 15, metric2: 25, xAxisCol: '2021-01-02' },
];
const metricsLabels = ['metric1', 'metric2'];
const opts = {
stack: true,
percentageThreshold: 10,
metricsLabels,
};
const result = extractDataTotalValues(data, opts);
expect(result.totalStackedValues).toEqual([30, 40]);
expect(result.thresholdValues).toEqual([3, 4]);
});
it('should calculate total and threshold values with stack option enabled', () => {
const data: DataRecord[] = [
{ metric1: 10, metric2: 20, xAxisCol: '2021-01-01' },
{ metric1: 15, metric2: 25, xAxisCol: '2021-01-02' },
];
const metricsLabels = ['metric1', 'metric2'];
const opts = {
stack: true,
percentageThreshold: 10,
metricsLabels,
};
const result = extractDataTotalValues(data, opts);
expect(result.totalStackedValues).toEqual([30, 40]);
expect(result.thresholdValues).toEqual([3, 4]);
});
it('should handle empty data array', () => {
const data: DataRecord[] = [];
const metricsLabels: string[] = [];
const opts = {
stack: true,
percentageThreshold: 10,
metricsLabels,
};
const result = extractDataTotalValues(data, opts);
expect(result.totalStackedValues).toEqual([]);
expect(result.thresholdValues).toEqual([]);
});
it('should calculate total and threshold values with stack option disabled', () => {
const data: DataRecord[] = [
{ metric1: 10, metric2: 20, xAxisCol: '2021-01-01' },
{ metric1: 15, metric2: 25, xAxisCol: '2021-01-02' },
];
const metricsLabels = ['metric1', 'metric2'];
const opts = {
stack: false,
percentageThreshold: 10,
metricsLabels,
};
const result = extractDataTotalValues(data, opts);
expect(result.totalStackedValues).toEqual([]);
expect(result.thresholdValues).toEqual([]);
});
it('should handle data with null or undefined values', () => {
const data: DataRecord[] = [
{ my_x_axis: 'abc', x: 1, y: 0, z: 2 },
{ my_x_axis: 'foo', x: null, y: 10, z: 5 },
{ my_x_axis: null, x: 4, y: 3, z: 7 },
];
const metricsLabels = ['x', 'y', 'z'];
const opts = {
stack: true,
percentageThreshold: 10,
metricsLabels,
};
const result = extractDataTotalValues(data, opts);
expect(result.totalStackedValues).toEqual([3, 15, 14]);
expect(result.thresholdValues).toEqual([
0.30000000000000004, 1.5, 1.4000000000000001,
]);
});
it('should handle different percentage thresholds', () => {
const data: DataRecord[] = [
{ metric1: 10, metric2: 20, xAxisCol: '2021-01-01' },
{ metric1: 15, metric2: 25, xAxisCol: '2021-01-02' },
];
const metricsLabels = ['metric1', 'metric2'];
const opts = {
stack: true,
percentageThreshold: 50,
metricsLabels,
};
const result = extractDataTotalValues(data, opts);
expect(result.totalStackedValues).toEqual([30, 40]);
expect(result.thresholdValues).toEqual([15, 20]);
});
it('should not add datum not in metrics to the total value when stacked', () => {
const data: DataRecord[] = [
{ xAxisCol: 'foo', xAxisSort: 10, val: 345 },
{ xAxisCol: 'bar', xAxisSort: 20, val: 2432 },
{ xAxisCol: 'baz', xAxisSort: 30, val: 4543 },
];
const metricsLabels = ['val'];
const opts = {
stack: true,
percentageThreshold: 50,
metricsLabels,
};
const result = extractDataTotalValues(data, opts);
// Assuming extractDataTotalValues returns the total value
// without including the 'xAxisCol' category
expect(result.totalStackedValues).toEqual([345, 2432, 4543]); // 10 + 20, excluding the 'xAxisCol' category
});
});
test('extractTooltipKeys with rich tooltip', () => {
const result = extractTooltipKeys(forecastValue, 1, true, false);
expect(result).toEqual(['foo', 'bar']);

View File

@@ -73,3 +73,26 @@ more details.
└── types
└── external.d.ts
```
### Available Handlebars Helpers in Superset
Below, you will find a list of all currently registered helpers in the Handlebars plugin for Superset. These helpers are registered and managed in the file [`HandlebarsViewer.tsx`](./path/to/HandlebarsViewer.tsx).
#### List of Registered Helpers:
1. **`dateFormat`**: Formats a date using a specified format.
- **Usage**: `{{dateFormat my_date format="MMMM YYYY"}}`
- **Default format**: `YYYY-MM-DD`.
2. **`stringify`**: Converts an object into a JSON string or returns a string representation of non-object values.
- **Usage**: `{{stringify myObj}}`.
3. **`formatNumber`**: Formats a number using locale-specific formatting.
- **Usage**: `{{formatNumber number locale="en-US"}}`.
- **Default locale**: `en-US`.
4. **`parseJson`**: Parses a JSON string into a JavaScript object.
- **Usage**: `{{parseJson jsonString}}`.

View File

@@ -99,5 +99,18 @@ Handlebars.registerHelper(
},
);
// usage: {{parseJson jsonString}}
Handlebars.registerHelper('parseJson', (jsonString: string) => {
try {
return JSON.parse(jsonString);
} catch (error) {
if (error instanceof Error) {
error.message = `Invalid JSON string: ${error.message}`;
throw error;
}
throw new Error(`Invalid JSON string: ${String(error)}`);
}
});
Helpers.registerHelpers(Handlebars);
HandlebarsGroupBy.register(Handlebars);

View File

@@ -20,8 +20,9 @@ import {
ControlSetItem,
CustomControlConfig,
sharedControls,
InfoTooltipWithTrigger,
} from '@superset-ui/chart-controls';
import { t, validateNonEmpty } from '@superset-ui/core';
import { t, validateNonEmpty, useTheme, SafeMarkdown } from '@superset-ui/core';
import { CodeEditor } from '../../components/CodeEditor/CodeEditor';
import { ControlHeader } from '../../components/ControlHeader/controlHeader';
import { debounceFunc } from '../../consts';
@@ -33,13 +34,48 @@ interface HandlebarsCustomControlProps {
const HandlebarsTemplateControl = (
props: CustomControlConfig<HandlebarsCustomControlProps>,
) => {
const theme = useTheme();
const val = String(
props?.value ? props?.value : props?.default ? props?.default : '',
);
const helperDescriptionsHeader = t(
'Available Handlebars Helpers in Superset:',
);
const helperDescriptions = [
{ key: 'dateFormat', descKey: 'Formats a date using a specified format.' },
{ key: 'stringify', descKey: 'Converts an object to a JSON string.' },
{
key: 'formatNumber',
descKey: 'Formats a number using locale-specific formatting.',
},
{
key: 'parseJson',
descKey: 'Parses a JSON string into a JavaScript object.',
},
];
const helpersTooltipContent = `
${helperDescriptionsHeader}
${helperDescriptions
.map(({ key, descKey }) => `- **${key}**: ${t(descKey)}`)
.join('\n')}
`;
return (
<div>
<ControlHeader>{props.label}</ControlHeader>
<ControlHeader>
<div>
{props.label}
<InfoTooltipWithTrigger
iconsStyle={{ marginLeft: theme.gridUnit }}
tooltip={<SafeMarkdown source={helpersTooltipContent} />}
/>
</div>
</ControlHeader>
<CodeEditor
theme="dark"
value={val}
@@ -65,6 +101,7 @@ export const handlebarsTemplateControlSetItem: ControlSetItem = {
</ul>`,
isInt: false,
renderTrigger: true,
valueKey: null,
validators: [validateNonEmpty],
mapStateToProps: ({ controls }) => ({

View File

@@ -75,6 +75,7 @@ export const styleControlSetItem: ControlSetItem = {
description: t('CSS applied to the chart'),
isInt: false,
renderTrigger: true,
valueKey: null,
validators: [],
mapStateToProps: ({ controls }) => ({

View File

@@ -20,7 +20,7 @@ import { AriaAttributes } from 'react';
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only';
import 'jest-enzyme';
import 'enzyme-matchers';
import jQuery from 'jquery';
import Enzyme from 'enzyme';
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';

View File

@@ -109,6 +109,7 @@ export function sleep(time: number) {
export * from '@testing-library/react';
export { customRender as render };
export { default as userEvent } from '@testing-library/user-event';
export async function selectOption(option: string, selectName?: string) {
const select = screen.getByRole(

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { shallow as enzymeShallow, mount as enzymeMount } from 'enzyme';
import { mount as enzymeMount } from 'enzyme';
// eslint-disable-next-line no-restricted-imports
import { supersetTheme } from '@superset-ui/core';
import { ReactElement } from 'react';
@@ -26,12 +26,13 @@ type optionsType = {
wrappingComponentProps?: any;
wrappingComponent?: ReactElement;
context?: any;
newOption?: string;
};
export function styledMount(
component: ReactElement,
options: optionsType = {},
) {
): any {
return enzymeMount(component, {
...options,
wrappingComponent: ProviderWrapper,
@@ -41,17 +42,3 @@ export function styledMount(
},
});
}
export function styledShallow(
component: ReactElement,
options: optionsType = {},
) {
return enzymeShallow(component, {
...options,
wrappingComponent: ProviderWrapper,
wrappingComponentProps: {
theme: supersetTheme,
...options?.wrappingComponentProps,
},
});
}

View File

@@ -39,16 +39,18 @@ export const GlobalStyles = () => (
.echarts-tooltip[style*='visibility: hidden'] {
display: none !important;
}
// Ant Design is applying inline z-index styles causing troubles
// TODO: Remove z-indexes when Ant Design is fully upgraded to v5
// Prefer vanilla Ant Design z-indexes that should work out of the box
.ant-popover,
.antd5-dropdown,
.ant-dropdown,
.ant-select-dropdown,
.antd5-modal-wrap,
.antd5-modal-mask,
.antd5-picker-dropdown {
.antd5-picker-dropdown,
.ant-popover,
.antd5-popover {
z-index: ${theme.zIndex.max} !important;
}
@@ -105,13 +107,6 @@ export const GlobalStyles = () => (
margin-right: 0;
}
}
.ant-dropdown-menu-sub .antd5-menu.antd5-menu-vertical {
box-shadow: none;
}
.ant-dropdown-menu-submenu-title,
.ant-dropdown-menu-item {
line-height: 1.5em !important;
}
`}
/>
);

View File

@@ -20,7 +20,7 @@ import sinon from 'sinon';
import fetchMock from 'fetch-mock';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { waitFor } from '@testing-library/react';
import { waitFor } from 'spec/helpers/testing-library';
import * as actions from 'src/SqlLab/actions/sqlLab';
import { LOG_EVENT } from 'src/logger/actions';
import {

View File

@@ -20,8 +20,12 @@ import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { Store } from 'redux';
import { render, fireEvent, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import {
fireEvent,
render,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { initialState, defaultQueryEditor } from 'src/SqlLab/fixtures';
import QueryLimitSelect, {
QueryLimitSelectProps,

View File

@@ -17,12 +17,13 @@
* under the License.
*/
import { useDispatch } from 'react-redux';
import { styled, useTheme, t } from '@superset-ui/core';
import { AntdDropdown } from 'src/components';
import { useTheme, t } from '@superset-ui/core';
import { Dropdown } from 'src/components/Dropdown';
import { Menu } from 'src/components/Menu';
import Icons from 'src/components/Icons';
import { queryEditorSetQueryLimit } from 'src/SqlLab/actions/sqlLab';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
import Button from 'src/components/Button';
export interface QueryLimitSelectProps {
queryEditorId: string;
@@ -34,28 +35,6 @@ export function convertToNumWithSpaces(num: number) {
return num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1 ');
}
const LimitSelectStyled = styled.span`
${({ theme }) => `
.ant-dropdown-trigger {
align-items: center;
color: ${theme.colors.grayscale.dark2};
display: flex;
font-size: 12px;
margin-right: ${theme.gridUnit * 2}px;
text-decoration: none;
border: 0;
background: transparent;
span {
display: inline-block;
margin-right: ${theme.gridUnit * 2}px;
&:last-of-type: {
margin-right: ${theme.gridUnit * 4}px;
}
}
}
`}
`;
function renderQueryLimit(
maxRow: number,
setQueryLimit: (limit: number) => void,
@@ -94,20 +73,18 @@ const QueryLimitSelect = ({
dispatch(queryEditorSetQueryLimit(queryEditor, updatedQueryLimit));
return (
<LimitSelectStyled>
<AntdDropdown
overlay={renderQueryLimit(maxRow, setQueryLimit)}
trigger={['click']}
>
<button type="button" onClick={e => e.preventDefault()}>
<span>{t('LIMIT')}:</span>
<span className="limitDropdown">
{convertToNumWithSpaces(queryLimit)}
</span>
<Icons.TriangleDown iconColor={theme.colors.grayscale.base} />
</button>
</AntdDropdown>
</LimitSelectStyled>
<Dropdown
dropdownRender={() => renderQueryLimit(maxRow, setQueryLimit)}
trigger={['click']}
>
<Button size="small" showMarginRight={false} type="link">
<span>{t('LIMIT')}:</span>
<span className="limitDropdown">
{convertToNumWithSpaces(queryLimit)}
</span>
<Icons.TriangleDown iconColor={theme.colors.grayscale.base} />
</Button>
</Dropdown>
);
};

View File

@@ -20,7 +20,6 @@ import { isValidElement } from 'react';
import thunk from 'redux-thunk';
import configureStore from 'redux-mock-store';
import QueryTable from 'src/SqlLab/components/QueryTable';
import { Provider } from 'react-redux';
import { runningQuery, successfulQuery, user } from 'src/SqlLab/fixtures';
import { render, screen } from 'spec/helpers/testing-library';
@@ -29,27 +28,55 @@ const mockedProps = {
displayLimit: 100,
latestQueryId: 'ryhMUZCGb',
};
test('is valid', () => {
expect(isValidElement(<QueryTable displayLimit={100} />)).toBe(true);
});
test('is valid with props', () => {
expect(isValidElement(<QueryTable {...mockedProps} />)).toBe(true);
});
test('renders a proper table', () => {
const mockStore = configureStore([thunk]);
const store = mockStore({
user,
describe('QueryTable', () => {
test('is valid', () => {
expect(isValidElement(<QueryTable displayLimit={100} />)).toBe(true);
});
const { container } = render(
<Provider store={store}>
<QueryTable {...mockedProps} />
</Provider>,
);
test('is valid with props', () => {
expect(isValidElement(<QueryTable {...mockedProps} />)).toBe(true);
});
expect(screen.getByTestId('listview-table')).toBeVisible(); // Presence of TableCollection
expect(screen.getByRole('table')).toBeVisible();
expect(container.querySelector('.table-condensed')).toBeVisible(); // Presence of TableView signature class
expect(container.querySelectorAll('table > thead > tr')).toHaveLength(1);
expect(container.querySelectorAll('table > tbody > tr')).toHaveLength(2);
test('renders a proper table', () => {
const mockStore = configureStore([thunk]);
const { container } = render(<QueryTable {...mockedProps} />, {
store: mockStore({ user }),
});
expect(screen.getByTestId('listview-table')).toBeVisible();
expect(screen.getByRole('table')).toBeVisible();
expect(container.querySelector('.table-condensed')).toBeVisible();
expect(container.querySelectorAll('table > thead > tr')).toHaveLength(1);
expect(container.querySelectorAll('table > tbody > tr')).toHaveLength(2);
});
test('renders empty table when no queries provided', () => {
const mockStore = configureStore([thunk]);
const { container } = render(
<QueryTable {...{ ...mockedProps, queries: [] }} />,
{ store: mockStore({ user }) },
);
expect(screen.getByTestId('listview-table')).toBeVisible();
expect(screen.getByRole('table')).toBeVisible();
expect(container.querySelector('.table-condensed')).toBeVisible();
expect(container.querySelectorAll('table > thead > tr')).toHaveLength(1);
expect(container.querySelectorAll('table > tbody > tr')).toHaveLength(0);
});
test('renders with custom displayLimit', () => {
const mockStore = configureStore([thunk]);
const customProps = {
...mockedProps,
displayLimit: 1,
queries: [runningQuery], // Modify to only include one query
};
const { container } = render(<QueryTable {...customProps} />, {
store: mockStore({ user }),
});
expect(screen.getByTestId('listview-table')).toBeVisible();
expect(container.querySelectorAll('table > tbody > tr')).toHaveLength(1);
});
});

View File

@@ -143,6 +143,13 @@ const setup = (props?: any, store?: Store) =>
});
describe('ResultSet', () => {
// Add cleanup after each test
afterEach(async () => {
fetchMock.resetHistory();
// Wait for any pending effects to complete
await new Promise(resolve => setTimeout(resolve, 0));
});
test('renders a Table', async () => {
const { getByTestId } = setup(
mockedProps,
@@ -157,8 +164,10 @@ describe('ResultSet', () => {
},
}),
);
const table = getByTestId('table-container');
expect(table).toBeInTheDocument();
await waitFor(() => {
const table = getByTestId('table-container');
expect(table).toBeInTheDocument();
});
});
test('should render success query', async () => {
@@ -245,7 +254,7 @@ describe('ResultSet', () => {
await waitFor(() =>
expect(fetchMock.calls(reRunQueryEndpoint)).toHaveLength(1),
);
});
}, 10000);
test('should not call reRunQuery if no error', async () => {
const query = queries[0];
@@ -508,13 +517,22 @@ describe('ResultSet', () => {
},
}),
);
await waitFor(() => {
const downloadButton = getByTestId('export-csv-button');
expect(downloadButton).toBeInTheDocument();
});
const downloadButton = getByTestId('export-csv-button');
fireEvent.click(downloadButton);
await waitFor(() => fireEvent.click(downloadButton));
const warningModal = await findByRole('dialog');
expect(
within(warningModal).getByText(`Download is on the way`),
).toBeInTheDocument();
});
await waitFor(() => {
expect(
within(warningModal).getByText(`Download is on the way`),
).toBeInTheDocument();
});
}, 20000);
test('should not allow download as CSV when user does not have permission to export data', async () => {
const { queryByTestId } = setup(

View File

@@ -16,8 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { render, screen, userEvent } from 'spec/helpers/testing-library';
import { Menu } from 'src/components/Menu';
import SaveDatasetActionButton from 'src/SqlLab/components/SaveDatasetActionButton';

View File

@@ -16,12 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
import { FC } from 'react';
import { t, useTheme, styled } from '@superset-ui/core';
import { t, useTheme } from '@superset-ui/core';
import Icons from 'src/components/Icons';
import { DropdownButton } from 'src/components/DropdownButton';
import Button from 'src/components/Button';
import { DropdownButtonProps } from 'antd/lib/dropdown';
interface SaveDatasetActionButtonProps {
setShowSave: (arg0: boolean) => void;
@@ -34,34 +32,14 @@ const SaveDatasetActionButton = ({
}: SaveDatasetActionButtonProps) => {
const theme = useTheme();
const StyledDropdownButton = styled(
DropdownButton as FC<DropdownButtonProps>,
)`
&.ant-dropdown-button button.ant-btn.ant-btn-default {
font-weight: ${theme.gridUnit * 150};
background-color: ${theme.colors.primary.light4};
color: ${theme.colors.primary.dark1};
&:nth-of-type(2) {
&:before,
&:hover:before {
border-left: 2px solid ${theme.colors.primary.dark2};
}
}
}
span[name='caret-down'] {
margin-left: ${theme.gridUnit * 1}px;
color: ${theme.colors.primary.dark2};
}
`;
return !overlayMenu ? (
<Button onClick={() => setShowSave(true)} buttonStyle="primary">
{t('Save')}
</Button>
) : (
<StyledDropdownButton
<DropdownButton
onClick={() => setShowSave(true)}
overlay={overlayMenu}
dropdownRender={() => overlayMenu}
icon={
<Icons.CaretDown
iconColor={theme.colors.grayscale.light5}
@@ -71,7 +49,7 @@ const SaveDatasetActionButton = ({
trigger={['click']}
>
{t('Save')}
</StyledDropdownButton>
</DropdownButton>
);
};

View File

@@ -18,13 +18,13 @@
*/
import * as reactRedux from 'react-redux';
import {
cleanup,
fireEvent,
render,
screen,
cleanup,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
import { createDatasource } from 'src/SqlLab/actions/sqlLab';

View File

@@ -96,32 +96,36 @@ interface SaveDatasetModalProps {
}
const Styles = styled.div`
${({ theme }) => `
.sdm-body {
margin: 0 8px;
margin: 0 ${theme.gridUnit * 2}px;
}
.sdm-input {
margin-left: 45px;
margin-left: ${theme.gridUnit * 10}px;
width: 401px;
}
.sdm-autocomplete {
width: 401px;
align-self: center;
margin-left: ${theme.gridUnit}px;
}
.sdm-radio {
display: block;
height: 30px;
margin: 10px 0px;
line-height: 30px;
}
.sdm-radio span {
display: inline-flex;
padding-right: 0px;
}
.sdm-overwrite-msg {
margin: 7px;
margin: ${theme.gridUnit * 2}px;
}
.sdm-overwrite-container {
flex: 1 1 auto;
display: flex;
}
`}
`;
const updateDataset = async (
dbId: number,
datasetId: number,

View File

@@ -18,8 +18,12 @@
*/
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import {
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import SaveQuery from 'src/SqlLab/components/SaveQuery';
import { initialState, databases } from 'src/SqlLab/fixtures';

View File

@@ -26,9 +26,13 @@ import {
ThemeProvider,
isFeatureEnabled,
} from '@superset-ui/core';
import { render, screen, act, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import userEvent from '@testing-library/user-event';
import {
render,
screen,
act,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import ShareSqlLabQuery from 'src/SqlLab/components/ShareSqlLabQuery';
import { initialState } from 'src/SqlLab/fixtures';
@@ -133,7 +137,7 @@ describe('ShareSqlLabQuery', () => {
});
});
const button = screen.getByRole('button');
const { id, remoteId, ...expected } = mockQueryEditor;
const { id: _id, remoteId: _remoteId, ...expected } = mockQueryEditor;
userEvent.click(button);
await waitFor(() =>
expect(fetchMock.calls(storeQueryUrl)).toHaveLength(1),
@@ -150,7 +154,7 @@ describe('ShareSqlLabQuery', () => {
});
});
const button = screen.getByRole('button');
const { id, ...expected } = unsavedQueryEditor;
const { id: _id, ...expected } = unsavedQueryEditor;
userEvent.click(button);
await waitFor(() =>
expect(fetchMock.calls(storeQueryUrl)).toHaveLength(1),

View File

@@ -16,12 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render } from 'spec/helpers/testing-library';
import { render, waitFor, within } from 'spec/helpers/testing-library';
import SouthPane from 'src/SqlLab/components/SouthPane';
import '@testing-library/jest-dom';
import { STATUS_OPTIONS } from 'src/SqlLab/constants';
import { initialState, table, defaultQueryEditor } from 'src/SqlLab/fixtures';
import { denormalizeTimestamp } from '@superset-ui/core';
import userEvent from '@testing-library/user-event';
const mockedProps = {
queryEditorId: defaultQueryEditor.id,
@@ -49,12 +49,14 @@ const mockState = {
tables: [
{
...table,
id: 't3',
name: 'table3',
dataPreviewQueryId: '2g2_iRFMl',
queryEditorId: defaultQueryEditor.id,
},
{
...table,
id: 't4',
name: 'table4',
dataPreviewQueryId: 'erWdqEWPm',
queryEditorId: defaultQueryEditor.id,
@@ -149,3 +151,22 @@ test('should render tabs for table metadata view', () => {
expect(tabs[index + 2]).toHaveTextContent(`${schema}.${name}`);
});
});
test('should remove tab', async () => {
const { getAllByRole } = await render(<SouthPane {...mockedProps} />, {
useRedux: true,
initialState: mockState,
});
const tabs = getAllByRole('tab');
const totalTabs = mockState.sqlLab.tables.length + 2;
expect(tabs).toHaveLength(totalTabs);
const removeButton = within(tabs[2].parentElement as HTMLElement).getByRole(
'button',
{
name: /remove/,
},
);
userEvent.click(removeButton);
await waitFor(() => expect(getAllByRole('tab')).toHaveLength(totalTabs - 1));
});

View File

@@ -136,7 +136,7 @@ const SouthPane = ({
dispatch(removeTables([table]));
}
},
[dispatch, queryEditorId],
[dispatch, pinnedTables],
);
return offline ? (

View File

@@ -17,13 +17,18 @@
* under the License.
*/
import { FocusEventHandler } from 'react';
import { act } from 'react-dom/test-utils';
import {
isFeatureEnabled,
getExtensionsRegistry,
FeatureFlag,
} from '@superset-ui/core';
import { fireEvent, render, waitFor } from 'spec/helpers/testing-library';
import {
act,
cleanup,
fireEvent,
render,
waitFor,
} from 'spec/helpers/testing-library';
import fetchMock from 'fetch-mock';
import reducers from 'spec/helpers/reducerIndex';
import { setupStore } from 'src/views/store';
@@ -135,6 +140,15 @@ const createStore = (initState: object) =>
});
describe('SqlEditor', () => {
beforeAll(() => {
jest.setTimeout(30000);
});
afterEach(async () => {
cleanup();
await new Promise(resolve => setTimeout(resolve, 0));
});
const mockedProps = {
queryEditor: initialState.sqlLab.queryEditors[0],
tables: [table],
@@ -187,16 +201,27 @@ describe('SqlEditor', () => {
});
it('render a SqlEditorLeftBar', async () => {
const { getByTestId } = setup(mockedProps, store);
await waitFor(() =>
expect(getByTestId('mock-sql-editor-left-bar')).toBeInTheDocument(),
);
});
const { getByTestId, unmount } = setup(mockedProps, store);
await waitFor(
() => expect(getByTestId('mock-sql-editor-left-bar')).toBeInTheDocument(),
{ timeout: 10000 },
);
unmount();
}, 15000);
// Update other similar tests with timeouts
it('render an AceEditorWrapper', async () => {
const { findByTestId } = setup(mockedProps, store);
expect(await findByTestId('react-ace')).toBeInTheDocument();
});
const { findByTestId, unmount } = setup(mockedProps, store);
await waitFor(
() => expect(findByTestId('react-ace')).resolves.toBeInTheDocument(),
{ timeout: 10000 },
);
unmount();
}, 15000);
it('skip rendering an AceEditorWrapper when the current tab is inactive', async () => {
const { findByTestId, queryByTestId } = setup(

View File

@@ -56,7 +56,8 @@ import Mousetrap from 'mousetrap';
import Button from 'src/components/Button';
import Timer from 'src/components/Timer';
import ResizableSidebar from 'src/components/ResizableSidebar';
import { AntdDropdown, Skeleton } from 'src/components';
import { Dropdown } from 'src/components/Dropdown';
import { Skeleton } from 'src/components';
import { Switch } from 'src/components/Switch';
import { Input } from 'src/components/Input';
import { Menu } from 'src/components/Menu';
@@ -868,9 +869,14 @@ const SqlEditor: FC<Props> = ({
<span>
<ShareSqlLabQuery queryEditorId={queryEditor.id} />
</span>
<AntdDropdown overlay={renderDropdown()} trigger={['click']}>
<Icons.MoreHoriz iconColor={theme.colors.grayscale.base} />
</AntdDropdown>
<Dropdown
dropdownRender={() => renderDropdown()}
trigger={['click']}
>
<Button buttonSize="xsmall" type="link" showMarginRight={false}>
<Icons.MoreHoriz iconColor={theme.colors.grayscale.base} />
</Button>
</Dropdown>
</div>
</>
)}

View File

@@ -17,8 +17,13 @@
* under the License.
*/
import fetchMock from 'fetch-mock';
import { render, screen, waitFor, within } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import {
render,
screen,
userEvent,
waitFor,
within,
} from 'spec/helpers/testing-library';
import SqlEditorLeftBar, {
SqlEditorLeftBarProps,
} from 'src/SqlLab/components/SqlEditorLeftBar';

View File

@@ -22,9 +22,9 @@ import {
fireEvent,
screen,
render,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { QueryEditor } from 'src/SqlLab/types';
import {
initialState,

View File

@@ -20,7 +20,7 @@ import { useMemo, FC } from 'react';
import { bindActionCreators } from 'redux';
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
import { Dropdown } from 'src/components/Dropdown';
import { MenuDotsDropdown } from 'src/components/Dropdown';
import { Menu } from 'src/components/Menu';
import { styled, t, QueryState } from '@superset-ui/core';
import {
@@ -88,10 +88,10 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
return (
<TabTitleWrapper>
<Dropdown
<MenuDotsDropdown
trigger={['click']}
overlay={
<Menu style={{ width: 176 }}>
<Menu>
<Menu.Item
className="close-btn"
key="1"

View File

@@ -122,7 +122,7 @@ test('fades table', async () => {
'1',
),
);
});
}, 10000);
test('sorts columns', async () => {
const { getAllByTestId, getByText } = render(

View File

@@ -30,11 +30,8 @@ import {
import AutoSizer from 'react-virtualized-auto-sizer';
import Icons from 'src/components/Icons';
import type { SqlLabRootState } from 'src/SqlLab/types';
import {
Skeleton,
AntdBreadcrumb as Breadcrumb,
AntdDropdown,
} from 'src/components';
import { Skeleton, AntdBreadcrumb as Breadcrumb, Button } from 'src/components';
import { Dropdown } from 'src/components/Dropdown';
import FilterableTable from 'src/components/FilterableTable';
import Tabs from 'src/components/Tabs';
import {
@@ -308,8 +305,8 @@ const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
<Title>
<Icons.Table iconSize="l" />
{tableName}
<AntdDropdown
overlay={
<Dropdown
dropdownRender={() => (
<Menu
onClick={({ key }) => {
if (key === 'refresh-table') {
@@ -324,15 +321,17 @@ const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
}}
items={dropdownMenu}
/>
}
)}
trigger={['click']}
>
<Icons.DownSquareOutlined
iconSize="m"
style={{ marginTop: 2, marginLeft: 4 }}
aria-label={t('Table actions')}
/>
</AntdDropdown>
<Button buttonSize="xsmall" type="link">
<Icons.DownSquareOutlined
iconSize="m"
style={{ marginTop: 2, marginLeft: 4 }}
aria-label={t('Table actions')}
/>
</Button>
</Dropdown>
</Title>
{isMetadataRefreshing ? (
<Skeleton active />

View File

@@ -16,8 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import {
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import Alert, { AlertProps } from 'src/components/Alert';
type AlertType = Pick<AlertProps, 'type'>;

View File

@@ -16,9 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import '@testing-library/jest-dom';
import { render, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { render, screen, userEvent } from 'spec/helpers/testing-library';
import AlteredSliceTag, {
alterForComparison,
formatValueHandler,

View File

@@ -16,9 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import '@testing-library/jest-dom';
import userEvent from '@testing-library/user-event';
import {
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { ModifiedInfo } from '.';
@@ -40,7 +43,7 @@ test('should render a tooltip when user is provided', async () => {
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toBeInTheDocument();
expect(screen.getByText('Modified by: Foo Bar')).toBeInTheDocument();
});
}, 10000);
test('should render only the date if username is not provided', async () => {
render(<ModifiedInfo date={TEST_DATE} />);

View File

@@ -67,7 +67,7 @@ const decideType = (buttonStyle: ButtonStyle) => {
success: 'primary',
secondary: 'default',
default: 'default',
tertiary: 'dashed',
tertiary: 'default',
dashed: 'dashed',
link: 'link',
};

View File

@@ -16,10 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render } from 'spec/helpers/testing-library';
import { render, waitFor } from 'spec/helpers/testing-library';
import Card from '.';
test('should render', () => {
const { container } = render(<Card />);
expect(container).toBeInTheDocument();
afterEach(async () => {
// Wait for any pending effects to complete
await new Promise(resolve => setTimeout(resolve, 0));
});
test('should render', async () => {
const { container } = render(<Card />);
await waitFor(() => {
expect(container).toBeInTheDocument();
});
});

View File

@@ -16,8 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import {
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import CertifiedBadge, {
CertifiedBadgeProps,
} from 'src/components/CertifiedBadge';

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