Compare commits

..

1 Commits

Author SHA1 Message Date
Sam Firke
120a92728e add two new options for boxplot percentiles 2025-01-31 15:34:59 -05:00
422 changed files with 9322 additions and 8093 deletions

View File

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

View File

@@ -50,45 +50,17 @@ jobs:
echo "result=up" >> $GITHUB_OUTPUT
else
echo "result=noop" >> $GITHUB_OUTPUT
exit 1
fi
- name: Get event SHA
id: get-sha
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);
run: |
echo "sha=${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT
- 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
@@ -109,7 +81,6 @@ 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: |
@@ -190,9 +161,8 @@ 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-$PR_NUMBER-ci
docker tag $IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:pr-${{ github.event.inputs.issue_number || github.event.issue.number }}-ci
docker push -a $ECR_REGISTRY/$ECR_REPOSITORY
ephemeral-env-up:
@@ -223,13 +193,11 @@ 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-$PR_NUMBER-ci
--image-ids imageTag=pr-${{ github.event.inputs.issue_number || github.event.issue.number }}-ci
- name: Fail on missing container image
if: steps.check-image.outcome == 'failure'
@@ -239,7 +207,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.pull_request.number }},
issue_number: ${{ github.event.inputs.issue_number || github.event.issue.number }},
owner: context.repo.owner,
repo: context.repo.repo,
body: errMsg
@@ -252,7 +220,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.pull_request.number }}-ci
image: ${{ steps.login-ecr.outputs.registry }}/superset-ci:pr-${{ github.event.inputs.issue_number || github.event.issue.number }}-ci
- name: Update env vars in the Amazon ECS task definition
run: |
@@ -261,30 +229,29 @@ 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.pull_request.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.issue.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-$PR_NUMBER-service \
--service-name pr-${{ github.event.inputs.issue_number || github.event.issue.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=$PR_NUMBER key=github_user,value=${{ github.actor }}
--tags key=pr,value=${{ github.event.inputs.issue_number || github.event.issue.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.pull_request.number }}-service
service: pr-${{ github.event.inputs.issue_number || github.event.issue.number }}-service
cluster: superset-ci
wait-for-service-stability: true
wait-for-minutes: 10
@@ -292,7 +259,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.pull_request.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.issue.number }}-service | jq '.taskArns | first')" >> $GITHUB_OUTPUT
- name: Get network interface
id: get-eni
run: |
@@ -307,22 +274,20 @@ jobs:
with:
github-token: ${{github.token}}
script: |
const issue_number = context.payload.inputs?.issue_number || context.issue.number;
github.rest.issues.createComment({
issue_number: issue_number,
issue_number: ${{ github.event.inputs.issue_number || github.event.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: issue_number,
issue_number: ${{ github.event.inputs.issue_number || github.event.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,7 +24,13 @@ 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:
@@ -40,11 +46,11 @@ jobs:
git fetch --prune --unshallow
git tag -d `git tag | grep -E '^trigger-'`
- name: Install Node.js
- name: Use Node.js ${{ matrix.node-version }}
if: env.HAS_TAGS
uses: actions/setup-node@v4
with:
node-version-file: './superset-frontend/.nvmrc'
node-version: ${{ matrix.node-version }}
- name: Cache npm
if: env.HAS_TAGS

View File

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

View File

@@ -28,6 +28,9 @@ 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
@@ -38,7 +41,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: './superset-frontend/.nvmrc'
node-version: ${{ matrix.node }}
- 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
- name: Set up Node.js 20
uses: actions/setup-node@v4
with:
node-version-file: './docs/.nvmrc'
node-version: '20'
- 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
- name: Set up Node.js 20
uses: actions/setup-node@v4
with:
node-version-file: './docs/.nvmrc'
node-version: '20'
- 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-file: './superset-frontend/.nvmrc'
node-version: "20"
- 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-file: './superset-frontend/.nvmrc'
node-version: '18'
- 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-file: './superset-frontend/.nvmrc'
node-version: '20'
- name: Install Dependencies
run: npm install

1
.gitignore vendored
View File

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

View File

@@ -18,19 +18,16 @@
######################################################################
# Node stage to deal with static asset construction
######################################################################
ARG PY_VER=3.11.11-slim-bookworm
ARG PY_VER=3.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
ARG BUILD_TRANSLATIONS="false" # Include translations in the final build
ENV BUILD_TRANSLATIONS=${BUILD_TRANSLATIONS}
ARG DEV_MODE="false" # Skip frontend build in dev mode
ENV DEV_MODE=${DEV_MODE}
@@ -125,13 +122,10 @@ 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/.venv/bin/activate && /app/docker/pip-install.sh --requires-build-essential -r requirements/translations.txt
/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,7 +137,6 @@ 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 zstd
libffi-dev python3-dev libsasl2-dev libldap2-dev libxi-dev chromium
# Install nodejs for custom build
# https://nodejs.org/en/download/package-manager/
RUN set -eux; \
curl -sL https://deb.nodesource.com/setup_20.x | bash -; \
curl -sL https://deb.nodesource.com/setup_18.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=/home/superset/superset/:$PYTHONPATH \
SUPERSET_TESTENV=true
COPY from_tarball_entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -29,16 +29,13 @@ 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 subversion build-essential libssl-dev \
libffi-dev python3-dev libsasl2-dev libldap2-dev libxi-dev chromium zstd
RUN apt-get install -y build-essential libssl-dev \
libffi-dev python3-dev libsasl2-dev libldap2-dev libxi-dev chromium
# Install nodejs for custom build
# https://nodejs.org/en/download/package-manager/
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 curl -sL https://deb.nodesource.com/setup_16.x | bash - \
&& apt-get install -y nodejs
RUN mkdir -p /home/superset
RUN chown superset /home/superset
@@ -49,12 +46,14 @@ 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 /home/superset/apache-superset-$VERSION/superset-frontend
WORKDIR apache-superset-$VERSION
RUN npm ci \
RUN cd superset-frontend \
&& 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 \
@@ -63,6 +62,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=/home/superset/superset/:$PYTHONPATH
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:|O|O|O|
| can this form post on UserInfoEditView |:heavy_check_mark:|O|O|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 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,6 +65,7 @@ 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|
@@ -75,6 +76,7 @@ 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:|
@@ -116,6 +118,8 @@ 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:|
@@ -125,6 +129,13 @@ 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|
@@ -181,6 +192,7 @@ 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,9 +24,8 @@ 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
@@ -46,7 +45,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,7 +69,6 @@ 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>` |
@@ -78,7 +77,6 @@ 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}` |
@@ -1076,23 +1074,6 @@ 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
@@ -1355,24 +1336,6 @@ 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
with our [installing on k8s](https://superset.apache.org/docs/installation/running-on-kubernetes)
our [installing on k8s](https://superset.apache.org/docs/installation/running-on-kubernetes)
documentation.
:::
@@ -43,6 +43,7 @@ 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,9 +150,6 @@ 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.
:::
@@ -160,9 +157,9 @@ The following example installs the drivers for BigQuery and Elasticsearch, allow
```yaml
bootstrapScript: |
#!/bin/bash
uv pip install .[postgres] \
.[bigquery] \
.[elasticsearch] &&\
pip install psycopg2==2.9.6 \
sqlalchemy-bigquery==1.6.1 \
elasticsearch-dbapi==0.2.5 &&\
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.2",
"less": "^4.2.1",
"less-loader": "^11.0.0",
"prism-react-renderer": "^2.4.1",
"react": "^18.3.1",

View File

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

Binary file not shown.

Before

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.2:
version "4.2.2"
resolved "https://registry.yarnpkg.com/less/-/less-4.2.2.tgz#4b59ede113933b58ab152190edf9180fc36846d8"
integrity sha512-tkuLHQlvWUTeQ3doAqnHbNn8T6WX1KA8yvbKG9x4VtKtIjHsVKQZCH11zRgAfbDAXC2UNIg/K9BYAAcEzUIrNg==
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==
dependencies:
copy-anything "^2.0.1"
parse-node-version "^1.0.1"

View File

@@ -87,6 +87,7 @@ 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",
@@ -155,7 +156,6 @@ 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,10 +172,6 @@ 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,6 +329,8 @@ 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,6 +738,10 @@ 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

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

View File

@@ -74,7 +74,7 @@ module.exports = {
'file-progress',
'lodash',
'theme-colors',
'i18n-strings',
'translation-vars',
'react-prefer-function-component',
'prettier',
],
@@ -284,7 +284,7 @@ module.exports = {
],
rules: {
'theme-colors/no-literal-colors': 0,
'i18n-strings/no-template-vars': 0,
'translation-vars/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',
'i18n-strings/no-template-vars': ['error', true],
'translation-vars/no-template-vars': ['error', true],
camelcase: [
'error',
{
@@ -354,14 +354,6 @@ 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',
'antd5-dropdown-menu-item-disabled',
'ant-dropdown-menu-item-disabled',
);
waitForChartLoad(mapSpec);
cy.get('[data-test="refresh-chart-menu-item"]').should(
'not.have.class',
'antd5-dropdown-menu-item-disabled',
'ant-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',
'antd5-dropdown-menu-item-disabled',
'ant-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',
'antd5-dropdown-menu-item-disabled',
'ant-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',
'antd5-dropdown-menu-item-disabled',
'ant-dropdown-menu-item-disabled',
);
});
});

View File

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

View File

@@ -61,14 +61,15 @@ function drillToDetail(targetMenuItem: string) {
const drillToDetailBy = (targetDrill: string) => {
interceptSamples();
cy.get('.antd5-dropdown:not(.antd5-dropdown-hidden)')
cy.get('.ant-dropdown:not(.ant-dropdown-hidden)')
.first()
.should('be.visible')
.find("[role='menu'] [role='menuitem']")
.contains(/^Drill to detail by$/)
.trigger('mouseover', { force: true });
cy.get(
'.antd5-dropdown-menu-submenu:not(.antd5-dropdown-menu-submenu-hidden) [data-test="drill-to-detail-by-submenu"]',
'.ant-dropdown-menu-submenu:not(.ant-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-dropdown-menu-item-selected')
cy.get('.antd5-menu-item-selected')
.contains('Horizontal (Top)')
.should('exist');
cy.get('.antd5-dropdown-menu-item').contains('Vertical (Left)').click();
cy.get('.antd5-menu-item').contains('Vertical (Left)').click();
cy.getBySel('dashboard-filters-panel').should('exist');
} else {
cy.get('.antd5-dropdown-menu-item-selected')
cy.get('.antd5-menu-item-selected')
.contains('Vertical (Left)')
.should('exist');
cy.get('.antd5-dropdown-menu-item').contains('Horizontal (Top)').click();
cy.get('.antd5-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('.antd5-popover-inner').scrollTo('bottom');
cy.get('.ant-popover-inner-content').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.getBySel('filter-status-popover').contains('test_9').click();
cy.get('.filterStatusPopover').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('.antd5-popover-content .ant-select-selector')
cy.get('.ant-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('.antd5-popover-inner-content')
cy.get('.ant-popover-inner-content')
.find('[class^=ant-input]')
.first()
.type(`${startRange}`);
}
if (endRange) {
cy.get('.antd5-popover-inner-content')
cy.get('.ant-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('.antd5-dropdown-menu-submenu-title')
cy.get('.ant-dropdown-menu-submenu-title')
.contains('On dashboards')
.trigger('mouseover', { force: true });
}
function closeDashboardsAddedTo() {
cy.get('.antd5-dropdown-menu-submenu-title')
cy.get('.ant-dropdown-menu-submenu-title')
.contains('On dashboards')
.trigger('mouseout', { force: true });
cy.getBySel('actions-trigger').click();
}
function verifyDashboardsSubmenuItem(dashboardName) {
cy.get('.antd5-dropdown-menu-submenu-popup').contains(dashboardName);
cy.get('.ant-dropdown-menu-submenu-popup').contains(dashboardName);
closeDashboardsAddedTo();
}
function verifyDashboardSearch() {
openDashboardsAddedTo();
cy.get('.antd5-dropdown-menu-submenu-popup').trigger('mouseover');
cy.get('.antd5-dropdown-menu-submenu-popup')
cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover');
cy.get('.ant-dropdown-menu-submenu-popup')
.find('input[placeholder="Search"]')
.type('1');
cy.get('.antd5-dropdown-menu-submenu-popup').contains('1 - Sample dashboard');
cy.get('.antd5-dropdown-menu-submenu-popup')
cy.get('.ant-dropdown-menu-submenu-popup').contains('1 - Sample dashboard');
cy.get('.ant-dropdown-menu-submenu-popup')
.find('input[placeholder="Search"]')
.type('Blahblah');
cy.get('.antd5-dropdown-menu-submenu-popup').contains('No results found');
cy.get('.antd5-dropdown-menu-submenu-popup')
cy.get('.ant-dropdown-menu-submenu-popup').contains('No results found');
cy.get('.ant-dropdown-menu-submenu-popup')
.find('[aria-label="close-circle"]')
.click();
closeDashboardsAddedTo();
@@ -68,8 +68,8 @@ function verifyDashboardSearch() {
function verifyDashboardLink() {
interceptDashboardGet();
openDashboardsAddedTo();
cy.get('.antd5-dropdown-menu-submenu-popup').trigger('mouseover');
cy.get('.antd5-dropdown-menu-submenu-popup a')
cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover');
cy.get('.ant-dropdown-menu-submenu-popup a')
.first()
.invoke('removeAttr', 'target')
.click();

View File

@@ -51,8 +51,8 @@ describe('Datasource control', () => {
)
.first()
.focus();
cy.focused().clear({ force: true });
cy.focused().type(`${newMetricName}{enter}`, { force: true });
cy.focused().clear();
cy.focused().type(`${newMetricName}{enter}`);
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 .antd5-dropdown-trigger').click();
cy.get(':nth-child(3) > .antd5-dropdown-menu-submenu-title').click();
cy.get('.header-with-actions .ant-dropdown-trigger').click();
cy.get(':nth-child(3) > .ant-dropdown-menu-submenu-title').click();
cy.get(
'.antd5-dropdown-menu-submenu > .antd5-dropdown-menu li:nth-child(3)',
'.ant-dropdown-menu-submenu > .ant-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('.antd5-dropdown-menu')
cy.get('.ant-dropdown-menu')
.last()
.find('.antd5-dropdown-menu-item')
.find('.ant-dropdown-menu-item')
.first()
.click({ force: true });

View File

@@ -158,10 +158,10 @@ export const sqlLabView = {
runButton: '.css-d3dxop',
},
rowsLimit: {
dropdown: '.antd5-dropdown-menu',
limitButton: '.antd5-dropdown-menu-item',
dropdown: '.ant-dropdown-menu',
limitButton: '.ant-dropdown-menu-item',
limitButtonText: '.css-151uxnz',
limitTextWithValue: '[class="antd5-dropdown-trigger"]',
limitTextWithValue: '[class="ant-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: '.antd5-popover-content',
container: '.ant-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 .antd5-dropdown-trigger',
'.header-with-actions .right-button-panel .ant-dropdown-trigger',
threeDotsMenuDropdown: dataTestLocator('header-actions-menu'),
refreshDashboard: dataTestLocator('refresh-dashboard-menu-item'),
saveAsMenuOption: dataTestLocator('save-as-menu-item'),

View File

@@ -75,5 +75,4 @@ 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=8192\" jest --watch",
"test": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --max-workers=80% --silent",
"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%",
"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,7 +139,6 @@
"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",
@@ -254,7 +253,7 @@
"@storybook/react-webpack5": "8.1.11",
"@svgr/webpack": "^8.1.0",
"@testing-library/dom": "^8.20.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^12.8.3",
@@ -302,7 +301,6 @@
"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",
@@ -323,7 +321,8 @@
"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:eslint-rules/eslint-plugin-theme-colors",
"eslint-plugin-theme-colors": "file:tools/eslint-plugin-theme-colors",
"eslint-plugin-translation-vars": "file:tools/eslint-plugin-translation-vars",
"exports-loader": "^5.0.0",
"fetch-mock": "^7.7.3",
"fork-ts-checker-webpack-plugin": "^9.0.2",
@@ -332,7 +331,9 @@
"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-v5';
import { Popover } from 'antd';
import type ReactAce from 'react-ace';
import type { PopoverProps } from 'antd-v5/lib/popover';
import type { PopoverProps } from 'antd/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"
arrow={{ pointAtCenter: true }}
arrowPointAtCenter
title={t('SQL expression')}
{...props}
>

View File

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

View File

@@ -17,7 +17,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Currency, Maybe, QueryFormMetric } from '../../types';
import { 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<Currency>;
currency?: Maybe<string>;
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 { container } = render(
const wrapper = shallow(
<WithLegend
className="test-class"
renderChart={renderChart}
renderLegend={renderLegend}
/>,
);
expect(container.querySelectorAll('.test-class')).toHaveLength(1);
expect(wrapper.hasClass('test-class')).toEqual(true);
});
it('renders when renderLegend is not set', () => {
const { container } = render(
const wrapper = mount(
<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(container.querySelectorAll('div.chart')).toHaveLength(1);
expect(container.querySelectorAll('div.legend')).toHaveLength(0);
expect(wrapper.render().find('div.chart')).toHaveLength(1);
expect(wrapper.render().find('div.legend')).toHaveLength(0);
}, 100);
});
it('renders', () => {
const { container } = render(
const wrapper = mount(
<WithLegend
debounceTime={1}
width={500}
@@ -77,13 +77,13 @@ describe.skip('WithLegend', () => {
return promiseTimeout(() => {
expect(renderChart).toHaveBeenCalledTimes(1);
expect(renderLegend).toHaveBeenCalledTimes(1);
expect(container.querySelectorAll('div.chart')).toHaveLength(1);
expect(container.querySelectorAll('div.legend')).toHaveLength(1);
expect(wrapper.render().find('div.chart')).toHaveLength(1);
expect(wrapper.render().find('div.legend')).toHaveLength(1);
}, 100);
});
it('renders without width or height', () => {
const { container } = render(
const wrapper = mount(
<WithLegend
debounceTime={1}
renderChart={renderChart}
@@ -96,13 +96,13 @@ describe.skip('WithLegend', () => {
return promiseTimeout(() => {
expect(renderChart).toHaveBeenCalledTimes(1);
expect(renderLegend).toHaveBeenCalledTimes(1);
expect(container.querySelectorAll('div.chart')).toHaveLength(1);
expect(container.querySelectorAll('div.legend')).toHaveLength(1);
expect(wrapper.render().find('div.chart')).toHaveLength(1);
expect(wrapper.render().find('div.legend')).toHaveLength(1);
}, 100);
});
it('renders legend on the left', () => {
const { container } = render(
const wrapper = mount(
<WithLegend
debounceTime={1}
position="left"
@@ -116,13 +116,13 @@ describe.skip('WithLegend', () => {
return promiseTimeout(() => {
expect(renderChart).toHaveBeenCalledTimes(1);
expect(renderLegend).toHaveBeenCalledTimes(1);
expect(container.querySelectorAll('div.chart')).toHaveLength(1);
expect(container.querySelectorAll('div.legend')).toHaveLength(1);
expect(wrapper.render().find('div.chart')).toHaveLength(1);
expect(wrapper.render().find('div.legend')).toHaveLength(1);
}, 100);
});
it('renders legend on the right', () => {
const { container } = render(
const wrapper = mount(
<WithLegend
debounceTime={1}
position="right"
@@ -136,13 +136,13 @@ describe.skip('WithLegend', () => {
return promiseTimeout(() => {
expect(renderChart).toHaveBeenCalledTimes(1);
expect(renderLegend).toHaveBeenCalledTimes(1);
expect(container.querySelectorAll('div.chart')).toHaveLength(1);
expect(container.querySelectorAll('div.legend')).toHaveLength(1);
expect(wrapper.render().find('div.chart')).toHaveLength(1);
expect(wrapper.render().find('div.legend')).toHaveLength(1);
}, 100);
});
it('renders legend on the top', () => {
const { container } = render(
const wrapper = mount(
<WithLegend
debounceTime={1}
position="top"
@@ -156,13 +156,13 @@ describe.skip('WithLegend', () => {
return promiseTimeout(() => {
expect(renderChart).toHaveBeenCalledTimes(1);
expect(renderLegend).toHaveBeenCalledTimes(1);
expect(container.querySelectorAll('div.chart')).toHaveLength(1);
expect(container.querySelectorAll('div.legend')).toHaveLength(1);
expect(wrapper.render().find('div.chart')).toHaveLength(1);
expect(wrapper.render().find('div.legend')).toHaveLength(1);
}, 100);
});
it('renders legend on the bottom', () => {
const { container } = render(
const wrapper = mount(
<WithLegend
debounceTime={1}
position="bottom"
@@ -176,13 +176,13 @@ describe.skip('WithLegend', () => {
return promiseTimeout(() => {
expect(renderChart).toHaveBeenCalledTimes(1);
expect(renderLegend).toHaveBeenCalledTimes(1);
expect(container.querySelectorAll('div.chart')).toHaveLength(1);
expect(container.querySelectorAll('div.legend')).toHaveLength(1);
expect(wrapper.render().find('div.chart')).toHaveLength(1);
expect(wrapper.render().find('div.legend')).toHaveLength(1);
}, 100);
});
it('renders legend with justifyContent set', () => {
const { container } = render(
const wrapper = mount(
<WithLegend
debounceTime={1}
position="right"
@@ -197,8 +197,8 @@ describe.skip('WithLegend', () => {
return promiseTimeout(() => {
expect(renderChart).toHaveBeenCalledTimes(1);
expect(renderLegend).toHaveBeenCalledTimes(1);
expect(container.querySelectorAll('div.chart')).toHaveLength(1);
expect(container.querySelectorAll('div.legend')).toHaveLength(1);
expect(wrapper.render().find('div.chart')).toHaveLength(1);
expect(wrapper.render().find('div.legend')).toHaveLength(1);
}, 100);
});
});

View File

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

View File

@@ -17,12 +17,10 @@
* 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,
@@ -30,7 +28,9 @@ 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,39 +44,45 @@ const DEFAULT_QUERIES_DATA = [
{ data: ['foo2', 'bar2'] },
];
// 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 || '';
function expectDimension(
renderedWrapper: cheerio.Cheerio,
width: number,
height: number,
) {
expect(renderedWrapper.find('.dimension').text()).toEqual(
[width, height].join('x'),
);
}
const renderWithTheme = (component: ReactElement) =>
render(component, {
wrapper: ({ children }) => (
<ThemeProvider theme={supersetTheme}>{children}</ThemeProvider>
),
const mount = (component: ReactElement) =>
enzymeMount(component, {
wrappingComponent: ThemeProvider,
wrappingComponentProps: { theme: supersetTheme },
});
describe('SuperChart', () => {
jest.setTimeout(5000);
let restoreConsole: RestoreConsole;
// TODO: rewrite to rtl
describe.skip('SuperChart', () => {
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(() => {
@@ -99,16 +105,14 @@ describe('SuperChart', () => {
afterEach(() => {
window.removeEventListener('error', onError);
});
it('should have correct number of errors', () => {
// eslint-disable-next-line jest/no-standalone-expect
expect(actualErrors).toBe(expectedErrors);
expectedErrors = 0;
});
it('renders default FallbackComponent', async () => {
expectedErrors = 1;
renderWithTheme(
const wrapper = mount(
<SuperChart
chartType={ChartKeys.BUGGY}
queriesData={[DEFAULT_QUERY_DATA]}
@@ -116,19 +120,16 @@ describe('SuperChart', () => {
height="200"
/>,
);
expect(
await screen.findByText('Oops! An error occurred!'),
).toBeInTheDocument();
await new Promise(resolve => setImmediate(resolve));
wrapper.update();
expect(wrapper.text()).toContain('Oops! An error occurred!');
});
it('renders custom FallbackComponent', async () => {
it('renders custom FallbackComponent', () => {
expectedErrors = 1;
const CustomFallbackComponent = jest.fn(() => (
<div>Custom Fallback!</div>
));
renderWithTheme(
const wrapper = mount(
<SuperChart
chartType={ChartKeys.BUGGY}
queriesData={[DEFAULT_QUERY_DATA]}
@@ -138,13 +139,15 @@ describe('SuperChart', () => {
/>,
);
expect(await screen.findByText('Custom Fallback!')).toBeInTheDocument();
expect(CustomFallbackComponent).toHaveBeenCalledTimes(1);
return promiseTimeout(() => {
expect(wrapper.render().find('div.test-component')).toHaveLength(0);
expect(CustomFallbackComponent).toHaveBeenCalledTimes(1);
});
});
it('call onErrorBoundary', async () => {
it('call onErrorBoundary', () => {
expectedErrors = 1;
const handleError = jest.fn();
renderWithTheme(
mount(
<SuperChart
chartType={ChartKeys.BUGGY}
queriesData={[DEFAULT_QUERY_DATA]}
@@ -154,20 +157,17 @@ describe('SuperChart', () => {
/>,
);
await screen.findByText('Oops! An error occurred!');
expect(handleError).toHaveBeenCalledTimes(1);
return promiseTimeout(() => {
expect(handleError).toHaveBeenCalledTimes(1);
});
});
// Update the test cases
it('does not include ErrorBoundary if told so', async () => {
it('does not include ErrorBoundary if told so', () => {
expectedErrors = 1;
const inactiveErrorHandler = jest.fn();
const activeErrorHandler = jest.fn();
renderWithTheme(
<ErrorBoundary
fallbackRender={() => <div>Error!</div>}
onError={activeErrorHandler}
>
mount(
// @ts-ignore
<ErrorBoundary onError={activeErrorHandler}>
<SuperChart
disableErrorBoundary
chartType={ChartKeys.BUGGY}
@@ -179,24 +179,15 @@ describe('SuperChart', () => {
</ErrorBoundary>,
);
await screen.findByText('Error!');
expect(activeErrorHandler).toHaveBeenCalledTimes(1);
expect(inactiveErrorHandler).not.toHaveBeenCalled();
return promiseTimeout(() => {
expect(activeErrorHandler).toHaveBeenCalledTimes(1);
expect(inactiveErrorHandler).toHaveBeenCalledTimes(0);
});
});
});
// 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(
it('passes the props to renderer correctly', () => {
const wrapper = mount(
<SuperChart
chartType={ChartKeys.DILIGENT}
queriesData={[DEFAULT_QUERY_DATA]}
@@ -206,123 +197,15 @@ describe('SuperChart', () => {
/>,
);
await promiseTimeout(() => {
const testComponent = findByClassName(container, 'test-component');
expect(testComponent).not.toBeNull();
expect(testComponent).toBeInTheDocument();
expect(getDimensionText(container)).toBe('101x118');
return promiseTimeout(() => {
const renderedWrapper = wrapper.render();
expect(renderedWrapper.find('div.test-component')).toHaveLength(1);
expectDimension(renderedWrapper, 101, 118);
});
});
// 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(
it('passes the props with multiple queries to renderer correctly', () => {
const wrapper = mount(
<SuperChart
chartType={ChartKeys.DILIGENT}
queriesData={DEFAULT_QUERIES_DATA}
@@ -332,25 +215,42 @@ describe('SuperChart', () => {
/>,
);
await promiseTimeout(() => {
const testComponent = container.querySelector('.test-component');
expect(testComponent).not.toBeNull();
expect(testComponent).toBeInTheDocument();
expect(getDimensionText(container)).toBe('101x118');
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);
});
});
describe('supports NoResultsComponent', () => {
it('renders NoResultsComponent when queriesData is missing', () => {
renderWithTheme(
const wrapper = mount(
<SuperChart chartType={ChartKeys.DILIGENT} width="200" height="200" />,
);
expect(screen.getByText('No Results')).toBeInTheDocument();
expect(wrapper.find(NoResultsComponent)).toHaveLength(1);
});
it('renders NoResultsComponent when queriesData data is null', () => {
renderWithTheme(
const wrapper = mount(
<SuperChart
chartType={ChartKeys.DILIGENT}
queriesData={[{ data: null }]}
@@ -359,12 +259,116 @@ describe('SuperChart', () => {
/>,
);
expect(screen.getByText('No Results')).toBeInTheDocument();
expect(wrapper.find(NoResultsComponent)).toHaveLength(1);
});
});
describe('supports dynamic width and/or height', () => {
// Add MyWrapper component definition
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', () => {
function MyWrapper({ width, height, children }: WrapperProps) {
return (
<div>
@@ -376,81 +380,50 @@ describe('SuperChart', () => {
);
}
it('works with width and height that are numbers', async () => {
const { container } = renderWithTheme(
it('works with width and height that are numbers', () => {
const wrapper = mount(
<SuperChart
chartType={ChartKeys.DILIGENT}
queriesData={[DEFAULT_QUERY_DATA]}
width={100}
height={100}
Wrapper={MyWrapper}
/>,
);
await promiseTimeout(() => {
const testComponent = container.querySelector('.test-component');
expect(testComponent).not.toBeNull();
expect(testComponent).toBeInTheDocument();
expect(getDimensionText(container)).toBe('100x100');
});
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);
});
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>,
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}
/>,
);
triggerResizeObserver();
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);
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);
});
});
});

View File

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

View File

@@ -17,11 +17,10 @@
* 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)', () => {
@@ -79,18 +78,14 @@ describe('reactify(renderFn)', () => {
it('returns a React component class', () =>
new Promise(done => {
render(<TestComponent />);
const wrapper = mount(<TestComponent />);
expect(renderFn).toHaveBeenCalledTimes(1);
expect(screen.getByText('abc')).toBeInTheDocument();
expect(screen.getByText('abc').parentNode).toHaveAttribute('id', 'test');
expect(wrapper.html()).toEqual('<div id="test"><b>abc</b></div>');
setTimeout(() => {
expect(renderFn).toHaveBeenCalledTimes(2);
expect(screen.getByText('def')).toBeInTheDocument();
expect(screen.getByText('def').parentNode).toHaveAttribute(
'id',
'test',
);
expect(wrapper.html()).toEqual('<div id="test"><b>def</b></div>');
wrapper.unmount();
done(undefined);
}, 20);
}));
@@ -124,9 +119,8 @@ describe('reactify(renderFn)', () => {
describe('defaultProps', () => {
it('has defaultProps if renderFn.defaultProps is defined', () => {
expect(TheChart.defaultProps).toBe(renderFn.defaultProps);
render(<TheChart id="test" />);
expect(screen.getByText('ghi')).toBeInTheDocument();
expect(screen.getByText('ghi').parentNode).toHaveAttribute('id', 'test');
const wrapper = mount(<TheChart id="test" />);
expect(wrapper.html()).toEqual('<div id="test"><b>ghi</b></div>');
});
it('does not have defaultProps if renderFn.defaultProps is not defined', () => {
const AnotherChart = reactify(() => {});
@@ -142,9 +136,9 @@ describe('reactify(renderFn)', () => {
});
it('calls willUnmount hook when it is provided', () =>
new Promise(done => {
const { unmount } = render(<AnotherTestComponent />);
const wrapper = mount(<AnotherTestComponent />);
setTimeout(() => {
unmount();
wrapper.unmount();
expect(willUnmountCb).toHaveBeenCalledTimes(1);
done(undefined);
}, 20);

View File

@@ -94,7 +94,9 @@ const config: ControlPanelConfig = {
['Tukey', t('Tukey')],
['Min/max (no outliers)', t('Min/max (no outliers)')],
['2/98 percentiles', t('2/98 percentiles')],
['5/95 percentiles', t('5/95 percentiles')],
['9/91 percentiles', t('9/91 percentiles')],
['10/90 percentiles', t('10/90 percentiles')],
],
},
},

View File

@@ -36,7 +36,9 @@ export type BoxPlotFormDataWhiskerOptions =
| 'Tukey'
| 'Min/max (no outliers)'
| '2/98 percentiles'
| '9/91 percentiles';
| '5/95 percentiles'
| '9/91 percentiles'
| '10/90 percentiles';
export type BoxPlotFormXTickLayout =
| '45°'

View File

@@ -27,7 +27,6 @@ import {
ensureIsArray,
GenericDataType,
getCustomFormatter,
getMetricLabel,
getNumberFormatter,
getXAxisLabel,
isDefined,
@@ -292,20 +291,12 @@ 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,
metricsLabels,
xAxisCol: xAxisLabel,
},
);
const {
@@ -314,7 +305,7 @@ export default function transformProps(
} = extractDataTotalValues(rebasedDataB, {
stack: Boolean(stackB),
percentageThreshold,
metricsLabels: metricsLabelsB,
xAxisCol: xAxisLabel,
});
annotationLayers

View File

@@ -215,18 +215,14 @@ 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(
@@ -300,6 +296,7 @@ export default function transformProps(
const entryName = String(entry.name || '');
const seriesName = inverted[entryName] || entryName;
const colorScaleKey = getOriginalSeries(seriesName, array);
const transformedSeries = transformSeries(
entry,
colorScale,
@@ -625,7 +622,6 @@ 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: false,
default: true,
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, LegendPaddingType } from '@superset-ui/chart-controls';
import { SortSeriesType } 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, legendState, metricsLabels } = opts;
const { stack, percentageThreshold, xAxisCol, legendState } = opts;
if (stack) {
data.forEach(datum => {
const values = Object.keys(datum).reduce((prev, curr) => {
if (!metricsLabels.includes(curr)) {
if (curr === xAxisCol) {
return prev;
}
if (legendState && !legendState[curr]) {
@@ -425,7 +425,6 @@ export function getLegendProps(
theme: SupersetTheme,
zoomable = false,
legendState?: LegendState,
padding?: LegendPaddingType,
): LegendComponentOption | LegendComponentOption[] {
const legend: LegendComponentOption | LegendComponentOption[] = {
orient: [LegendOrientation.Top, LegendOrientation.Bottom].includes(
@@ -444,30 +443,13 @@ 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;
@@ -485,7 +467,7 @@ export function getChartPadding(
show: boolean,
orientation: LegendOrientation,
margin?: string | number | null,
padding?: LegendPaddingType,
padding?: { top?: number; bottom?: number; left?: number; right?: number },
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,25 +36,15 @@ describe('EchartsTimeseries transformProps', () => {
colorScheme: 'bnbColors',
datasource: '3__table',
granularity_sqla: 'ds',
metrics: ['sum__num'],
metric: 'sum__num',
groupby: ['foo', 'bar'],
viz_type: 'my_viz',
};
const queriesData = [
{
data: [
{
'San Francisco': 1,
'New York': 2,
__timestamp: 599616000000,
sum__num: 4,
},
{
'San Francisco': 3,
'New York': 4,
__timestamp: 599916000000,
sum__num: 8,
},
{ 'San Francisco': 1, 'New York': 2, __timestamp: 599616000000 },
{ 'San Francisco': 3, 'New York': 4, __timestamp: 599916000000 },
],
},
];
@@ -74,7 +64,7 @@ describe('EchartsTimeseries transformProps', () => {
height: 600,
echartOptions: expect.objectContaining({
legend: expect.objectContaining({
data: ['sum__num', 'San Francisco', 'New York'],
data: ['San Francisco', 'New York'],
}),
series: expect.arrayContaining([
expect.objectContaining({
@@ -111,7 +101,7 @@ describe('EchartsTimeseries transformProps', () => {
height: 600,
echartOptions: expect.objectContaining({
legend: expect.objectContaining({
data: ['sum__num', 'San Francisco', 'New York'],
data: ['San Francisco', 'New York'],
}),
series: expect.arrayContaining([
expect.objectContaining({
@@ -156,7 +146,7 @@ describe('EchartsTimeseries transformProps', () => {
height: 600,
echartOptions: expect.objectContaining({
legend: expect.objectContaining({
data: ['sum__num', 'San Francisco', 'New York', 'My Formula'],
data: ['San Francisco', 'New York', 'My Formula'],
}),
series: expect.arrayContaining([
expect.objectContaining({
@@ -284,7 +274,7 @@ describe('EchartsTimeseries transformProps', () => {
expect.objectContaining({
echartOptions: expect.objectContaining({
legend: expect.objectContaining({
data: ['sum__num', 'San Francisco', 'New York', 'My Line'],
data: ['San Francisco', 'New York', 'My Line'],
}),
series: expect.arrayContaining([
expect.objectContaining({
@@ -430,7 +420,7 @@ describe('Does transformProps transform series correctly', () => {
colorScheme: 'bnbColors',
datasource: '3__table',
granularity_sqla: 'ds',
metrics: ['sum__num'],
metric: 'sum__num',
groupby: ['foo', 'bar'],
showValue: true,
stack: true,
@@ -445,28 +435,24 @@ 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,
},
],
},
@@ -482,7 +468,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' || key === 'sum__num') return stackSum;
if (key === '__timestamp') return stackSum;
return stackSum + currentStack[key as keyof typeof currentStack];
}, 0);
totals.push(total);
@@ -575,6 +561,7 @@ 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) => {
@@ -589,6 +576,7 @@ 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,7 +29,6 @@ import {
calculateLowerLogTick,
dedupSeries,
extractGroupbyLabel,
extractDataTotalValues,
extractSeries,
extractShowValueIndexes,
extractTooltipKeys,
@@ -1086,123 +1085,6 @@ 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,26 +73,3 @@ 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,18 +99,5 @@ 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,9 +20,8 @@ import {
ControlSetItem,
CustomControlConfig,
sharedControls,
InfoTooltipWithTrigger,
} from '@superset-ui/chart-controls';
import { t, validateNonEmpty, useTheme, SafeMarkdown } from '@superset-ui/core';
import { t, validateNonEmpty } from '@superset-ui/core';
import { CodeEditor } from '../../components/CodeEditor/CodeEditor';
import { ControlHeader } from '../../components/ControlHeader/controlHeader';
import { debounceFunc } from '../../consts';
@@ -34,48 +33,13 @@ 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>
<div>
{props.label}
<InfoTooltipWithTrigger
iconsStyle={{ marginLeft: theme.gridUnit }}
tooltip={<SafeMarkdown source={helpersTooltipContent} />}
/>
</div>
</ControlHeader>
<ControlHeader>{props.label}</ControlHeader>
<CodeEditor
theme="dark"
value={val}
@@ -101,7 +65,6 @@ export const handlebarsTemplateControlSetItem: ControlSetItem = {
</ul>`,
isInt: false,
renderTrigger: true,
valueKey: null,
validators: [validateNonEmpty],
mapStateToProps: ({ controls }) => ({

View File

@@ -75,7 +75,6 @@ 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 'enzyme-matchers';
import 'jest-enzyme';
import jQuery from 'jquery';
import Enzyme from 'enzyme';
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';

View File

@@ -109,7 +109,6 @@ 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 { mount as enzymeMount } from 'enzyme';
import { shallow as enzymeShallow, mount as enzymeMount } from 'enzyme';
// eslint-disable-next-line no-restricted-imports
import { supersetTheme } from '@superset-ui/core';
import { ReactElement } from 'react';
@@ -26,13 +26,12 @@ type optionsType = {
wrappingComponentProps?: any;
wrappingComponent?: ReactElement;
context?: any;
newOption?: string;
};
export function styledMount(
component: ReactElement,
options: optionsType = {},
): any {
) {
return enzymeMount(component, {
...options,
wrappingComponent: ProviderWrapper,
@@ -42,3 +41,17 @@ export function styledMount(
},
});
}
export function styledShallow(
component: ReactElement,
options: optionsType = {},
) {
return enzymeShallow(component, {
...options,
wrappingComponent: ProviderWrapper,
wrappingComponentProps: {
theme: supersetTheme,
...options?.wrappingComponentProps,
},
});
}

View File

@@ -39,18 +39,16 @@ 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,
.ant-popover,
.antd5-popover {
.antd5-picker-dropdown {
z-index: ${theme.zIndex.max} !important;
}
@@ -107,6 +105,13 @@ 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 'spec/helpers/testing-library';
import { waitFor } from '@testing-library/react';
import * as actions from 'src/SqlLab/actions/sqlLab';
import { LOG_EVENT } from 'src/logger/actions';
import {

View File

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

View File

@@ -17,13 +17,12 @@
* under the License.
*/
import { useDispatch } from 'react-redux';
import { useTheme, t } from '@superset-ui/core';
import { Dropdown } from 'src/components/Dropdown';
import { styled, useTheme, t } from '@superset-ui/core';
import { AntdDropdown } from 'src/components';
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;
@@ -35,6 +34,28 @@ 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,
@@ -73,18 +94,20 @@ const QueryLimitSelect = ({
dispatch(queryEditorSetQueryLimit(queryEditor, updatedQueryLimit));
return (
<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>
<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>
);
};

View File

@@ -20,6 +20,7 @@ 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';
@@ -28,55 +29,27 @@ const mockedProps = {
displayLimit: 100,
latestQueryId: 'ryhMUZCGb',
};
describe('QueryTable', () => {
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 { 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);
});
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,
});
const { container } = render(
<Provider store={store}>
<QueryTable {...mockedProps} />
</Provider>,
);
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);
});

View File

@@ -143,13 +143,6 @@ 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,
@@ -164,10 +157,8 @@ describe('ResultSet', () => {
},
}),
);
await waitFor(() => {
const table = getByTestId('table-container');
expect(table).toBeInTheDocument();
});
const table = getByTestId('table-container');
expect(table).toBeInTheDocument();
});
test('should render success query', async () => {
@@ -254,7 +245,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];
@@ -517,22 +508,13 @@ describe('ResultSet', () => {
},
}),
);
await waitFor(() => {
const downloadButton = getByTestId('export-csv-button');
expect(downloadButton).toBeInTheDocument();
});
const downloadButton = getByTestId('export-csv-button');
await waitFor(() => fireEvent.click(downloadButton));
fireEvent.click(downloadButton);
const warningModal = await findByRole('dialog');
await waitFor(() => {
expect(
within(warningModal).getByText(`Download is on the way`),
).toBeInTheDocument();
});
}, 20000);
expect(
within(warningModal).getByText(`Download is on the way`),
).toBeInTheDocument();
});
test('should not allow download as CSV when user does not have permission to export data', async () => {
const { queryByTestId } = setup(

View File

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

View File

@@ -16,10 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { t, useTheme } from '@superset-ui/core';
import { FC } from 'react';
import { t, useTheme, styled } 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;
@@ -32,14 +34,34 @@ 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>
) : (
<DropdownButton
<StyledDropdownButton
onClick={() => setShowSave(true)}
dropdownRender={() => overlayMenu}
overlay={overlayMenu}
icon={
<Icons.CaretDown
iconColor={theme.colors.grayscale.light5}
@@ -49,7 +71,7 @@ const SaveDatasetActionButton = ({
trigger={['click']}
>
{t('Save')}
</DropdownButton>
</StyledDropdownButton>
);
};

View File

@@ -18,13 +18,13 @@
*/
import * as reactRedux from 'react-redux';
import {
cleanup,
fireEvent,
render,
screen,
userEvent,
cleanup,
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,36 +96,32 @@ interface SaveDatasetModalProps {
}
const Styles = styled.div`
${({ theme }) => `
.sdm-body {
margin: 0 ${theme.gridUnit * 2}px;
margin: 0 8px;
}
.sdm-input {
margin-left: ${theme.gridUnit * 10}px;
margin-left: 45px;
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: ${theme.gridUnit * 2}px;
margin: 7px;
}
.sdm-overwrite-container {
flex: 1 1 auto;
display: flex;
`}
}
`;
const updateDataset = async (
dbId: number,
datasetId: number,

View File

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

View File

@@ -26,13 +26,9 @@ import {
ThemeProvider,
isFeatureEnabled,
} from '@superset-ui/core';
import {
render,
screen,
act,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { render, screen, act, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import userEvent from '@testing-library/user-event';
import ShareSqlLabQuery from 'src/SqlLab/components/ShareSqlLabQuery';
import { initialState } from 'src/SqlLab/fixtures';
@@ -137,7 +133,7 @@ describe('ShareSqlLabQuery', () => {
});
});
const button = screen.getByRole('button');
const { id: _id, remoteId: _remoteId, ...expected } = mockQueryEditor;
const { id, remoteId, ...expected } = mockQueryEditor;
userEvent.click(button);
await waitFor(() =>
expect(fetchMock.calls(storeQueryUrl)).toHaveLength(1),
@@ -154,7 +150,7 @@ describe('ShareSqlLabQuery', () => {
});
});
const button = screen.getByRole('button');
const { id: _id, ...expected } = unsavedQueryEditor;
const { 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, waitFor, within } from 'spec/helpers/testing-library';
import { render } 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,14 +49,12 @@ const mockState = {
tables: [
{
...table,
id: 't3',
name: 'table3',
dataPreviewQueryId: '2g2_iRFMl',
queryEditorId: defaultQueryEditor.id,
},
{
...table,
id: 't4',
name: 'table4',
dataPreviewQueryId: 'erWdqEWPm',
queryEditorId: defaultQueryEditor.id,
@@ -151,22 +149,3 @@ 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, pinnedTables],
[dispatch, queryEditorId],
);
return offline ? (

View File

@@ -17,18 +17,13 @@
* under the License.
*/
import { FocusEventHandler } from 'react';
import { act } from 'react-dom/test-utils';
import {
isFeatureEnabled,
getExtensionsRegistry,
FeatureFlag,
} from '@superset-ui/core';
import {
act,
cleanup,
fireEvent,
render,
waitFor,
} from 'spec/helpers/testing-library';
import { 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';
@@ -140,15 +135,6 @@ 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],
@@ -201,27 +187,16 @@ describe('SqlEditor', () => {
});
it('render a SqlEditorLeftBar', async () => {
const { getByTestId, unmount } = setup(mockedProps, store);
await waitFor(
() => expect(getByTestId('mock-sql-editor-left-bar')).toBeInTheDocument(),
{ timeout: 10000 },
const { getByTestId } = setup(mockedProps, store);
await waitFor(() =>
expect(getByTestId('mock-sql-editor-left-bar')).toBeInTheDocument(),
);
});
unmount();
}, 15000);
// Update other similar tests with timeouts
it('render an AceEditorWrapper', async () => {
const { findByTestId, unmount } = setup(mockedProps, store);
await waitFor(
() => expect(findByTestId('react-ace')).resolves.toBeInTheDocument(),
{ timeout: 10000 },
);
unmount();
}, 15000);
const { findByTestId } = setup(mockedProps, store);
expect(await findByTestId('react-ace')).toBeInTheDocument();
});
it('skip rendering an AceEditorWrapper when the current tab is inactive', async () => {
const { findByTestId, queryByTestId } = setup(

View File

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

View File

@@ -17,13 +17,8 @@
* under the License.
*/
import fetchMock from 'fetch-mock';
import {
render,
screen,
userEvent,
waitFor,
within,
} from 'spec/helpers/testing-library';
import { render, screen, waitFor, within } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
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 { MenuDotsDropdown } from 'src/components/Dropdown';
import { Dropdown } 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>
<MenuDotsDropdown
<Dropdown
trigger={['click']}
overlay={
<Menu>
<Menu style={{ width: 176 }}>
<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,8 +30,11 @@ 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, Button } from 'src/components';
import { Dropdown } from 'src/components/Dropdown';
import {
Skeleton,
AntdBreadcrumb as Breadcrumb,
AntdDropdown,
} from 'src/components';
import FilterableTable from 'src/components/FilterableTable';
import Tabs from 'src/components/Tabs';
import {
@@ -305,8 +308,8 @@ const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
<Title>
<Icons.Table iconSize="l" />
{tableName}
<Dropdown
dropdownRender={() => (
<AntdDropdown
overlay={
<Menu
onClick={({ key }) => {
if (key === 'refresh-table') {
@@ -321,17 +324,15 @@ const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
}}
items={dropdownMenu}
/>
)}
}
trigger={['click']}
>
<Button buttonSize="xsmall" type="link">
<Icons.DownSquareOutlined
iconSize="m"
style={{ marginTop: 2, marginLeft: 4 }}
aria-label={t('Table actions')}
/>
</Button>
</Dropdown>
<Icons.DownSquareOutlined
iconSize="m"
style={{ marginTop: 2, marginLeft: 4 }}
aria-label={t('Table actions')}
/>
</AntdDropdown>
</Title>
{isMetadataRefreshing ? (
<Skeleton active />

View File

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

View File

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

View File

@@ -16,12 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import '@testing-library/jest-dom';
import userEvent from '@testing-library/user-event';
import { ModifiedInfo } from '.';
@@ -43,7 +40,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: 'default',
tertiary: 'dashed',
dashed: 'dashed',
link: 'link',
};

View File

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

View File

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

View File

@@ -30,7 +30,6 @@ import { useDispatch, useSelector } from 'react-redux';
import {
Behavior,
BinaryQueryObjectFilterClause,
Column,
ContextMenuFilters,
ensureIsArray,
FeatureFlag,
@@ -43,11 +42,8 @@ import {
import { RootState } from 'src/dashboard/types';
import { Menu } from 'src/components/Menu';
import { usePermissions } from 'src/hooks/usePermissions';
import { Dropdown } from 'src/components/Dropdown';
import { AntdDropdown as Dropdown } from 'src/components/index';
import { updateDataMask } from 'src/dataMask/actions';
import DrillByModal from 'src/components/Chart/DrillBy/DrillByModal';
import { useVerboseMap } from 'src/hooks/apiResources/datasets';
import { Dataset } from 'src/components/Chart/types';
import { DrillDetailMenuItems } from '../DrillDetail';
import { getMenuAdjustedY } from '../utils';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
@@ -118,22 +114,8 @@ const ChartContextMenu = (
}>({ clientX: 0, clientY: 0 });
const [drillModalIsOpen, setDrillModalIsOpen] = useState(false);
const [drillByColumn, setDrillByColumn] = useState<Column>();
const [showDrillByModal, setShowDrillByModal] = useState(false);
const [dataset, setDataset] = useState<Dataset>();
const verboseMap = useVerboseMap(dataset);
const handleDrillBy = useCallback((column: Column, dataset: Dataset) => {
setDrillByColumn(column);
setDataset(dataset); // Save dataset when drilling
setShowDrillByModal(true);
}, []);
const handleCloseDrillByModal = useCallback(() => {
setShowDrillByModal(false);
}, []);
const menuItems: React.JSX.Element[] = [];
const menuItems = [];
const showDrillToDetail =
isFeatureEnabled(FeatureFlag.DrillToDetail) &&
@@ -267,9 +249,9 @@ const ChartContextMenu = (
formData={formData}
contextMenuY={clientY}
submenuIndex={submenuIndex}
canDownload={canDownload}
open={openKeys.includes('drill-by-submenu')}
key="drill-by-submenu"
onDrillBy={handleDrillBy}
{...(additionalConfig?.drillBy || {})}
/>,
);
@@ -304,7 +286,7 @@ const ChartContextMenu = (
return ReactDOM.createPortal(
<>
<Dropdown
dropdownRender={() => (
overlay={
<Menu
className="chart-context-menu"
data-test="chart-context-menu"
@@ -320,15 +302,15 @@ const ChartContextMenu = (
<Menu.Item disabled>{t('No actions')}</Menu.Item>
)}
</Menu>
)}
}
trigger={['click']}
onOpenChange={value => {
onVisibleChange={value => {
setVisible(value);
if (!value) {
setOpenKeys([]);
}
}}
open={visible}
visible={visible}
>
<span
id={`hidden-span-${id}`}
@@ -353,16 +335,6 @@ const ChartContextMenu = (
}}
/>
)}
{showDrillByModal && drillByColumn && dataset && filters?.drillBy && (
<DrillByModal
column={drillByColumn}
drillByConfig={filters?.drillBy}
formData={formData}
onHideModal={handleCloseDrillByModal}
dataset={{ ...dataset!, verbose_map: verboseMap }}
canDownload={canDownload}
/>
)}
</>,
document.body,
);

View File

@@ -16,19 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
import userEvent from '@testing-library/user-event';
import {
Behavior,
ChartMetadata,
getChartMetadataRegistry,
} from '@superset-ui/core';
import fetchMock from 'fetch-mock';
import {
render,
screen,
userEvent,
within,
waitFor,
} from 'spec/helpers/testing-library';
import { render, screen, within, waitFor } from 'spec/helpers/testing-library';
import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
import { Menu } from 'src/components/Menu';
import { supersetGetCache } from 'src/utils/cachedSupersetGet';
@@ -79,6 +74,7 @@ const renderMenu = ({
<DrillByMenuItems
formData={formData ?? defaultFormData}
drillByConfig={drillByConfig}
canDownload
open
{...rest}
/>
@@ -168,9 +164,6 @@ test('render menu item with submenu without searchbox', async () => {
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});
// Add global timeout for all tests
jest.setTimeout(20000);
test('render menu item with submenu and searchbox', async () => {
fetchMock.get(DATASET_ENDPOINT, {
result: { columns: defaultColumns },
@@ -178,32 +171,18 @@ test('render menu item with submenu and searchbox', async () => {
renderMenu({});
await waitFor(() => fetchMock.called(DATASET_ENDPOINT));
await expectDrillByEnabled();
defaultColumns.forEach(column => {
expect(screen.getByText(column.column_name)).toBeInTheDocument();
});
// Wait for all columns to be visible
await waitFor(
() => {
defaultColumns.forEach(column => {
expect(screen.getByText(column.column_name)).toBeInTheDocument();
});
},
{ timeout: 10000 },
);
const searchbox = await waitFor(
() => screen.getAllByPlaceholderText('Search columns')[1],
);
const searchbox = screen.getAllByPlaceholderText('Search columns')[1];
expect(searchbox).toBeInTheDocument();
userEvent.type(searchbox, 'col1');
const expectedFilteredColumnNames = ['col1', 'col10', 'col11'];
await screen.findByText('col1');
// Wait for filtered results
await waitFor(() => {
expectedFilteredColumnNames.forEach(colName => {
expect(screen.getByText(colName)).toBeInTheDocument();
});
});
const expectedFilteredColumnNames = ['col1', 'col10', 'col11'];
defaultColumns
.filter(col => !expectedFilteredColumnNames.includes(col.column_name))
@@ -231,22 +210,16 @@ test('Do not display excluded column in the menu', async () => {
await waitFor(() => fetchMock.called(DATASET_ENDPOINT));
await expectDrillByEnabled();
// Wait for menu items to be loaded
await waitFor(
() => {
defaultColumns
.filter(column => !excludedColNames.includes(column.column_name))
.forEach(column => {
expect(screen.getByText(column.column_name)).toBeInTheDocument();
});
},
{ timeout: 10000 },
);
excludedColNames.forEach(colName => {
expect(screen.queryByText(colName)).not.toBeInTheDocument();
});
}, 20000);
defaultColumns
.filter(column => !excludedColNames.includes(column.column_name))
.forEach(column => {
expect(screen.getByText(column.column_name)).toBeInTheDocument();
});
});
test('When menu item is clicked, call onSelection with clicked column and drill by filters', async () => {
fetchMock
@@ -264,10 +237,7 @@ test('When menu item is clicked, call onSelection with clicked column and drill
await waitFor(() => fetchMock.called(DATASET_ENDPOINT));
await expectDrillByEnabled();
// Wait for col1 to be visible before clicking
const col1Element = await waitFor(() => screen.getByText('col1'));
userEvent.click(col1Element);
userEvent.click(screen.getByText('col1'));
expect(onSelectionMock).toHaveBeenCalledWith(
{
column_name: 'col1',
@@ -275,4 +245,4 @@ test('When menu item is clicked, call onSelection with clicked column and drill
},
{ filters: defaultFilters, groupbyFieldName: 'groupby' },
);
}, 20000);
});

View File

@@ -53,8 +53,10 @@ import {
cachedSupersetGet,
supersetGetCache,
} from 'src/utils/cachedSupersetGet';
import { useVerboseMap } from 'src/hooks/apiResources/datasets';
import { InputRef } from 'antd-v5';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import DrillByModal from './DrillByModal';
import { getSubmenuYOffset } from '../utils';
import { MenuItemWithTruncation } from '../MenuItemWithTruncation';
import { Dataset } from '../types';
@@ -72,8 +74,8 @@ export interface DrillByMenuItemsProps {
onClick?: (event: MouseEvent) => void;
openNewModal?: boolean;
excludedColumns?: Column[];
canDownload: boolean;
open: boolean;
onDrillBy?: (column: Column, dataset: Dataset) => void;
}
const loadDrillByOptions = getExtensionsRegistry().get('load.drillby.options');
@@ -104,8 +106,8 @@ export const DrillByMenuItems = ({
onClick = () => {},
excludedColumns,
openNewModal = true,
canDownload,
open,
onDrillBy,
...rest
}: DrillByMenuItemsProps) => {
const theme = useTheme();
@@ -115,20 +117,25 @@ export const DrillByMenuItems = ({
const [debouncedSearchInput, setDebouncedSearchInput] = useState('');
const [dataset, setDataset] = useState<Dataset>();
const [columns, setColumns] = useState<Column[]>([]);
const [showModal, setShowModal] = useState(false);
const [currentColumn, setCurrentColumn] = useState();
const ref = useRef<InputRef>(null);
const showSearch =
loadDrillByOptions || columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD;
const handleSelection = useCallback(
(event, column) => {
onClick(event);
onSelection(column, drillByConfig);
if (openNewModal && onDrillBy && dataset) {
onDrillBy(column, dataset);
setCurrentColumn(column);
if (openNewModal) {
setShowModal(true);
}
},
[drillByConfig, onClick, onSelection, openNewModal, onDrillBy, dataset],
[drillByConfig, onClick, onSelection, openNewModal],
);
const closeModal = useCallback(() => {
setShowModal(false);
}, []);
useEffect(() => {
if (open) {
@@ -149,6 +156,7 @@ export const DrillByMenuItems = ({
?.behaviors.find(behavior => behavior === Behavior.DrillBy),
[formData.viz_type],
);
const verboseMap = useVerboseMap(dataset);
useEffect(() => {
async function loadOptions() {
@@ -267,11 +275,11 @@ export const DrillByMenuItems = ({
const column = columns[index];
return (
<MenuItemWithTruncation
menuKey={`drill-by-item-${column.column_name}`}
key={`drill-by-item-${column.column_name}`}
tooltipText={column.verbose_name || column.column_name}
{...rest}
onClick={e => handleSelection(e, column)}
style={style}
{...rest}
>
{column.verbose_name || column.column_name}
</MenuItemWithTruncation>
@@ -281,7 +289,6 @@ export const DrillByMenuItems = ({
return (
<>
<Menu.SubMenu
key="drill-by-submenu"
title={t('Drill by')}
popupClassName="chart-context-submenu"
popupOffset={[0, submenuYOffset]}
@@ -342,6 +349,16 @@ export const DrillByMenuItems = ({
)}
</div>
</Menu.SubMenu>
{showModal && (
<DrillByModal
column={currentColumn}
drillByConfig={drillByConfig}
formData={formData}
onHideModal={closeModal}
dataset={{ ...dataset!, verbose_map: verboseMap }}
canDownload={canDownload}
/>
)}
</>
);
};

View File

@@ -20,13 +20,9 @@
import { useState } from 'react';
import fetchMock from 'fetch-mock';
import { omit, omitBy } from 'lodash';
import {
render,
screen,
userEvent,
waitFor,
within,
} from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { waitFor, within } from '@testing-library/react';
import { render, screen } from 'spec/helpers/testing-library';
import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
import mockState from 'spec/fixtures/mockState';
import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';

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