Compare commits

...

36 Commits

Author SHA1 Message Date
Levis Mbote
79aff6827c refactor(Alert): Migrate Alert component to Ant Design V5 (#31168)
Co-authored-by: Diego Pucci <diegopucci.me@gmail.com>
2024-12-06 21:26:04 +02:00
alexandrusoare
079e7327a2 chore(FilterBar): move the "Add/edit filters" button in the FilterBar to the settings menu (#31290)
Co-authored-by: Geido <60598000+geido@users.noreply.github.com>
Co-authored-by: Diego Pucci <diegopucci.me@gmail.com>
2024-12-06 20:52:55 +02:00
JUST.in DO IT
48864ce8c7 fix(sqllab): Remove update_saved_query_exec_info to reduce lag (#31294) 2024-12-06 10:09:02 -08:00
Damian Pendrak
2816a70af3 fix: annotations on horizontal bar chart (#31308) 2024-12-05 13:20:22 -08:00
Levis Mbote
6af22a9cdd refactor(Name_column): Make 'Name' column of Saved Query page into links (#31312) 2024-12-05 13:17:30 -08:00
dependabot[bot]
827fe06903 chore(deps): bump deck.gl from 9.0.34 to 9.0.36 in /superset-frontend/plugins/legacy-preset-chart-deckgl (#31203)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-05 13:03:39 -07:00
Evan Rusackas
45815d8642 fix(filters): improving the add filter/divider UI. (#31279) 2024-12-05 09:13:24 -07:00
Oleg Ovcharuk
cf5c770adc feat: add YDB as a new database engine (#31141) 2024-12-05 09:14:19 -05:00
Joe Li
638f82b46d chore: relax greenlet requirements (#31275)
Co-authored-by: Maxime Beauchemin <maximebeauchemin@gmail.com>
2024-12-04 14:02:25 -08:00
dependabot[bot]
e0e1eea9ce chore(deps-dev): bump typescript from 5.6.3 to 5.7.2 in /docs (#31205)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-04 14:17:41 -07:00
dependabot[bot]
27c7240185 chore(deps): bump @algolia/client-search from 5.12.0 to 5.15.0 in /docs (#31207)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-04 11:44:34 -07:00
dependabot[bot]
5ca2a8f670 chore(deps): bump less from 4.2.0 to 4.2.1 in /docs (#31208)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-04 11:43:22 -07:00
dependabot[bot]
2d60a2d48c chore(deps-dev): bump @docusaurus/tsconfig from 3.5.2 to 3.6.3 in /docs (#31204)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-04 11:42:28 -07:00
dependabot[bot]
b70c8ee7a8 chore(deps): bump swagger-ui-react from 5.17.14 to 5.18.2 in /docs (#31206)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-04 11:22:32 -07:00
dependabot[bot]
a3fd7423b0 chore(deps-dev): bump @types/jest from 29.5.12 to 29.5.14 in /superset-websocket (#31224)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-03 23:45:20 -07:00
dependabot[bot]
f679a18e82 chore(deps): bump @types/react-table from 7.7.19 to 7.7.20 in /superset-frontend (#31228)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-03 23:45:05 -07:00
Vitor Avila
77f3764fea feat(Handlebars): formatNumber and group helpers (#31261) 2024-12-03 17:55:57 -03:00
JUST.in DO IT
1e0c04fc15 fix(trino): db session error in handle cursor (#31265) 2024-12-03 11:57:37 -08:00
dependabot[bot]
56b973f3cc chore(deps-dev): bump @docusaurus/module-type-aliases from 3.5.2 to 3.6.3 in /docs (#31210)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-03 10:19:29 -07:00
dependabot[bot]
3479574bd4 chore(deps): bump @ant-design/icons from 5.5.1 to 5.5.2 in /docs (#31213)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-03 10:18:43 -07:00
dependabot[bot]
aa55751b1d chore(deps): bump @scarf/scarf from 1.3.0 to 1.4.0 in /superset-frontend (#31230)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-03 10:17:49 -07:00
Sam Firke
6c2aade375 chore(bug report template): bump Superset versions to reflect 4.1.1 release (#31259) 2024-12-03 12:14:36 -05:00
dependabot[bot]
f51f19bcba chore(deps): bump re-resizable from 6.10.0 to 6.10.1 in /superset-frontend (#31231)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-03 10:13:14 -07:00
Kamil Gabryjelski
1d44662b1d refactor: Split SliceHeaderControls into smaller files (#31270) 2024-12-03 16:36:39 +01:00
Daniel Vaz Gaspar
25f4226dbb fix: add more clickhouse disallowed functions on config (#31198) 2024-12-03 10:48:06 +00:00
Maxime Beauchemin
dd1ba96adf feat: use uv in CI (#31260) 2024-12-02 18:16:56 -08:00
Maxime Beauchemin
d4888fa4af docs: adapt docs to suggest 'docker compose up --build' (#30864) 2024-12-02 18:03:13 -08:00
Maxime Beauchemin
b3559f644c chore: simplify Dockerfile package install calls with bash wrappers (#31034) 2024-12-02 17:57:01 -08:00
dependabot[bot]
fe80fb1090 chore(deps): bump codecov/codecov-action from 4 to 5 (#31214)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-02 17:50:12 -08:00
github-actions[bot]
43efa05113 chore(🦾): bump python flask-migrate subpackage(s) (#31250)
Co-authored-by: GitHub Action <action@github.com>
2024-12-02 17:42:35 -08:00
github-actions[bot]
e5e3f9e210 chore(🦾): bump python nh3 0.2.18 -> 0.2.19 (#31249)
Co-authored-by: GitHub Action <action@github.com>
2024-12-02 17:42:08 -08:00
github-actions[bot]
468dfed416 chore(🦾): bump python pyjwt 2.10.0 -> 2.10.1 (#31253)
Co-authored-by: GitHub Action <action@github.com>
2024-12-02 17:41:41 -08:00
Maxime Beauchemin
3564740255 chore: pin greenlet in base dependencies (#31254) 2024-12-02 16:41:15 -08:00
Joe Li
8020729ced fix: check for column before adding in migrations (#31185) 2024-12-02 13:47:55 -08:00
Maxime Beauchemin
deec63bb5b docs(contributing): how to nuke the docker-compose postgres (#31186) 2024-12-02 11:50:49 -08:00
JUST.in DO IT
339d491dfc feat(sqllab): Popup notification when download data can exceed row count (#31187)
Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>
2024-12-02 11:15:25 -08:00
86 changed files with 2270 additions and 1547 deletions

View File

@@ -41,8 +41,8 @@ body:
label: Superset version
options:
- master / latest-dev
- "4.1.0"
- "3.1.3"
- "4.1.1"
- "4.0.2"
validations:
required: true
- type: dropdown

View File

@@ -42,12 +42,12 @@ runs:
- name: Install dependencies
run: |
if [ "${{ inputs.install-superset }}" = "true" ]; then
sudo apt-get update && sudo apt-get -y install libldap2-dev libsasl2-dev build-essential
pip install --upgrade pip setuptools wheel
sudo apt-get update && sudo apt-get -y install libldap2-dev libsasl2-dev
pip install --upgrade pip setuptools wheel uv
if [ "${{ inputs.requirements-type }}" = "dev" ]; then
pip install -r requirements/development.txt
uv pip install --system -r requirements/development.txt
elif [ "${{ inputs.requirements-type }}" = "base" ]; then
pip install -r requirements/base.txt
uv pip install --system -r requirements/base.txt
fi
fi
shell: bash

View File

@@ -73,7 +73,7 @@ jobs:
working-directory: ./superset-frontend/packages/generator-superset
run: npm run test
- name: Upload code coverage
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
with:
flags: javascript
token: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -68,7 +68,7 @@ jobs:
run: |
./scripts/python_tests.sh
- name: Upload code coverage
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
with:
flags: python,mysql
token: ${{ secrets.CODECOV_TOKEN }}
@@ -129,7 +129,7 @@ jobs:
run: |
./scripts/python_tests.sh
- name: Upload code coverage
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
with:
flags: python,postgres
token: ${{ secrets.CODECOV_TOKEN }}
@@ -181,7 +181,7 @@ jobs:
run: |
./scripts/python_tests.sh
- name: Upload code coverage
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
with:
flags: python,sqlite
token: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -77,7 +77,7 @@ jobs:
run: |
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
- name: Upload code coverage
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
with:
flags: python,presto
token: ${{ secrets.CODECOV_TOKEN }}
@@ -145,7 +145,7 @@ jobs:
pip install -e .[hive]
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
- name: Upload code coverage
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
with:
flags: python,hive
token: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -46,7 +46,7 @@ jobs:
run: |
pytest --durations-min=0.5 --cov-report= --cov=superset ./tests/common ./tests/unit_tests --cache-clear
- name: Upload code coverage
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
with:
flags: python,unit
token: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -70,6 +70,7 @@ google-sheets.svg
ibm-db2.svg
postgresql.svg
snowflake.svg
ydb.svg
# docs-related
erd.puml

View File

@@ -20,44 +20,38 @@
######################################################################
ARG PY_VER=3.10-slim-bookworm
# if BUILDPLATFORM is null, set it to 'amd64' (or leave as is otherwise).
# If BUILDPLATFORM is null, set it to 'amd64' (or leave as is otherwise).
ARG BUILDPLATFORM=${BUILDPLATFORM:-amd64}
FROM --platform=${BUILDPLATFORM} node:20-bullseye-slim AS superset-node
# Arguments for build configuration
ARG NPM_BUILD_CMD="build"
ARG BUILD_TRANSLATIONS="false" # Include translations in the final build
ARG DEV_MODE="false" # Skip frontend build in dev mode
ARG INCLUDE_CHROMIUM="true" # Include headless Chromium for alerts & reports
ARG INCLUDE_FIREFOX="false" # Include headless Firefox if enabled
# Include translations in the final build. The default supports en only to
# reduce complexity and weight for those only using en
ARG BUILD_TRANSLATIONS="false"
# Used by docker-compose to skip the frontend build,
# in dev we mount the repo and build the frontend inside docker
ARG DEV_MODE="false"
# Include headless browsers? Allows for alerts, reports & thumbnails, but bloats the images
ARG INCLUDE_CHROMIUM="true"
ARG INCLUDE_FIREFOX="false"
# Somehow we need python3 + build-essential on this side of the house to install node-gyp
RUN apt-get update -qq \
&& apt-get install \
-yqq --no-install-recommends \
build-essential \
python3 \
zstd
# Install system dependencies required for node-gyp
RUN --mount=type=bind,source=./docker,target=/docker \
/docker/apt-install.sh build-essential python3 zstd
# Define environment variables for frontend build
ENV BUILD_CMD=${NPM_BUILD_CMD} \
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
# NPM ci first, as to NOT invalidate previous steps except for when package.json changes
RUN --mount=type=bind,target=/frontend-mem-nag.sh,src=./docker/frontend-mem-nag.sh \
/frontend-mem-nag.sh
# Run the frontend memory monitoring script
RUN --mount=type=bind,source=./docker,target=/docker \
/docker/frontend-mem-nag.sh
WORKDIR /app/superset-frontend
# Creating empty folders to avoid errors when running COPY later on
RUN mkdir -p /app/superset/static/assets
RUN --mount=type=bind,target=./package.json,src=./superset-frontend/package.json \
--mount=type=bind,target=./package-lock.json,src=./superset-frontend/package-lock.json \
# Create necessary folders to avoid errors in subsequent steps
RUN mkdir -p /app/superset/static/assets \
/app/superset/translations
# Mount package files and install dependencies if not in dev mode
RUN --mount=type=bind,source=./superset-frontend/package.json,target=./package.json \
--mount=type=bind,source=./superset-frontend/package-lock.json,target=./package-lock.json \
if [ "$DEV_MODE" = "false" ]; then \
npm ci; \
else \
@@ -66,33 +60,39 @@ RUN --mount=type=bind,target=./package.json,src=./superset-frontend/package.json
# Runs the webpack build process
COPY superset-frontend /app/superset-frontend
# This copies the .po files needed for translation
RUN mkdir -p /app/superset/translations
# Copy translation files
COPY superset/translations /app/superset/translations
# Build the frontend if not in dev mode
RUN if [ "$DEV_MODE" = "false" ]; then \
BUILD_TRANSLATIONS=$BUILD_TRANSLATIONS npm run ${BUILD_CMD}; \
else \
echo "Skipping 'npm run ${BUILD_CMD}' in dev mode"; \
fi
# Compiles .json files from the .po files, then deletes the .po files
# Compile .json files from .po translations (if required) and clean up .po files
RUN if [ "$BUILD_TRANSLATIONS" = "true" ]; then \
npm run build-translation; \
else \
echo "Skipping translations as requested by build flag"; \
fi
RUN rm /app/superset/translations/*/LC_MESSAGES/*.po
RUN rm /app/superset/translations/messages.pot
fi \
# removing translations files regardless
&& rm -rf /app/superset/translations/*/LC_MESSAGES/*.po \
/app/superset/translations/messages.pot
# Transition to Python base image
FROM python:${PY_VER} AS python-base
RUN pip install --no-cache-dir --upgrade setuptools pip uv
######################################################################
# Final lean image...
######################################################################
FROM python-base AS lean
# Include translations in the final build. The default supports en only to
# reduce complexity and weight for those only using en
# Build argument for including translations
ARG BUILD_TRANSLATIONS="false"
WORKDIR /app
@@ -104,9 +104,16 @@ ENV LANG=C.UTF-8 \
SUPERSET_HOME="/app/superset_home" \
SUPERSET_PORT=8088
RUN mkdir -p ${PYTHONPATH} superset/static requirements superset-frontend apache_superset.egg-info requirements \
# Set up necessary directories and user
RUN --mount=type=bind,source=./docker,target=/docker \
mkdir -p ${PYTHONPATH} \
superset/static \
requirements \
superset-frontend \
apache_superset.egg-info \
requirements \
&& useradd --user-group -d ${SUPERSET_HOME} -m --no-log-init --shell /bin/bash superset \
&& apt-get update -qq && apt-get install -yqq --no-install-recommends \
&& /docker/apt-install.sh \
curl \
libsasl2-dev \
libsasl2-modules-gssapi-mit \
@@ -117,58 +124,62 @@ RUN mkdir -p ${PYTHONPATH} superset/static requirements superset-frontend apache
&& chown -R superset:superset ./* \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
# Copy required files for Python build
COPY --chown=superset:superset pyproject.toml setup.py MANIFEST.in README.md ./
# setup.py uses the version information in package.json
COPY --chown=superset:superset superset-frontend/package.json superset-frontend/
COPY --chown=superset:superset requirements/base.txt requirements/
COPY --chown=superset:superset scripts/check-env.py scripts/
RUN --mount=type=cache,target=/root/.cache/pip \
apt-get update -qq && apt-get install -yqq --no-install-recommends \
build-essential \
&& pip install --no-cache-dir --upgrade setuptools pip \
&& pip install --no-cache-dir -r requirements/base.txt \
&& apt-get autoremove -yqq --purge build-essential \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
# Copy the compiled frontend assets
# Install Python dependencies using docker/pip-install.sh
RUN --mount=type=bind,source=./docker,target=/docker \
--mount=type=cache,target=/root/.cache/pip \
/docker/pip-install.sh --requires-build-essential -r requirements/base.txt
# Copy the compiled frontend assets from the node image
COPY --chown=superset:superset --from=superset-node /app/superset/static/assets superset/static/assets
## Lastly, let's install superset itself
# Copy the main Superset source code
COPY --chown=superset:superset superset superset
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -e .
# Copy the .json translations from the frontend layer
# Install Superset itself using docker/pip-install.sh
RUN --mount=type=bind,source=./docker,target=/docker \
--mount=type=cache,target=/root/.cache/pip \
/docker/pip-install.sh -e .
# Copy .json translations from the node image
COPY --chown=superset:superset --from=superset-node /app/superset/translations superset/translations
# Compile translations for the backend - this generates .mo files, then deletes the .po files
# Compile backend translations and clean up
COPY ./scripts/translations/generate_mo_files.sh ./scripts/translations/
RUN if [ "$BUILD_TRANSLATIONS" = "true" ]; then \
./scripts/translations/generate_mo_files.sh \
&& chown -R superset:superset superset/translations \
&& rm superset/translations/messages.pot \
&& rm superset/translations/*/LC_MESSAGES/*.po; \
else \
echo "Skipping translations as requested by build flag"; \
fi
&& chown -R superset:superset superset/translations; \
fi \
&& rm -rf superset/translations/messages.pot \
superset/translations/*/LC_MESSAGES/*.po
# Add server run script
COPY --chmod=755 ./docker/run-server.sh /usr/bin/
USER superset
# Set user and healthcheck
USER superset
HEALTHCHECK CMD curl -f "http://localhost:${SUPERSET_PORT}/health"
# Expose port and set CMD
EXPOSE ${SUPERSET_PORT}
CMD ["/usr/bin/run-server.sh"]
######################################################################
# Dev image...
######################################################################
FROM lean AS dev
USER root
RUN apt-get update -qq \
&& apt-get install -yqq --no-install-recommends \
# Install dev dependencies
RUN --mount=type=bind,source=./docker,target=/docker \
/docker/apt-install.sh \
libnss3 \
libdbus-glib-1-2 \
libgtk-3-0 \
@@ -176,46 +187,46 @@ RUN apt-get update -qq \
libasound2 \
libxtst6 \
git \
pkg-config \
&& rm -rf /var/cache/apt/archives/* /var/lib/apt/lists/*
pkg-config
# Install Playwright and its dependencies
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir playwright
RUN playwright install-deps
uv pip install --system playwright \
&& playwright install-deps
# Optionally install Chromium
RUN if [ "$INCLUDE_CHROMIUM" = "true" ]; then \
playwright install chromium; \
else \
echo "Skipping translations in dev mode"; \
echo "Skipping Chromium installation in dev mode"; \
fi
# Install GeckoDriver WebDriver
ARG GECKODRIVER_VERSION=v0.34.0 \
FIREFOX_VERSION=125.0.3
RUN if [ "$INCLUDE_FIREFOX" = "true" ]; then \
apt-get update -qq \
&& apt-get install -yqq --no-install-recommends wget bzip2 \
# Install GeckoDriver WebDriver and Firefox (if required)
ARG GECKODRIVER_VERSION=v0.34.0
ARG FIREFOX_VERSION=125.0.3
RUN --mount=type=bind,source=./docker,target=/docker \
if [ "$INCLUDE_FIREFOX" = "true" ]; then \
/docker/apt-install.sh wget bzip2 \
&& wget -q https://github.com/mozilla/geckodriver/releases/download/${GECKODRIVER_VERSION}/geckodriver-${GECKODRIVER_VERSION}-linux64.tar.gz -O - | tar xfz - -C /usr/local/bin \
&& wget -q https://download-installer.cdn.mozilla.net/pub/firefox/releases/${FIREFOX_VERSION}/linux-x86_64/en-US/firefox-${FIREFOX_VERSION}.tar.bz2 -O - | tar xfj - -C /opt \
&& ln -s /opt/firefox/firefox /usr/local/bin/firefox \
&& apt-get autoremove -yqq --purge wget bzip2 && rm -rf /var/[log,tmp]/* /tmp/* /var/lib/apt/lists/* /var/cache/apt/archives/*; \
else \
echo "Skipping Firefox installation in dev mode"; \
fi
# Installing mysql client os-level dependencies in dev image only because GPL
RUN apt-get install -yqq --no-install-recommends \
default-libmysqlclient-dev \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
# Install MySQL client dependencies
RUN --mount=type=bind,source=./docker,target=/docker \
/docker/apt-install.sh default-libmysqlclient-dev
# Copy development requirements and install them
COPY --chown=superset:superset requirements/development.txt requirements/
RUN --mount=type=cache,target=/root/.cache/pip \
apt-get update -qq && apt-get install -yqq --no-install-recommends \
build-essential \
&& pip install --no-cache-dir -r requirements/development.txt \
&& apt-get autoremove -yqq --purge build-essential \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
RUN --mount=type=bind,source=./docker,target=/docker \
--mount=type=cache,target=/root/.cache/pip \
/docker/pip-install.sh --requires-build-essential -r requirements/development.txt
USER superset
######################################################################
# CI image...
######################################################################

View File

@@ -136,6 +136,7 @@ Here are some of the major database solutions that are supported:
<img src="https://superset.apache.org/img/databases/oceanbase.svg" alt="oceanbase" border="0" width="220" />
<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" />
</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

@@ -24,6 +24,7 @@ assists people when migrating to a new version.
## Next
- [31198](https://github.com/apache/superset/pull/31198) Disallows by default the use of the following ClickHouse functions: "version", "currentDatabase", "hostName".
- [29798](https://github.com/apache/superset/pull/29798) Since 3.1.0, the intial schedule for an alert or report was mistakenly offset by the specified timezone's relation to UTC. The initial schedule should now begin at the correct time.
- [30021](https://github.com/apache/superset/pull/30021) The `dev` layer in our Dockerfile no long includes firefox binaries, only Chromium to reduce bloat/docker-build-time.
- [30099](https://github.com/apache/superset/pull/30099) Translations are no longer included in the default docker image builds. If your environment requires translations, you'll want to set the docker build arg `BUILD_TRANSACTION=true`.

51
docker/apt-install.sh Executable file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
set -euo pipefail
# Ensure this script is run as root
if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root" >&2
exit 1
fi
# Check for required arguments
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <package1> [<package2> ...]" >&2
exit 1
fi
# Colors for better logging (optional)
GREEN='\033[0;32m'
RED='\033[0;31m'
RESET='\033[0m'
# Install packages with clean-up
echo -e "${GREEN}Updating package lists...${RESET}"
apt-get update -qq
echo -e "${GREEN}Installing packages: $@${RESET}"
apt-get install -yqq --no-install-recommends "$@"
echo -e "${GREEN}Autoremoving unnecessary packages...${RESET}"
apt-get autoremove -y
echo -e "${GREEN}Cleaning up package cache and metadata...${RESET}"
apt-get clean
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* /tmp/* /var/tmp/*
echo -e "${GREEN}Installation and cleanup complete.${RESET}"

64
docker/pip-install.sh Executable file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
set -euo pipefail
# Default flag
REQUIRES_BUILD_ESSENTIAL=false
USE_CACHE=true
# Filter arguments
ARGS=()
for arg in "$@"; do
case "$arg" in
--requires-build-essential)
REQUIRES_BUILD_ESSENTIAL=true
;;
--no-cache)
USE_CACHE=false
;;
*)
ARGS+=("$arg")
;;
esac
done
# Install build-essential if required
if $REQUIRES_BUILD_ESSENTIAL; then
echo "Installing build-essential for package builds..."
apt-get update -qq \
&& apt-get install -yqq --no-install-recommends build-essential
fi
# Choose whether to use pip cache
if $USE_CACHE; then
echo "Using pip cache..."
uv pip install --system "${ARGS[@]}"
else
echo "Disabling pip cache..."
uv pip install --system --no-cache-dir "${ARGS[@]}"
fi
# Remove build-essential if it was installed
if $REQUIRES_BUILD_ESSENTIAL; then
echo "Removing build-essential to keep the image lean..."
apt-get autoremove -yqq --purge build-essential \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
fi
echo "Python packages installed successfully."

View File

@@ -81,6 +81,7 @@ are compatible with Superset.
| [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}` |
| [Vertica](/docs/configuration/databases#vertica) | `pip install sqlalchemy-vertica-python` | `vertica+vertica_python://<UserName>:<DBPassword>@<Database Host>/<Database Name>` |
| [YDB](/docs/configuration/databases#ydb) | `pip install ydb-sqlalchemy` | `ydb://{host}:{port}/{database_name}` |
| [YugabyteDB](/docs/configuration/databases#yugabytedb) | `pip install psycopg2` | `postgresql://<UserName>:<DBPassword>@<Database Host>/<Database Name>` |
---
@@ -1537,6 +1538,78 @@ Other parameters:
- Load Balancer - Backup Host
#### YDB
The recommended connector library for [YDB](https://ydb.tech/) is
[ydb-sqlalchemy](https://pypi.org/project/ydb-sqlalchemy/).
##### Connection String
The connection string for YDB looks like this:
```
ydb://{host}:{port}/{database_name}
```
##### Protocol
You can specify `protocol` in the `Secure Extra` field at `Advanced / Security`:
```
{
"protocol": "grpcs"
}
```
Default is `grpc`.
##### Authentication Methods
###### Static Credentials
To use `Static Credentials` you should provide `username`/`password` in the `Secure Extra` field at `Advanced / Security`:
```
{
"credentials": {
"username": "...",
"password": "..."
}
}
```
###### Access Token Credentials
To use `Access Token Credentials` you should provide `token` in the `Secure Extra` field at `Advanced / Security`:
```
{
"credentials": {
"token": "...",
}
}
```
##### Service Account Credentials
To use Service Account Credentials, you should provide `service_account_json` in the `Secure Extra` field at `Advanced / Security`:
```
{
"credentials": {
"service_account_json": {
"id": "...",
"service_account_id": "...",
"created_at": "...",
"key_algorithm": "...",
"public_key": "...",
"private_key": "..."
}
}
}
```
#### YugabyteDB
[YugabyteDB](https://www.yugabyte.com/) is a distributed SQL database built on top of PostgreSQL.

View File

@@ -32,7 +32,9 @@ cd superset
Setting things up to squeeze a "hello world" into any part of Superset should be as simple as
```bash
docker compose up
# getting docker compose to fire up services, and rebuilding if some docker layers have changed
# using the `--build` suffix may be slower and optional if layers like py dependencies haven't changed
docker compose up --build
```
Note that:
@@ -70,6 +72,24 @@ documentation.
configured to be secure.
:::
### Nuking the postgres database
At times, it's possible to end up with your development database in a bad state, it's
common while switching branches that contain migrations for instance, where the database
version stamp that `alembic` manages is no longer available after switching branch.
In that case, the easy solution is to nuke the postgres db and start fresh. Note that the full
state of the database will be gone after doing this, so be cautious.
```bash
# first stop docker-compose if it's running
docker-compose down
# delete the volume containing the database
docker volume rm superset_db_home
# restart docker-compose, which will init a fresh database and load examples
docker-compose up
```
## Installing Development Tools
:::note
@@ -676,7 +696,7 @@ If you already have launched Docker environment please use the following command
Launch environment:
`CYPRESS_CONFIG=true docker compose up`
`CYPRESS_CONFIG=true docker compose up --build`
It will serve the backend and frontend on port 8088.
@@ -744,7 +764,7 @@ superset:
Start Superset as usual
```bash
docker compose up
docker compose up --build
```
Install the required libraries and packages to the docker container

View File

@@ -76,7 +76,8 @@ on latest base images using `docker compose build --pull`. In most cases though,
### Option #1 - for an interactive development environment
```bash
docker compose up
# The --build argument insures all the layers are up-to-date
docker compose up --build
```
:::tip
@@ -235,3 +236,11 @@ may want to find the exact hostname you want to use, for that you can do `ifconf
Docker for you. Alternately if you don't even see the `docker0` interface try (if needed with sudo)
`docker network inspect bridge` and see if there is an entry for `"Gateway"` and note the IP
address.
## 4. To build or not to build
When running `docker compose up`, docker will build what is required behind the scene, but
may use the docker cache if assets already exist. Running `docker compose build` prior to
`docker compose up` or the equivalent shortcut `docker compose up --build` ensures that your
docker images matche the definition in the repository. This should only apply to the main
docker-compose.yml file (default) and not to the alternative methods defined above.

View File

@@ -17,8 +17,8 @@
"typecheck": "tsc"
},
"dependencies": {
"@algolia/client-search": "^5.12.0",
"@ant-design/icons": "^5.4.0",
"@algolia/client-search": "^5.15.0",
"@ant-design/icons": "^5.5.2",
"@docsearch/react": "^3.6.3",
"@docusaurus/core": "^3.5.2",
"@docusaurus/plugin-client-redirects": "^3.5.2",
@@ -34,7 +34,7 @@
"clsx": "^2.1.1",
"docusaurus-plugin-less": "^2.0.2",
"file-loader": "^6.2.0",
"less": "^4.2.0",
"less": "^4.2.1",
"less-loader": "^11.0.0",
"prism-react-renderer": "^2.4.0",
"react": "^18.3.1",
@@ -42,14 +42,14 @@
"react-github-btn": "^1.4.0",
"react-svg-pan-zoom": "^3.13.1",
"stream": "^0.0.3",
"swagger-ui-react": "^5.17.14",
"swagger-ui-react": "^5.18.2",
"url-loader": "^4.1.1"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.5.2",
"@docusaurus/tsconfig": "^3.5.2",
"@docusaurus/module-type-aliases": "^3.6.3",
"@docusaurus/tsconfig": "^3.6.3",
"@types/react": "^18.3.12",
"typescript": "^5.6.3",
"typescript": "^5.7.2",
"webpack": "^5.96.1"
},
"browserslist": {

20
docs/static/img/databases/ydb.svg vendored Normal file
View File

@@ -0,0 +1,20 @@
<svg width="753" height="274" viewBox="0 0 753 274" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_28_1297)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 53.8669C5 37.6466 29.6243 29 60 29C90.3757 29 115 37.6466 115 53.8669V138.133C115 154.353 90.3757 163 60 163C29.6243 163 5 154.353 5 138.133V53.8669Z" fill="#2399FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M175 53.8669C175 37.6466 199.624 29 230 29C260.376 29 285 37.6466 285 53.8669V138.133C285 154.353 260.376 163 230 163C199.624 163 175 154.353 175 138.133V53.8669Z" fill="#2399FF"/>
<path d="M177 85H113V103H177V85Z" fill="#2399FF"/>
<path d="M173 157H115L81 111H59L105 173H183L229 111H207L173 157Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M89 145.867C89 129.647 113.624 121 144 121C174.376 121 199 129.647 199 145.867V230.133C199 246.353 174.376 255 144 255C113.624 255 89 246.353 89 230.133V145.867Z" fill="#2399FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M108.783 136.779C100.111 140.552 99 144.237 99 146C99 147.763 100.111 151.448 108.783 155.221C117.076 158.829 129.435 161 144 161C158.565 161 170.924 158.829 179.217 155.221C187.889 151.448 189 147.763 189 146C189 144.237 187.889 140.552 179.218 136.779C170.924 133.171 158.565 131 144 131C129.435 131 117.076 133.171 108.783 136.779Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.7825 44.7792C16.1105 48.5515 15 52.2365 15 54C15 55.7635 16.1105 59.4485 24.7825 63.2208C33.0763 66.8287 45.4354 69 60 69C74.5646 69 86.9237 66.8287 95.2175 63.2208C103.889 59.4485 105 55.7635 105 54C105 52.2365 103.889 48.5515 95.2175 44.7792C86.9237 41.1713 74.5646 39 60 39C45.4354 39 33.0763 41.1713 24.7825 44.7792Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M194.783 44.7792C186.111 48.5515 185 52.2365 185 54C185 55.7635 186.111 59.4485 194.783 63.2208C203.076 66.8287 215.435 69 230 69C244.565 69 256.924 66.8287 265.217 63.2208C273.889 59.4485 275 55.7635 275 54C275 52.2365 273.889 48.5515 265.218 44.7792C256.924 41.1713 244.565 39 230 39C215.435 39 203.076 41.1713 194.783 44.7792Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M694.131 64H634.75V210H705.026C730.974 210 750.243 191.821 750.243 166.963C750.243 150.15 740.93 137.39 726.201 130.891C733.027 124.143 737.168 115.224 737.168 104.858C737.168 81.2033 718.875 64 694.131 64ZM660.899 85.791V123.925H691.951C702.482 123.925 711.019 115.389 711.019 104.858C711.019 94.3277 702.482 85.791 691.951 85.791H660.899ZM660.899 188.209V145.716H702.847C714.581 145.716 724.093 155.229 724.093 166.963C724.093 178.697 714.581 188.209 702.847 188.209H660.899Z" fill="black"/>
<path d="M352.716 64.0039H382.134L419.179 128.287L456.223 64.0039H485.641L432.308 155.472V210.004H406.049V155.472L352.716 64.0039Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M496.008 64.0039H546.127C589.713 64.0039 619.127 92.3289 619.127 137.004C619.127 181.679 589.713 210.004 546.127 210.004H496.008V64.0039ZM522.157 188.213V85.7949H543.948C573.32 85.7949 592.978 104.364 592.978 137.004C592.978 169.644 573.32 188.213 543.948 188.213H522.157Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_28_1297">
<rect width="753" height="274" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -55,6 +55,7 @@ dependencies = [
"flask-wtf>=1.1.0, <2.0",
"func_timeout",
"geopy",
"greenlet>=3.0.3, <=3.1.1",
"gunicorn>=22.0.0; sys_platform != 'win32'",
"hashids>=1.3.1, <2",
# known issue with holidays 0.26.0 and above related to prophet lib #25017
@@ -178,14 +179,11 @@ netezza = ["nzalchemy>=11.0.2"]
starrocks = ["starrocks>=1.0.0"]
doris = ["pydoris>=1.0.0, <2.0.0"]
oceanbase = ["oceanbase_py>=0.0.1"]
ydb = ["ydb-sqlalchemy>=0.1.2"]
development = [
"docker",
"flask-testing",
"freezegun",
# playwright requires greenlet==3.0.3
# submitted a PR to relax deps in 11/2024
# https://github.com/microsoft/playwright-python/pull/2669
"greenlet==3.0.3",
"grpcio>=1.55.3",
"openapi-spec-validator",
"parameterized",

View File

@@ -24,3 +24,8 @@ numexpr>=2.9.0
# 5.0.0 has a sensitive deprecation used in other libs
# -> https://github.com/aio-libs/async-timeout/blob/master/CHANGES.rst#500-2024-10-31
async_timeout>=4.0.0,<5.0.0
# playwright requires greenlet==3.0.3
# submitted a PR to relax deps in 11/2024
# https://github.com/microsoft/playwright-python/pull/2669
greenlet==3.0.3

View File

@@ -1,4 +1,4 @@
# SHA1:cc62b2b6658afa9dbb6e81046e1084f15442858a
# SHA1:04f7e0860829f18926ea238354e6d4a6ab823d50
#
# This file is autogenerated by pip-compile-multi
# To update, run:
@@ -7,7 +7,7 @@
#
-e file:.
# via -r requirements/base.in
alembic==1.13.1
alembic==1.14.0
# via flask-migrate
amqp==5.3.1
# via kombu
@@ -155,8 +155,9 @@ google-auth==2.36.0
# via shillelagh
greenlet==3.0.3
# via
# -r requirements/base.in
# apache-superset
# shillelagh
# sqlalchemy
gunicorn==23.0.0
# via apache-superset
hashids==1.3.1
@@ -221,7 +222,7 @@ msgpack==1.0.8
# via apache-superset
msgspec==0.18.6
# via flask-session
nh3==0.2.18
nh3==0.2.19
# via apache-superset
numba==0.60.0
# via pandas
@@ -285,7 +286,7 @@ pycparser==2.22
# via cffi
pygments==2.18.0
# via rich
pyjwt==2.10.0
pyjwt==2.10.1
# via
# apache-superset
# flask-appbuilder

View File

@@ -88,6 +88,9 @@ describe('Horizontal FilterBar', () => {
cy.getBySel('horizontal-filterbar-empty')
.contains('No filters are currently added to this dashboard.')
.should('exist');
cy.get(nativeFilters.filtersPanel.filterGear).click({
force: true,
});
cy.getBySel('filter-bar__create-filter').should('exist');
cy.getBySel('filterbar-action-buttons').should('exist');
});
@@ -120,7 +123,7 @@ describe('Horizontal FilterBar', () => {
cy.getBySel('form-item-value').should('have.length', 3);
cy.viewport(768, 1024);
cy.getBySel('form-item-value').should('have.length', 0);
cy.getBySel('form-item-value').should('have.length', 1);
openMoreFilters(false);
cy.getBySel('form-item-value').should('have.length', 3);

View File

@@ -263,8 +263,10 @@ describe('Native filters', () => {
});
it('User can expand / retract native filter sidebar on a dashboard', () => {
cy.get(nativeFilters.addFilterButton.button).should('not.exist');
expandFilterOnLeftPanel();
cy.get(nativeFilters.filtersPanel.filterGear).click({
force: true,
});
cy.get(nativeFilters.filterFromDashboardView.createFilterButton).should(
'be.visible',
);

View File

@@ -228,6 +228,9 @@ export function collapseFilterOnLeftPanel() {
************************************************************************* */
export function enterNativeFilterEditModal(waitForDataset = true) {
interceptDataset();
cy.get(nativeFilters.filtersPanel.filterGear).click({
force: true,
});
cy.get(nativeFilters.filterFromDashboardView.createFilterButton).click({
force: true,
});
@@ -243,11 +246,7 @@ export function enterNativeFilterEditModal(waitForDataset = true) {
* @summary helper for adding new filter
************************************************************************* */
export function clickOnAddFilterInModal() {
cy.get(nativeFilters.addFilterButton.button).first().click();
return cy
.get(nativeFilters.addFilterButton.dropdownItem)
.contains('Filter')
.click({ force: true });
return cy.get(nativeFilters.modal.addNewFilterButton).click({ force: true });
}
/** ************************************************************************

View File

@@ -62,7 +62,7 @@ describe('Visualization > Line', () => {
'not.exist',
);
cy.get('.ant-alert-warning').should('not.exist');
cy.get('.antd5-alert-warning').should('not.exist');
});
it('should allow negative values in Y bounds', () => {
@@ -71,7 +71,7 @@ describe('Visualization > Line', () => {
cy.get('#controlSections-tab-display').click();
cy.get('span').contains('Y Axis Bounds').scrollIntoView();
cy.get('input[placeholder="Min"]').type('-0.1', { delay: 100 });
cy.get('.ant-alert-warning').should('not.exist');
cy.get('.antd5-alert-warning').should('not.exist');
});
it('should allow type to search color schemes and apply the scheme', () => {

View File

@@ -94,7 +94,7 @@ export const databasesPage = {
dbDropdown: '[class="ant-select-selection-search-input"]',
dbDropdownMenu: '.rc-virtual-list-holder-inner',
dbDropdownMenuItem: '[class="ant-select-item-option-content"]',
infoAlert: '.ant-alert',
infoAlert: '.antd5-alert',
serviceAccountInput: '[name="credentials_info"]',
connectionStep: {
modal: '.ant-modal-content',
@@ -103,7 +103,7 @@ export const databasesPage = {
helperBottom: '.helper-bottom',
postgresDatabase: '[name="database"]',
dbInput: '[name="database_name"]',
alertMessage: '.ant-alert-message',
alertMessage: '.antd5-alert-message',
errorField: '[role="alert"]',
uploadJson: '[title="Upload JSON file"]',
chooseFile: '[class="ant-btn input-upload-btn"]',
@@ -166,7 +166,7 @@ export const sqlLabView = {
renderedTableHeader: '.ReactVirtualized__Table__headerRow',
renderedTableRow: '.ReactVirtualized__Table__row',
errorBody: '.error-body',
alertMessage: '.ant-alert-message',
alertMessage: '.antd5-alert-message',
historyTable: {
header: '[role=columnheader]',
table: '.QueryTable',
@@ -325,7 +325,7 @@ export const nativeFilters = {
confirmCancelButton: dataTestLocator(
'native-filter-modal-confirm-cancel-button',
),
alertXUnsavedFilters: '.ant-alert-message',
alertXUnsavedFilters: '.antd5-alert-message',
tabsList: {
filterItemsContainer: dataTestLocator('filter-title-container'),
tabsContainer: '[class="ant-tabs-nav-list"]',
@@ -334,10 +334,8 @@ export const nativeFilters = {
},
addFilter: dataTestLocator('add-filter-button'),
defaultValueCheck: '.ant-checkbox-checked',
},
addFilterButton: {
button: `.ant-modal-content [data-test="new-dropdown-icon"]`,
dropdownItem: '.ant-dropdown-menu-item',
addNewFilterButton: dataTestLocator('add-new-filter-button'),
addNewDividerButton: dataTestLocator('add-new-divider-button'),
},
filtersPanel: {
filterName: dataTestLocator('filters-config-modal__name-input'),
@@ -348,6 +346,7 @@ export const nativeFilters = {
filterTypeInput: dataTestLocator('filters-config-modal__filter-type'),
fieldInput: dataTestLocator('field-input'),
filterTypeItem: '.ant-select-selection-item',
filterGear: dataTestLocator('filterbar-orientation-icon'),
},
filterFromDashboardView: {
filterValueInput: '[class="ant-select-selection-search-input"]',

View File

@@ -24,7 +24,7 @@
"@rjsf/core": "^5.21.1",
"@rjsf/utils": "^5.19.3",
"@rjsf/validator-ajv8": "^5.22.3",
"@scarf/scarf": "^1.3.0",
"@scarf/scarf": "^1.4.0",
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
"@superset-ui/core": "file:./packages/superset-ui-core",
"@superset-ui/legacy-plugin-chart-calendar": "file:./plugins/legacy-plugin-chart-calendar",
@@ -101,7 +101,7 @@
"prop-types": "^15.8.1",
"query-string": "^6.13.7",
"rc-trigger": "^5.3.4",
"re-resizable": "^6.10.0",
"re-resizable": "^6.10.1",
"react": "^16.13.1",
"react-ace": "^10.1.0",
"react-checkbox-tree": "^1.8.0",
@@ -202,7 +202,7 @@
"@types/react-redux": "^7.1.10",
"@types/react-router-dom": "^5.3.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.19",
"@types/react-table": "^7.7.20",
"@types/react-transition-group": "^4.4.10",
"@types/react-ultimate-pagination": "^1.2.4",
"@types/react-virtualized-auto-sizer": "^1.0.4",
@@ -9680,9 +9680,10 @@
"license": "MIT"
},
"node_modules/@scarf/scarf": {
"version": "1.3.0",
"hasInstallScript": true,
"license": "Apache-2.0"
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
"hasInstallScript": true
},
"node_modules/@sigstore/bundle": {
"version": "2.3.2",
@@ -13980,9 +13981,9 @@
}
},
"node_modules/@types/react-table": {
"version": "7.7.19",
"dev": true,
"license": "MIT",
"version": "7.7.20",
"resolved": "https://registry.npmjs.org/@types/react-table/-/react-table-7.7.20.tgz",
"integrity": "sha512-ahMp4pmjVlnExxNwxyaDrFgmKxSbPwU23sGQw2gJK4EhCvnvmib2s/O/+y1dfV57dXOwpr2plfyBol+vEHbi2w==",
"dependencies": {
"@types/react": "*"
}
@@ -28029,6 +28030,14 @@
"uglify-js": "^3.1.4"
}
},
"node_modules/handlebars-group-by": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/handlebars-group-by/-/handlebars-group-by-1.0.1.tgz",
"integrity": "sha512-qwVVDVAJMBKdmnQU8jcEXGOu+4/2YJX1RP3pUw6Ee9t6gdkxt+dJEWDudSFTgqb35KXrktw/Nn/Dp3Rx5muHpg==",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/handlebars/node_modules/source-map": {
"version": "0.6.1",
"license": "BSD-3-Clause",
@@ -45807,9 +45816,9 @@
}
},
"node_modules/re-resizable": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.10.0.tgz",
"integrity": "sha512-hysSK0xmA5nz24HBVztlk4yCqCLCvS32E6ZpWxVKop9x3tqCa4yAj1++facrmkOf62JsJHjmjABdKxXofYioCw==",
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.10.1.tgz",
"integrity": "sha512-m33nSWRH57UZLmep5M/LatkZ2NRqimVD/bOOpvymw5Zf33+eTSEixsUugscOZzAtK0/nx+OSuOf8VbKJx/4ptw==",
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0"
@@ -58568,6 +58577,7 @@
"license": "Apache-2.0",
"dependencies": {
"handlebars": "^4.7.8",
"handlebars-group-by": "^1.0.1",
"just-handlebars-helpers": "^1.0.19"
},
"devDependencies": {
@@ -58671,13 +58681,6 @@
"react-dom": "^16.13.1"
}
},
"plugins/plugin-chart-table/node_modules/@types/react-table": {
"version": "7.7.20",
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"plugins/plugin-chart-table/node_modules/d3-array": {
"version": "2.12.1",
"license": "BSD-3-Clause",
@@ -65114,7 +65117,9 @@
}
},
"@scarf/scarf": {
"version": "1.3.0"
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ=="
},
"@sigstore/bundle": {
"version": "2.3.2",
@@ -69177,6 +69182,7 @@
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.7",
"handlebars": "^4.7.8",
"handlebars-group-by": "*",
"jest": "^29.7.0",
"just-handlebars-helpers": "^1.0.19"
},
@@ -69231,12 +69237,6 @@
"xss": "^1.0.15"
},
"dependencies": {
"@types/react-table": {
"version": "7.7.20",
"requires": {
"@types/react": "*"
}
},
"d3-array": {
"version": "2.12.1",
"requires": {
@@ -70370,8 +70370,9 @@
}
},
"@types/react-table": {
"version": "7.7.19",
"dev": true,
"version": "7.7.20",
"resolved": "https://registry.npmjs.org/@types/react-table/-/react-table-7.7.20.tgz",
"integrity": "sha512-ahMp4pmjVlnExxNwxyaDrFgmKxSbPwU23sGQw2gJK4EhCvnvmib2s/O/+y1dfV57dXOwpr2plfyBol+vEHbi2w==",
"requires": {
"@types/react": "^16.9.53"
}
@@ -79829,6 +79830,11 @@
}
}
},
"handlebars-group-by": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/handlebars-group-by/-/handlebars-group-by-1.0.1.tgz",
"integrity": "sha512-qwVVDVAJMBKdmnQU8jcEXGOu+4/2YJX1RP3pUw6Ee9t6gdkxt+dJEWDudSFTgqb35KXrktw/Nn/Dp3Rx5muHpg=="
},
"har-schema": {
"version": "2.0.0",
"dev": true
@@ -90721,9 +90727,9 @@
}
},
"re-resizable": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.10.0.tgz",
"integrity": "sha512-hysSK0xmA5nz24HBVztlk4yCqCLCvS32E6ZpWxVKop9x3tqCa4yAj1++facrmkOf62JsJHjmjABdKxXofYioCw==",
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.10.1.tgz",
"integrity": "sha512-m33nSWRH57UZLmep5M/LatkZ2NRqimVD/bOOpvymw5Zf33+eTSEixsUugscOZzAtK0/nx+OSuOf8VbKJx/4ptw==",
"requires": {}
},
"react": {

View File

@@ -90,7 +90,7 @@
"@rjsf/core": "^5.21.1",
"@rjsf/utils": "^5.19.3",
"@rjsf/validator-ajv8": "^5.22.3",
"@scarf/scarf": "^1.3.0",
"@scarf/scarf": "^1.4.0",
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
"@superset-ui/core": "file:./packages/superset-ui-core",
"@superset-ui/legacy-plugin-chart-calendar": "file:./plugins/legacy-plugin-chart-calendar",
@@ -167,7 +167,7 @@
"prop-types": "^15.8.1",
"query-string": "^6.13.7",
"rc-trigger": "^5.3.4",
"re-resizable": "^6.10.0",
"re-resizable": "^6.10.1",
"react": "^16.13.1",
"react-ace": "^10.1.0",
"react-checkbox-tree": "^1.8.0",
@@ -268,7 +268,7 @@
"@types/react-redux": "^7.1.10",
"@types/react-router-dom": "^5.3.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.19",
"@types/react-table": "^7.7.20",
"@types/react-transition-group": "^4.4.10",
"@types/react-ultimate-pagination": "^1.2.4",
"@types/react-virtualized-auto-sizer": "^1.0.4",

View File

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

View File

@@ -384,6 +384,7 @@ export default function transformProps(
xAxisType,
colorScale,
sliceId,
orientation,
),
);
else if (isIntervalAnnotationLayer(layer)) {
@@ -395,6 +396,7 @@ export default function transformProps(
colorScale,
theme,
sliceId,
orientation,
),
);
} else if (isEventAnnotationLayer(layer)) {
@@ -406,6 +408,7 @@ export default function transformProps(
colorScale,
theme,
sliceId,
orientation,
),
);
} else if (isTimeseriesAnnotationLayer(layer)) {
@@ -417,6 +420,7 @@ export default function transformProps(
annotationData,
colorScale,
sliceId,
orientation,
),
);
}

View File

@@ -53,6 +53,7 @@ import {
EchartsTimeseriesSeriesType,
ForecastSeriesEnum,
LegendOrientation,
OrientationType,
StackType,
} from '../types';
@@ -364,8 +365,11 @@ export function transformFormulaAnnotation(
xAxisType: AxisType,
colorScale: CategoricalColorScale,
sliceId?: number,
orientation?: OrientationType,
): SeriesOption {
const { name, color, opacity, width, style } = layer;
const isHorizontal = orientation === OrientationType.Horizontal;
return {
name,
id: name,
@@ -379,7 +383,9 @@ export function transformFormulaAnnotation(
},
type: 'line',
smooth: true,
data: evalFormula(layer, data, xAxisCol, xAxisType),
data: evalFormula(layer, data, xAxisCol, xAxisType).map(([x, y]) =>
isHorizontal ? [y, x] : [x, y],
),
symbolSize: 0,
};
}
@@ -391,6 +397,7 @@ export function transformIntervalAnnotation(
colorScale: CategoricalColorScale,
theme: SupersetTheme,
sliceId?: number,
orientation?: OrientationType,
): SeriesOption[] {
const series: SeriesOption[] = [];
const annotations = extractRecordAnnotations(layer, annotationData);
@@ -398,6 +405,7 @@ export function transformIntervalAnnotation(
const { name, color, opacity, showLabel } = layer;
const { descriptions, intervalEnd, time, title } = annotation;
const label = formatAnnotationLabel(name, title, descriptions);
const isHorizontal = orientation === OrientationType.Horizontal;
const intervalData: (
| MarkArea1DDataItemOption
| MarkArea2DDataItemOption
@@ -405,11 +413,9 @@ export function transformIntervalAnnotation(
[
{
name: label,
xAxis: time,
},
{
xAxis: intervalEnd,
...(isHorizontal ? { yAxis: time } : { xAxis: time }),
},
isHorizontal ? { yAxis: intervalEnd } : { xAxis: intervalEnd },
],
];
const intervalLabel: SeriesLabelOption = showLabel
@@ -466,6 +472,7 @@ export function transformEventAnnotation(
colorScale: CategoricalColorScale,
theme: SupersetTheme,
sliceId?: number,
orientation?: OrientationType,
): SeriesOption[] {
const series: SeriesOption[] = [];
const annotations = extractRecordAnnotations(layer, annotationData);
@@ -473,10 +480,11 @@ export function transformEventAnnotation(
const { name, color, opacity, style, width, showLabel } = layer;
const { descriptions, time, title } = annotation;
const label = formatAnnotationLabel(name, title, descriptions);
const isHorizontal = orientation === OrientationType.Horizontal;
const eventData: MarkLine1DDataItemOption[] = [
{
name: label,
xAxis: time,
...(isHorizontal ? { yAxis: time } : { xAxis: time }),
},
];
@@ -539,10 +547,12 @@ export function transformTimeseriesAnnotation(
annotationData: AnnotationData,
colorScale: CategoricalColorScale,
sliceId?: number,
orientation?: OrientationType,
): SeriesOption[] {
const series: SeriesOption[] = [];
const { hideLine, name, opacity, showMarkers, style, width, color } = layer;
const result = annotationData[name];
const isHorizontal = orientation === OrientationType.Horizontal;
if (isTimeseriesAnnotationResult(result)) {
result.forEach(annotation => {
const { key, values } = annotation;
@@ -550,7 +560,11 @@ export function transformTimeseriesAnnotation(
type: 'line',
id: key,
name: key,
data: values.map(row => [row.x, row.y] as [OptionName, number]),
data: values.map(({ x, y }) =>
isHorizontal
? ([y, x] as [number, OptionName])
: ([x, y] as [OptionName, number]),
),
symbolSize: showMarkers ? markerSize : 0,
lineStyle: {
opacity: parseAnnotationOpacity(opacity),

View File

@@ -0,0 +1,349 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
AnnotationData,
AnnotationSourceType,
AnnotationStyle,
AnnotationType,
AxisType,
CategoricalColorNamespace,
EventAnnotationLayer,
FormulaAnnotationLayer,
IntervalAnnotationLayer,
supersetTheme,
TimeseriesAnnotationLayer,
TimeseriesDataRecord,
} from '@superset-ui/core';
import { OrientationType } from '@superset-ui/plugin-chart-echarts';
import {
transformEventAnnotation,
transformFormulaAnnotation,
transformIntervalAnnotation,
transformTimeseriesAnnotation,
} from '../../src/Timeseries/transformers';
const mockData: TimeseriesDataRecord[] = [
{
__timestamp: 10,
},
{
__timestamp: 20,
},
];
const mockFormulaAnnotationLayer: FormulaAnnotationLayer = {
annotationType: AnnotationType.Formula as const,
name: 'My Formula',
show: true,
style: AnnotationStyle.Solid,
value: '50',
showLabel: true,
};
describe('transformFormulaAnnotation', () => {
it('should transform data correctly', () => {
expect(
transformFormulaAnnotation(
mockFormulaAnnotationLayer,
mockData,
'__timestamp',
AxisType.Value,
CategoricalColorNamespace.getScale(''),
undefined,
).data,
).toEqual([
[10, 50],
[20, 50],
]);
});
it('should swap x and y for horizontal chart', () => {
expect(
transformFormulaAnnotation(
mockFormulaAnnotationLayer,
mockData,
'__timestamp',
AxisType.Value,
CategoricalColorNamespace.getScale(''),
undefined,
OrientationType.Horizontal,
).data,
).toEqual([
[50, 10],
[50, 20],
]);
});
});
const mockIntervalAnnotationLayer: IntervalAnnotationLayer = {
name: 'Interval annotation layer',
annotationType: AnnotationType.Interval as const,
sourceType: AnnotationSourceType.Native as const,
color: null,
style: AnnotationStyle.Solid,
width: 1,
show: true,
showLabel: false,
value: 1,
};
const mockIntervalAnnotationData: AnnotationData = {
'Interval annotation layer': {
records: [
{
start_dttm: 10,
end_dttm: 12,
short_descr: 'Timeseries 1',
long_descr: '',
json_metadata: '',
},
{
start_dttm: 13,
end_dttm: 15,
short_descr: 'Timeseries 2',
long_descr: '',
json_metadata: '',
},
],
},
};
describe('transformIntervalAnnotation', () => {
it('should transform data correctly', () => {
expect(
transformIntervalAnnotation(
mockIntervalAnnotationLayer,
mockData,
mockIntervalAnnotationData,
CategoricalColorNamespace.getScale(''),
supersetTheme,
)
.map(annotation => annotation.markArea)
.map(markArea => markArea.data),
).toEqual([
[
[
{ name: 'Interval annotation layer - Timeseries 1', xAxis: 10 },
{ xAxis: 12 },
],
],
[
[
{ name: 'Interval annotation layer - Timeseries 2', xAxis: 13 },
{ xAxis: 15 },
],
],
]);
});
it('should use yAxis for horizontal chart data', () => {
expect(
transformIntervalAnnotation(
mockIntervalAnnotationLayer,
mockData,
mockIntervalAnnotationData,
CategoricalColorNamespace.getScale(''),
supersetTheme,
undefined,
OrientationType.Horizontal,
)
.map(annotation => annotation.markArea)
.map(markArea => markArea.data),
).toEqual([
[
[
{ name: 'Interval annotation layer - Timeseries 1', yAxis: 10 },
{ yAxis: 12 },
],
],
[
[
{ name: 'Interval annotation layer - Timeseries 2', yAxis: 13 },
{ yAxis: 15 },
],
],
]);
});
});
const mockEventAnnotationLayer: EventAnnotationLayer = {
annotationType: AnnotationType.Event,
color: null,
name: 'Event annotation layer',
show: true,
showLabel: false,
sourceType: AnnotationSourceType.Native,
style: AnnotationStyle.Solid,
value: 1,
width: 1,
};
const mockEventAnnotationData: AnnotationData = {
'Event annotation layer': {
records: [
{
start_dttm: 10,
end_dttm: 12,
short_descr: 'Test annotation',
long_descr: '',
json_metadata: '',
},
{
start_dttm: 13,
end_dttm: 15,
short_descr: 'Test annotation 2',
long_descr: '',
json_metadata: '',
},
],
},
};
describe('transformEventAnnotation', () => {
it('should transform data correctly', () => {
expect(
transformEventAnnotation(
mockEventAnnotationLayer,
mockData,
mockEventAnnotationData,
CategoricalColorNamespace.getScale(''),
supersetTheme,
)
.map(annotation => annotation.markLine)
.map(markLine => markLine.data),
).toEqual([
[
{
name: 'Event annotation layer - Test annotation',
xAxis: 10,
},
],
[{ name: 'Event annotation layer - Test annotation 2', xAxis: 13 }],
]);
});
it('should use yAxis for horizontal chart data', () => {
expect(
transformEventAnnotation(
mockEventAnnotationLayer,
mockData,
mockEventAnnotationData,
CategoricalColorNamespace.getScale(''),
supersetTheme,
undefined,
OrientationType.Horizontal,
)
.map(annotation => annotation.markLine)
.map(markLine => markLine.data),
).toEqual([
[
{
name: 'Event annotation layer - Test annotation',
yAxis: 10,
},
],
[{ name: 'Event annotation layer - Test annotation 2', yAxis: 13 }],
]);
});
});
const mockTimeseriesAnnotationLayer: TimeseriesAnnotationLayer = {
annotationType: AnnotationType.Timeseries,
color: null,
hideLine: false,
name: 'Timeseries annotation layer',
overrides: {
time_range: null,
},
show: true,
showLabel: false,
showMarkers: false,
sourceType: AnnotationSourceType.Line,
style: AnnotationStyle.Solid,
value: 1,
width: 1,
};
const mockTimeseriesAnnotationData: AnnotationData = {
'Timeseries annotation layer': [
{
key: 'Key 1',
values: [
{
x: 10,
y: 12,
},
],
},
{
key: 'Key 2',
values: [
{
x: 12,
y: 15,
},
{
x: 15,
y: 20,
},
],
},
],
};
describe('transformTimeseriesAnnotation', () => {
it('should transform data correctly', () => {
expect(
transformTimeseriesAnnotation(
mockTimeseriesAnnotationLayer,
1,
mockData,
mockTimeseriesAnnotationData,
CategoricalColorNamespace.getScale(''),
).map(annotation => annotation.data),
).toEqual([
[[10, 12]],
[
[12, 15],
[15, 20],
],
]);
});
it('should swap x and y for horizontal chart', () => {
expect(
transformTimeseriesAnnotation(
mockTimeseriesAnnotationLayer,
1,
mockData,
mockTimeseriesAnnotationData,
CategoricalColorNamespace.getScale(''),
undefined,
OrientationType.Horizontal,
).map(annotation => annotation.data),
).toEqual([
[[12, 10]],
[
[15, 12],
[20, 15],
],
]);
});
});

View File

@@ -28,6 +28,7 @@
},
"dependencies": {
"handlebars": "^4.7.8",
"handlebars-group-by": "^1.0.1",
"just-handlebars-helpers": "^1.0.19"
},
"peerDependencies": {

View File

@@ -22,6 +22,7 @@ import moment from 'moment';
import { useMemo, useState } from 'react';
import { isPlainObject } from 'lodash';
import Helpers from 'just-handlebars-helpers';
import HandlebarsGroupBy from 'handlebars-group-by';
export interface HandlebarsViewerProps {
templateSource: string;
@@ -88,4 +89,15 @@ Handlebars.registerHelper('stringify', (obj: any, obj2: any) => {
return isPlainObject(obj) ? JSON.stringify(obj) : String(obj);
});
Handlebars.registerHelper(
'formatNumber',
function (number: any, locale = 'en-US') {
if (typeof number !== 'number') {
return number;
}
return number.toLocaleString(locale);
},
);
Helpers.registerHelpers(Handlebars);
HandlebarsGroupBy.register(Handlebars);

View File

@@ -22,3 +22,4 @@ declare module '*.png' {
}
declare module '*.jpg';
declare module 'just-handlebars-helpers';
declare module 'handlebars-group-by';

View File

@@ -16,7 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import {
render,
screen,
waitFor,
fireEvent,
within,
} from 'spec/helpers/testing-library';
import configureStore from 'redux-mock-store';
import { Store } from 'redux';
import thunk from 'redux-thunk';
@@ -492,6 +498,38 @@ describe('ResultSet', () => {
expect(queryByTestId('export-csv-button')).toBeInTheDocument();
});
test('should display a popup message when the CSV content is limited to the dropdown limit', async () => {
const queryLimit = 2;
const { getByTestId, findByRole } = setup(
mockedProps,
mockStore({
...initialState,
user: {
...user,
roles: {
sql_lab: [['can_export_csv', 'SQLLab']],
},
},
sqlLab: {
...initialState.sqlLab,
queries: {
[queries[0].id]: {
...queries[0],
limitingFactor: 'DROPDOWN',
queryLimit,
},
},
},
}),
);
const downloadButton = getByTestId('export-csv-button');
fireEvent.click(downloadButton);
const warningModal = await findByRole('dialog');
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(
mockedProps,

View File

@@ -64,6 +64,7 @@ import CopyToClipboard from 'src/components/CopyToClipboard';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { prepareCopyToClipboardTabularData } from 'src/utils/common';
import { getItem, LocalStorageKeys } from 'src/utils/localStorageHelpers';
import Modal from 'src/components/Modal';
import {
addQueryEditor,
clearQueryResults,
@@ -296,6 +297,9 @@ const ResultSet = ({
const renderControls = () => {
if (search || visualize || csv) {
const { results, queryLimit, limitingFactor, rows } = query;
const limit = queryLimit || results.query.limit;
const rowsCount = Math.min(rows || 0, results?.data?.length || 0);
let { data } = query.results;
if (cache && query.cached) {
data = cachedData;
@@ -342,7 +346,21 @@ const ResultSet = ({
buttonSize="small"
href={getExportCsvUrl(query.id)}
data-test="export-csv-button"
onClick={() => logAction(LOG_ACTIONS_SQLLAB_DOWNLOAD_CSV, {})}
onClick={() => {
logAction(LOG_ACTIONS_SQLLAB_DOWNLOAD_CSV, {});
if (
limitingFactor === LimitingFactor.Dropdown &&
limit === rowsCount
) {
Modal.warning({
title: t('Download is on the way'),
content: t(
'Downloading %(rows)s rows based on the LIMIT configuration. If you want the entire result set, you need to adjust the LIMIT.',
{ rows: rowsCount.toLocaleString() },
),
});
}
}}
>
<i className="fa fa-file-text-o" /> {t('Download to CSV')}
</Button>

View File

@@ -267,7 +267,6 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
return (
<ButtonGroup
css={css`
display: flex;
column-gap: ${theme.gridUnit * 1.5}px;
margin-right: ${theme.gridUnit}px;
& span {

View File

@@ -18,13 +18,12 @@
*/
import Alert, { AlertProps } from './index';
type AlertType = Pick<AlertProps, 'type'>;
type AlertTypeValue = AlertType[keyof AlertType];
type AlertType = Required<Pick<AlertProps, 'type'>>;
type AlertTypeValue = AlertType['type'];
const types: AlertTypeValue[] = ['info', 'error', 'warning', 'success'];
const smallText = 'Lorem ipsum dolor sit amet';
const bigText =
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' +
'Nam id porta neque, a vehicula orci. Maecenas rhoncus elit sit amet ' +
@@ -38,40 +37,46 @@ export default {
export const AlertGallery = () => (
<>
{types.map(type => (
<div key={type} style={{ marginBottom: 40, width: 600 }}>
<h4>{type}</h4>
<Alert
type={type}
showIcon
closable
message={bigText}
style={{ marginBottom: 20 }}
/>
<Alert
type={type}
showIcon
message={smallText}
description={bigText}
closable
/>
<div key={type} style={{ marginBottom: '40px', width: '600px' }}>
<h4 style={{ textTransform: 'capitalize' }}>{type} Alerts</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<Alert
type={type}
showIcon
message={smallText}
description={bigText}
closable
closeIcon={
<span
aria-label="close icon"
style={{
fontSize: '12px',
fontWeight: 'bold',
}}
>
x
</span>
}
/>
</div>
</div>
))}
</>
);
AlertGallery.parameters = {
actions: {
disable: true,
},
controls: {
disable: true,
},
};
export const InteractiveAlert = (args: AlertProps) => (
<>
<Alert {...args} />
Some content to test the `roomBelow` prop
<div
style={{
marginTop: args.roomBelow ? '40px' : '0px',
border: '1px dashed gray',
padding: '10px',
textAlign: 'center',
}}
>
Content below the Alert to test the `roomBelow` property
</div>
</>
);
@@ -79,8 +84,8 @@ InteractiveAlert.args = {
closable: true,
roomBelow: false,
type: 'info',
message: smallText,
description: bigText,
message: 'This is a sample alert message.',
description: 'Sample description for additional context.',
showIcon: true,
};
@@ -89,5 +94,18 @@ InteractiveAlert.argTypes = {
type: {
control: { type: 'select' },
options: types,
description: 'Type of the alert (e.g., info, error, warning, success).',
},
closable: {
control: { type: 'boolean' },
description: 'Whether the Alert can be closed with a close button.',
},
showIcon: {
control: { type: 'boolean' },
description: 'Whether to display an icon in the Alert.',
},
roomBelow: {
control: { type: 'boolean' },
description: 'Adds margin below the Alert for layout spacing.',
},
};

View File

@@ -27,45 +27,41 @@ test('renders with default props', async () => {
render(<Alert message="Message" />);
expect(screen.getByRole('alert')).toHaveTextContent('Message');
expect(await screen.findByLabelText(`info icon`)).toBeInTheDocument();
expect(await screen.findByLabelText('info icon')).toBeInTheDocument();
expect(await screen.findByLabelText('close icon')).toBeInTheDocument();
});
test('renders each type', async () => {
const types: AlertTypeValue[] = ['info', 'error', 'warning', 'success'];
for (let i = 0; i < types.length; i += 1) {
const type = types[i];
render(<Alert type={type} message="Message" />);
// eslint-disable-next-line no-await-in-loop
expect(await screen.findByLabelText(`${type} icon`)).toBeInTheDocument();
}
await Promise.all(
types.map(async type => {
render(<Alert type={type} message="Message" />);
expect(await screen.findByLabelText(`${type} icon`)).toBeInTheDocument();
}),
);
});
test('renders without close button', async () => {
render(<Alert message="Message" closable={false} />);
await waitFor(() => {
expect(screen.queryByLabelText('close icon')).not.toBeInTheDocument();
});
});
test('disappear when closed', () => {
test('disappear when closed', async () => {
render(<Alert message="Message" />);
userEvent.click(screen.queryByLabelText('close icon')!);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
test('renders without icon', async () => {
const type = 'info';
render(<Alert type={type} message="Message" showIcon={false} />);
userEvent.click(screen.getByLabelText('close icon'));
await waitFor(() => {
expect(screen.queryByLabelText(`${type} icon`)).not.toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});
test('renders message', async () => {
render(<Alert message="Message" />);
expect(await screen.findByRole('alert')).toHaveTextContent('Message');
test('renders without icon', async () => {
render(<Alert type="info" message="Message" showIcon={false} />);
await waitFor(() => {
expect(screen.queryByLabelText('info icon')).not.toBeInTheDocument();
});
});
test('renders message and description', async () => {
@@ -74,3 +70,10 @@ test('renders message and description', async () => {
expect(alert).toHaveTextContent('Message');
expect(alert).toHaveTextContent('Description');
});
test('calls onClose callback when closed', () => {
const onCloseMock = jest.fn();
render(<Alert message="Message" onClose={onCloseMock} />);
userEvent.click(screen.getByLabelText('close icon'));
expect(onCloseMock).toHaveBeenCalledTimes(1);
});

View File

@@ -17,12 +17,13 @@
* under the License.
*/
import { PropsWithChildren } from 'react';
import AntdAlert, { AlertProps as AntdAlertProps } from 'antd/lib/alert';
import { useTheme } from '@superset-ui/core';
import { Alert as AntdAlert } from 'antd-v5';
import { AlertProps as AntdAlertProps } from 'antd-v5/lib/alert';
import { css, useTheme } from '@superset-ui/core';
import Icons from 'src/components/Icons';
export type AlertProps = PropsWithChildren<
AntdAlertProps & { roomBelow?: boolean }
Omit<AntdAlertProps, 'children'> & { roomBelow?: boolean }
>;
export default function Alert(props: AlertProps) {
@@ -36,8 +37,8 @@ export default function Alert(props: AlertProps) {
} = props;
const theme = useTheme();
const { colors, typography, gridUnit } = theme;
const { alert, error, info, success } = colors;
const { colors } = theme;
const { alert: alertColor, error, info, success } = colors;
let baseColor = info;
let AlertIcon = Icons.InfoSolid;
@@ -45,7 +46,7 @@ export default function Alert(props: AlertProps) {
baseColor = error;
AlertIcon = Icons.ErrorSolid;
} else if (type === 'warning') {
baseColor = alert;
baseColor = alertColor;
AlertIcon = Icons.AlertSolid;
} else if (type === 'success') {
baseColor = success;
@@ -55,33 +56,36 @@ export default function Alert(props: AlertProps) {
return (
<AntdAlert
role="alert"
aria-live={type === 'error' ? 'assertive' : 'polite'}
showIcon={showIcon}
icon={<AlertIcon aria-label={`${type} icon`} />}
closeText={closable && <Icons.XSmall aria-label="close icon" />}
css={{
marginBottom: roomBelow ? gridUnit * 4 : 0,
padding: `${gridUnit * 2}px ${gridUnit * 3}px`,
alignItems: 'flex-start',
border: 0,
backgroundColor: baseColor.light2,
'& .ant-alert-icon': {
marginRight: gridUnit * 2,
},
'& .ant-alert-message': {
color: baseColor.dark2,
fontSize: typography.sizes.m,
fontWeight: description
? typography.weights.bold
: typography.weights.normal,
},
'& .ant-alert-description': {
color: baseColor.dark2,
fontSize: typography.sizes.m,
},
}}
icon={
showIcon && (
<span
role="img"
aria-label={`${type} icon`}
style={{
color: baseColor.base,
}}
>
<AlertIcon />
</span>
)
}
closeIcon={closable && <Icons.XSmall aria-label="close icon" />}
message={children || 'Default message'}
description={description}
css={css`
margin-bottom: ${roomBelow ? theme.gridUnit * 4 : 0}px;
a {
text-decoration: underline;
}
.antd5-alert-message {
font-weight: ${description
? theme.typography.weights.bold
: 'inherit'};
}
`}
{...props}
>
{children}
</AntdAlert>
/>
);
}

View File

@@ -21,6 +21,7 @@ import { ReactNode } from 'react';
export interface ButtonGroupProps {
className?: string;
children: ReactNode;
expand?: boolean;
}
export default function ButtonGroup(props: ButtonGroupProps) {
@@ -30,22 +31,28 @@ export default function ButtonGroup(props: ButtonGroupProps) {
role="group"
className={className}
css={{
'& :nth-of-type(1):not(:nth-last-of-type(1))': {
display: 'flex',
'& > :nth-of-type(1):not(:nth-last-of-type(1))': {
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
borderRight: 0,
marginLeft: 0,
},
'& :not(:nth-of-type(1)):not(:nth-last-of-type(1))': {
'& > :not(:nth-of-type(1)):not(:nth-last-of-type(1))': {
borderRadius: 0,
borderRight: 0,
marginLeft: 0,
},
'& :nth-last-of-type(1):not(:nth-of-type(1))': {
'& > :nth-last-of-type(1):not(:nth-of-type(1))': {
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
marginLeft: 0,
},
...(props.expand && {
'& .superset-button': {
flexGrow: 1,
},
}),
}}
>
{children}

View File

@@ -40,13 +40,13 @@ import {
} from '@superset-ui/core';
import { RootState } from 'src/dashboard/types';
import { Menu } from 'src/components/Menu';
import { usePermissions } from 'src/hooks/usePermissions';
import { AntdDropdown as Dropdown } from 'src/components/index';
import { updateDataMask } from 'src/dataMask/actions';
import { DrillDetailMenuItems } from '../DrillDetail';
import { getMenuAdjustedY } from '../utils';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import { DrillByMenuItems } from '../DrillBy/DrillByMenuItems';
import { usePermissions } from './usePermissions';
export enum ContextMenuItem {
CrossFilter,

View File

@@ -54,6 +54,7 @@ import {
LineChartOutlined,
LoadingOutlined,
MonitorOutlined,
PicCenterOutlined,
PlusCircleOutlined,
PlusOutlined,
ReloadOutlined,
@@ -106,6 +107,7 @@ const AntdIcons = {
LineChartOutlined,
LoadingOutlined,
MonitorOutlined,
PicCenterOutlined,
PlusCircleOutlined,
PlusOutlined,
ReloadOutlined,

View File

@@ -20,24 +20,9 @@
import { css, SupersetTheme } from '@superset-ui/core';
export const antdWarningAlertStyles = (theme: SupersetTheme) => css`
border: 1px solid ${theme.colors.warning.light1};
padding: ${theme.gridUnit * 4}px;
margin: ${theme.gridUnit * 4}px 0;
color: ${theme.colors.warning.dark2};
.ant-alert-message {
.antd5-alert-message {
margin: 0;
}
.ant-alert-description {
font-size: ${theme.typography.sizes.s + 1}px;
line-height: ${theme.gridUnit * 4}px;
.ant-alert-icon {
margin-right: ${theme.gridUnit * 2.5}px;
font-size: ${theme.typography.sizes.l + 1}px;
position: relative;
top: ${theme.gridUnit / 4}px;
}
}
`;

View File

@@ -29,9 +29,8 @@ import { useUiConfig } from 'src/components/UiConfigContext';
import { Tooltip } from 'src/components/Tooltip';
import { useSelector } from 'react-redux';
import EditableTitle from 'src/components/EditableTitle';
import SliceHeaderControls, {
SliceHeaderControlsProps,
} from 'src/dashboard/components/SliceHeaderControls';
import SliceHeaderControls from 'src/dashboard/components/SliceHeaderControls';
import { SliceHeaderControlsProps } from 'src/dashboard/components/SliceHeaderControls/types';
import FiltersBadge from 'src/dashboard/components/FiltersBadge';
import Icons from 'src/components/Icons';
import { RootState } from 'src/dashboard/types';

View File

@@ -23,10 +23,9 @@ import { render, screen } from 'spec/helpers/testing-library';
import { FeatureFlag, VizType } from '@superset-ui/core';
import mockState from 'spec/fixtures/mockState';
import { Menu } from 'src/components/Menu';
import SliceHeaderControls, {
SliceHeaderControlsProps,
handleDropdownNavigation,
} from '.';
import SliceHeaderControls from '.';
import { SliceHeaderControlsProps } from './types';
import { handleDropdownNavigation } from './utils';
jest.mock('src/components/Dropdown', () => {
const original = jest.requireActual('src/components/Dropdown');
@@ -310,13 +309,13 @@ test('Should show "Drill to detail" with `can_explore` & `can_samples` perms', (
(global as any).featureFlags = {
[FeatureFlag.DrillToDetail]: true,
};
const props = {
...createProps(),
supersetCanExplore: true,
};
const props = createProps();
props.slice.slice_id = 18;
renderWrapper(props, {
Admin: [['can_samples', 'Datasource']],
Admin: [
['can_samples', 'Datasource'],
['can_explore', 'Superset'],
],
});
expect(screen.getByText('Drill to detail')).toBeInTheDocument();
});

View File

@@ -0,0 +1,117 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ReactChild, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { css, t, useTheme } from '@superset-ui/core';
import Modal from 'src/components/Modal';
import Button from 'src/components/Button';
export const ViewResultsModalTrigger = ({
canExplore,
exploreUrl,
triggerNode,
modalTitle,
modalBody,
showModal = false,
setShowModal,
}: {
canExplore?: boolean;
exploreUrl: string;
triggerNode: ReactChild;
modalTitle: ReactChild;
modalBody: ReactChild;
showModal: boolean;
setShowModal: (showModal: boolean) => void;
}) => {
const history = useHistory();
const exploreChart = () => history.push(exploreUrl);
const theme = useTheme();
const openModal = useCallback(() => setShowModal(true), [setShowModal]);
const closeModal = useCallback(() => setShowModal(false), [setShowModal]);
return (
<>
<span
data-test="span-modal-trigger"
onClick={openModal}
role="button"
tabIndex={0}
>
{triggerNode}
</span>
{(() => (
<Modal
css={css`
.ant-modal-body {
display: flex;
flex-direction: column;
}
`}
show={showModal}
onHide={closeModal}
closable
title={modalTitle}
footer={
<>
<Button
buttonStyle="secondary"
buttonSize="small"
onClick={exploreChart}
disabled={!canExplore}
tooltip={
!canExplore
? t(
'You do not have sufficient permissions to edit the chart',
)
: undefined
}
>
{t('Edit chart')}
</Button>
<Button
buttonStyle="primary"
buttonSize="small"
onClick={closeModal}
css={css`
margin-left: ${theme.gridUnit * 2}px;
`}
>
{t('Close')}
</Button>
</>
}
responsive
resizable
resizableConfig={{
minHeight: theme.gridUnit * 128,
minWidth: theme.gridUnit * 128,
defaultSize: {
width: 'auto',
height: '75vh',
},
}}
draggable
destroyOnClose
>
{modalBody}
</Modal>
))()}
</>
);
};

View File

@@ -16,19 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
MouseEvent,
Key,
KeyboardEvent,
ReactChild,
useState,
useRef,
RefObject,
useCallback,
ReactElement,
} from 'react';
import { MouseEvent, Key, useState, useRef, RefObject } from 'react';
import { RouteComponentProps, useHistory, withRouter } from 'react-router-dom';
import { useHistory } from 'react-router-dom';
import moment from 'moment';
import {
Behavior,
@@ -36,23 +26,12 @@ import {
isFeatureEnabled,
FeatureFlag,
getChartMetadataRegistry,
QueryFormData,
styled,
t,
useTheme,
ensureIsArray,
VizType,
} from '@superset-ui/core';
import { useSelector } from 'react-redux';
import {
MenuItemKeyEnum,
Menu,
MenuItemChildType,
isAntdMenuItem,
isAntdMenuItemRef,
isSubMenuOrItemType,
isAntdMenuSubmenu,
} from 'src/components/Menu';
import { Menu } from 'src/components/Menu';
import { NoAnimationDropdown } from 'src/components/Dropdown';
import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems';
import downloadAsImage from 'src/utils/downloadAsImage';
@@ -60,28 +39,16 @@ import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip'
import { Tooltip } from 'src/components/Tooltip';
import Icons from 'src/components/Icons';
import ModalTrigger from 'src/components/ModalTrigger';
import Button from 'src/components/Button';
import ViewQueryModal from 'src/explore/components/controls/ViewQueryModal';
import { ResultsPaneOnDashboard } from 'src/explore/components/DataTablesPane';
import Modal from 'src/components/Modal';
import { DrillDetailMenuItems } from 'src/components/Chart/DrillDetail';
import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import { findPermission } from 'src/utils/findPermission';
import { usePermissions } from 'src/hooks/usePermissions';
import { useCrossFiltersScopingModal } from '../nativeFilters/FilterBar/CrossFilters/ScopingModal/useCrossFiltersScopingModal';
const ACTION_KEYS = {
enter: 'Enter',
spacebar: 'Spacebar',
space: ' ',
};
const NAV_KEYS = {
tab: 'Tab',
escape: 'Escape',
up: 'ArrowUp',
down: 'ArrowDown',
};
import { handleDropdownNavigation } from './utils';
import { ViewResultsModalTrigger } from './ViewResultsModalTrigger';
import { SliceHeaderControlsProps } from './types';
// TODO: replace 3 dots with an icon
const VerticalDotsContainer = styled.div`
@@ -126,51 +93,6 @@ const VerticalDotsTrigger = () => (
</VerticalDotsContainer>
);
export interface SliceHeaderControlsProps {
slice: {
description: string;
viz_type: string;
slice_name: string;
slice_id: number;
slice_description: string;
datasource: string;
};
componentId: string;
dashboardId: number;
chartStatus: string;
isCached: boolean[];
cachedDttm: string[] | null;
isExpanded?: boolean;
updatedDttm: number | null;
isFullSize?: boolean;
isDescriptionExpanded?: boolean;
formData: QueryFormData;
exploreUrl: string;
forceRefresh: (sliceId: number, dashboardId: number) => void;
logExploreChart?: (sliceId: number) => void;
logEvent?: (eventName: string, eventData?: object) => void;
toggleExpandSlice?: (sliceId: number) => void;
exportCSV?: (sliceId: number) => void;
exportPivotCSV?: (sliceId: number) => void;
exportFullCSV?: (sliceId: number) => void;
exportXLSX?: (sliceId: number) => void;
exportFullXLSX?: (sliceId: number) => void;
handleToggleFullSize: () => void;
addDangerToast: (message: string) => void;
addSuccessToast: (message: string) => void;
supersetCanExplore?: boolean;
supersetCanShare?: boolean;
supersetCanCSV?: boolean;
crossFiltersEnabled?: boolean;
}
type SliceHeaderControlsPropsWithRouter = SliceHeaderControlsProps &
RouteComponentProps;
const dropdownIconsStyles = css`
&&.anticon > .anticon:first-child {
margin-right: 0;
@@ -178,353 +100,7 @@ const dropdownIconsStyles = css`
}
`;
/**
* A MenuItem can be recognized in the tree by the presence of a ref
*
* @param children
* @param currentKeys
* @returns an array of keys
*/
const extractMenuItemRefs = (child: MenuItemChildType): RefObject<any>[] => {
// check that child has props
const childProps: Record<string, any> = child?.props;
// loop through each prop
if (childProps) {
const arrayProps = Object.values(childProps);
// check if any is of type ref MenuItem
const refs = arrayProps.filter(ref => isAntdMenuItemRef(ref));
return refs;
}
return [];
};
/**
* Recursively extracts keys from menu items
*
* @param children
* @param currentKeys
* @returns an array of keys and their refs
*
*/
const extractMenuItemsKeys = (
children: MenuItemChildType[],
currentKeys?: { key: string; ref?: RefObject<any> }[],
): { key: string; ref?: RefObject<any> }[] => {
const allKeys = currentKeys || [];
const arrayChildren = ensureIsArray(children);
arrayChildren.forEach((child: MenuItemChildType) => {
const isMenuItem = isAntdMenuItem(child);
const refs = extractMenuItemRefs(child);
// key is immediately available in a standard MenuItem
if (isMenuItem) {
const { key } = child;
if (key) {
allKeys.push({
key,
});
}
}
// one or more menu items refs are available
if (refs.length) {
allKeys.push(
...refs.map(ref => ({ key: ref.current.props.eventKey, ref })),
);
}
// continue to extract keys from nested children
if (child?.props?.children) {
const childKeys = extractMenuItemsKeys(child.props.children, allKeys);
allKeys.push(...childKeys);
}
});
return allKeys;
};
/**
* Generates a map of keys and their types for a MenuItem
* Individual refs can be given to extract keys from nested items
* Refs can be used to control the event handlers of the menu items
*
* @param itemChildren
* @param type
* @returns a map of keys and their types
*/
const extractMenuItemsKeyMap = (
children: MenuItemChildType,
): Record<string, any> => {
const keysMap: Record<string, any> = {};
const childrenArray = ensureIsArray(children);
childrenArray.forEach((child: MenuItemChildType) => {
const isMenuItem = isAntdMenuItem(child);
const isSubmenu = isAntdMenuSubmenu(child);
const menuItemsRefs = extractMenuItemRefs(child);
// key is immediately available in MenuItem or SubMenu
if (isMenuItem || isSubmenu) {
const directKey = child?.key;
if (directKey) {
keysMap[directKey] = {};
keysMap[directKey].type = isSubmenu
? MenuItemKeyEnum.SubMenu
: MenuItemKeyEnum.MenuItem;
}
}
// one or more menu items refs are available
if (menuItemsRefs.length) {
menuItemsRefs.forEach(ref => {
const key = ref.current.props.eventKey;
keysMap[key] = {};
keysMap[key].type = isSubmenu
? MenuItemKeyEnum.SubMenu
: MenuItemKeyEnum.MenuItem;
keysMap[key].parent = child.key;
keysMap[key].ref = ref;
});
}
// if it has children must check for the presence of menu items
if (child?.props?.children) {
const theChildren = child?.props?.children;
const childKeys = extractMenuItemsKeys(theChildren);
childKeys.forEach(keyMap => {
const k = keyMap.key;
keysMap[k] = {};
keysMap[k].type = MenuItemKeyEnum.SubMenuItem;
keysMap[k].parent = child.key;
if (keyMap.ref) {
keysMap[k].ref = keyMap.ref;
}
});
}
});
return keysMap;
};
/**
*
* Determines the next key to select based on the current key and direction
*
* @param keys
* @param keysMap
* @param currentKeyIndex
* @param direction
* @returns the selected key and the open key
*/
const getNavigationKeys = (
keys: string[],
keysMap: Record<string, any>,
currentKeyIndex: number,
direction = 'up',
) => {
const step = direction === 'up' ? -1 : 1;
const skipStep = direction === 'up' ? -2 : 2;
const keysLen = direction === 'up' ? 0 : keys.length;
const mathFn = direction === 'up' ? Math.max : Math.min;
let openKey: string | undefined;
let selectedKey = keys[mathFn(currentKeyIndex + step, keysLen)];
// go to first key if current key is the last
if (!selectedKey) {
return { selectedKey: keys[0], openKey: undefined };
}
const isSubMenu = keysMap[selectedKey]?.type === MenuItemKeyEnum.SubMenu;
if (isSubMenu) {
// this is a submenu, skip to first submenu item
selectedKey = keys[mathFn(currentKeyIndex + skipStep, keysLen)];
}
// re-evaulate if current selected key is a submenu or submenu item
if (!isSubMenuOrItemType(keysMap[selectedKey].type)) {
openKey = undefined;
} else {
const parentKey = keysMap[selectedKey].parent;
if (parentKey) {
openKey = parentKey;
}
}
return { selectedKey, openKey };
};
export const handleDropdownNavigation = (
e: KeyboardEvent<HTMLElement>,
dropdownIsOpen: boolean,
menu: ReactElement,
toggleDropdown: () => void,
setSelectedKeys: (keys: string[]) => void,
setOpenKeys: (keys: string[]) => void,
) => {
if (e.key === NAV_KEYS.tab && !dropdownIsOpen) {
return; // if tab, continue with system tab navigation
}
const menuProps = menu.props || {};
const keysMap = extractMenuItemsKeyMap(menuProps.children);
const keys = Object.keys(keysMap);
const { selectedKeys = [] } = menuProps;
const currentKeyIndex = keys.indexOf(selectedKeys[0]);
switch (e.key) {
// toggle the dropdown on keypress
case ACTION_KEYS.enter:
case ACTION_KEYS.spacebar:
case ACTION_KEYS.space:
if (selectedKeys.length) {
const currentKey = selectedKeys[0];
const currentKeyConf = keysMap[selectedKeys];
// when a menu item is selected, then trigger
// the menu item's onClick handler
menuProps.onClick?.({ key: currentKey, domEvent: e });
// trigger click handle on ref
if (currentKeyConf?.ref) {
const refMenuItemProps = currentKeyConf.ref.current.props;
refMenuItemProps.onClick?.({
key: currentKey,
domEvent: e,
});
}
// clear out/deselect keys
setSelectedKeys([]);
// close submenus
setOpenKeys([]);
// put focus back on menu trigger
e.currentTarget.focus();
}
// if nothing was selected, or after selecting new menu item,
toggleDropdown();
break;
// select the menu items going down
case NAV_KEYS.down:
case NAV_KEYS.tab && !e.shiftKey: {
const { selectedKey, openKey } = getNavigationKeys(
keys,
keysMap,
currentKeyIndex,
'down',
);
setSelectedKeys([selectedKey]);
setOpenKeys(openKey ? [openKey] : []);
break;
}
// select the menu items going up
case NAV_KEYS.up:
case NAV_KEYS.tab && e.shiftKey: {
const { selectedKey, openKey } = getNavigationKeys(
keys,
keysMap,
currentKeyIndex,
'up',
);
setSelectedKeys([selectedKey]);
setOpenKeys(openKey ? [openKey] : []);
break;
}
case NAV_KEYS.escape:
// close dropdown menu
toggleDropdown();
break;
default:
break;
}
};
const ViewResultsModalTrigger = ({
canExplore,
exploreUrl,
triggerNode,
modalTitle,
modalBody,
showModal = false,
setShowModal,
}: {
canExplore?: boolean;
exploreUrl: string;
triggerNode: ReactChild;
modalTitle: ReactChild;
modalBody: ReactChild;
showModal: boolean;
setShowModal: (showModal: boolean) => void;
}) => {
const history = useHistory();
const exploreChart = () => history.push(exploreUrl);
const theme = useTheme();
const openModal = useCallback(() => setShowModal(true), []);
const closeModal = useCallback(() => setShowModal(false), []);
return (
<>
<span
data-test="span-modal-trigger"
onClick={openModal}
role="button"
tabIndex={0}
>
{triggerNode}
</span>
{(() => (
<Modal
css={css`
.ant-modal-body {
display: flex;
flex-direction: column;
}
`}
show={showModal}
onHide={closeModal}
closable
title={modalTitle}
footer={
<>
<Button
buttonStyle="secondary"
buttonSize="small"
onClick={exploreChart}
disabled={!canExplore}
tooltip={
!canExplore
? t(
'You do not have sufficient permissions to edit the chart',
)
: undefined
}
>
{t('Edit chart')}
</Button>
<Button
buttonStyle="primary"
buttonSize="small"
onClick={closeModal}
css={css`
margin-left: ${theme.gridUnit * 2}px;
`}
>
{t('Close')}
</Button>
</>
}
responsive
resizable
resizableConfig={{
minHeight: theme.gridUnit * 128,
minWidth: theme.gridUnit * 128,
defaultSize: {
width: 'auto',
height: '75vh',
},
}}
draggable
destroyOnClose
>
{modalBody}
</Modal>
))()}
</>
);
};
const SliceHeaderControls = (props: SliceHeaderControlsPropsWithRouter) => {
const SliceHeaderControls = (props: SliceHeaderControlsProps) => {
const [dropdownIsOpen, setDropdownIsOpen] = useState(false);
const [tableModalIsOpen, setTableModalIsOpen] = useState(false);
const [drillModalIsOpen, setDrillModalIsOpen] = useState(false);
@@ -559,19 +135,7 @@ const SliceHeaderControls = (props: SliceHeaderControlsPropsWithRouter) => {
.get(props.slice.viz_type)
?.behaviors?.includes(Behavior.InteractiveChart);
const canExplore = props.supersetCanExplore;
const canDatasourceSamples = useSelector((state: RootState) =>
findPermission('can_samples', 'Datasource', state.user?.roles),
);
const canDrill = useSelector((state: RootState) =>
findPermission('can_drill', 'Dashboard', state.user?.roles),
);
const canDrillToDetail = (canExplore || canDrill) && canDatasourceSamples;
const canViewQuery = useSelector((state: RootState) =>
findPermission('can_view_query', 'Dashboard', state.user?.roles),
);
const canViewTable = useSelector((state: RootState) =>
findPermission('can_view_chart_as_table', 'Dashboard', state.user?.roles),
);
const { canDrillToDetail, canViewQuery, canViewTable } = usePermissions();
const refreshChart = () => {
if (props.updatedDttm) {
props.forceRefresh(props.slice.slice_id, props.dashboardId);
@@ -965,4 +529,4 @@ const SliceHeaderControls = (props: SliceHeaderControlsPropsWithRouter) => {
);
};
export default withRouter(SliceHeaderControls);
export default SliceHeaderControls;

View File

@@ -0,0 +1,62 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { QueryFormData } from '@superset-ui/core';
export interface SliceHeaderControlsProps {
slice: {
description: string;
viz_type: string;
slice_name: string;
slice_id: number;
slice_description: string;
datasource: string;
};
componentId: string;
dashboardId: number;
chartStatus: string;
isCached: boolean[];
cachedDttm: string[] | null;
isExpanded?: boolean;
updatedDttm: number | null;
isFullSize?: boolean;
isDescriptionExpanded?: boolean;
formData: QueryFormData;
exploreUrl: string;
forceRefresh: (sliceId: number, dashboardId: number) => void;
logExploreChart?: (sliceId: number) => void;
logEvent?: (eventName: string, eventData?: object) => void;
toggleExpandSlice?: (sliceId: number) => void;
exportCSV?: (sliceId: number) => void;
exportPivotCSV?: (sliceId: number) => void;
exportFullCSV?: (sliceId: number) => void;
exportXLSX?: (sliceId: number) => void;
exportFullXLSX?: (sliceId: number) => void;
handleToggleFullSize: () => void;
addDangerToast: (message: string) => void;
addSuccessToast: (message: string) => void;
supersetCanExplore?: boolean;
supersetCanShare?: boolean;
supersetCanCSV?: boolean;
crossFiltersEnabled?: boolean;
}

View File

@@ -0,0 +1,293 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
isAntdMenuItem,
isAntdMenuItemRef,
isAntdMenuSubmenu,
isSubMenuOrItemType,
MenuItemChildType,
MenuItemKeyEnum,
} from 'src/components/Menu';
import { KeyboardEvent, ReactElement, RefObject } from 'react';
import { ensureIsArray } from '@superset-ui/core';
const ACTION_KEYS = {
enter: 'Enter',
spacebar: 'Spacebar',
space: ' ',
};
const NAV_KEYS = {
tab: 'Tab',
escape: 'Escape',
up: 'ArrowUp',
down: 'ArrowDown',
};
/**
* A MenuItem can be recognized in the tree by the presence of a ref
*
* @param children
* @param currentKeys
* @returns an array of keys
*/
const extractMenuItemRefs = (child: MenuItemChildType): RefObject<any>[] => {
// check that child has props
const childProps: Record<string, any> = child?.props;
// loop through each prop
if (childProps) {
const arrayProps = Object.values(childProps);
// check if any is of type ref MenuItem
return arrayProps.filter(ref => isAntdMenuItemRef(ref));
}
return [];
};
/**
* Recursively extracts keys from menu items
*
* @param children
* @param currentKeys
* @returns an array of keys and their refs
*
*/
const extractMenuItemsKeys = (
children: MenuItemChildType[],
currentKeys?: { key: string; ref?: RefObject<any> }[],
): { key: string; ref?: RefObject<any> }[] => {
const allKeys = currentKeys || [];
const arrayChildren = ensureIsArray(children);
arrayChildren.forEach((child: MenuItemChildType) => {
const isMenuItem = isAntdMenuItem(child);
const refs = extractMenuItemRefs(child);
// key is immediately available in a standard MenuItem
if (isMenuItem) {
const { key } = child;
if (key) {
allKeys.push({
key,
});
}
}
// one or more menu items refs are available
if (refs.length) {
allKeys.push(
...refs.map(ref => ({ key: ref.current.props.eventKey, ref })),
);
}
// continue to extract keys from nested children
if (child?.props?.children) {
const childKeys = extractMenuItemsKeys(child.props.children, allKeys);
allKeys.push(...childKeys);
}
});
return allKeys;
};
/**
* Generates a map of keys and their types for a MenuItem
* Individual refs can be given to extract keys from nested items
* Refs can be used to control the event handlers of the menu items
*
* @param itemChildren
* @param type
* @returns a map of keys and their types
*/
const extractMenuItemsKeyMap = (
children: MenuItemChildType,
): Record<string, any> => {
const keysMap: Record<string, any> = {};
const childrenArray = ensureIsArray(children);
childrenArray.forEach((child: MenuItemChildType) => {
const isMenuItem = isAntdMenuItem(child);
const isSubmenu = isAntdMenuSubmenu(child);
const menuItemsRefs = extractMenuItemRefs(child);
// key is immediately available in MenuItem or SubMenu
if (isMenuItem || isSubmenu) {
const directKey = child?.key;
if (directKey) {
keysMap[directKey] = {};
keysMap[directKey].type = isSubmenu
? MenuItemKeyEnum.SubMenu
: MenuItemKeyEnum.MenuItem;
}
}
// one or more menu items refs are available
if (menuItemsRefs.length) {
menuItemsRefs.forEach(ref => {
const key = ref.current.props.eventKey;
keysMap[key] = {};
keysMap[key].type = isSubmenu
? MenuItemKeyEnum.SubMenu
: MenuItemKeyEnum.MenuItem;
keysMap[key].parent = child.key;
keysMap[key].ref = ref;
});
}
// if it has children must check for the presence of menu items
if (child?.props?.children) {
const theChildren = child?.props?.children;
const childKeys = extractMenuItemsKeys(theChildren);
childKeys.forEach(keyMap => {
const k = keyMap.key;
keysMap[k] = {};
keysMap[k].type = MenuItemKeyEnum.SubMenuItem;
keysMap[k].parent = child.key;
if (keyMap.ref) {
keysMap[k].ref = keyMap.ref;
}
});
}
});
return keysMap;
};
/**
*
* Determines the next key to select based on the current key and direction
*
* @param keys
* @param keysMap
* @param currentKeyIndex
* @param direction
* @returns the selected key and the open key
*/
const getNavigationKeys = (
keys: string[],
keysMap: Record<string, any>,
currentKeyIndex: number,
direction = 'up',
) => {
const step = direction === 'up' ? -1 : 1;
const skipStep = direction === 'up' ? -2 : 2;
const keysLen = direction === 'up' ? 0 : keys.length;
const mathFn = direction === 'up' ? Math.max : Math.min;
let openKey: string | undefined;
let selectedKey = keys[mathFn(currentKeyIndex + step, keysLen)];
// go to first key if current key is the last
if (!selectedKey) {
return { selectedKey: keys[0], openKey: undefined };
}
const isSubMenu = keysMap[selectedKey]?.type === MenuItemKeyEnum.SubMenu;
if (isSubMenu) {
// this is a submenu, skip to first submenu item
selectedKey = keys[mathFn(currentKeyIndex + skipStep, keysLen)];
}
// re-evaulate if current selected key is a submenu or submenu item
if (!isSubMenuOrItemType(keysMap[selectedKey].type)) {
openKey = undefined;
} else {
const parentKey = keysMap[selectedKey].parent;
if (parentKey) {
openKey = parentKey;
}
}
return { selectedKey, openKey };
};
export const handleDropdownNavigation = (
e: KeyboardEvent<HTMLElement>,
dropdownIsOpen: boolean,
menu: ReactElement,
toggleDropdown: () => void,
setSelectedKeys: (keys: string[]) => void,
setOpenKeys: (keys: string[]) => void,
) => {
if (e.key === NAV_KEYS.tab && !dropdownIsOpen) {
return; // if tab, continue with system tab navigation
}
const menuProps = menu.props || {};
const keysMap = extractMenuItemsKeyMap(menuProps.children);
const keys = Object.keys(keysMap);
const { selectedKeys = [] } = menuProps;
const currentKeyIndex = keys.indexOf(selectedKeys[0]);
switch (e.key) {
// toggle the dropdown on keypress
case ACTION_KEYS.enter:
case ACTION_KEYS.spacebar:
case ACTION_KEYS.space:
if (selectedKeys.length) {
const currentKey = selectedKeys[0];
const currentKeyConf = keysMap[selectedKeys];
// when a menu item is selected, then trigger
// the menu item's onClick handler
menuProps.onClick?.({ key: currentKey, domEvent: e });
// trigger click handle on ref
if (currentKeyConf?.ref) {
const refMenuItemProps = currentKeyConf.ref.current.props;
refMenuItemProps.onClick?.({
key: currentKey,
domEvent: e,
});
}
// clear out/deselect keys
setSelectedKeys([]);
// close submenus
setOpenKeys([]);
// put focus back on menu trigger
e.currentTarget.focus();
}
// if nothing was selected, or after selecting new menu item,
toggleDropdown();
break;
// select the menu items going down
case NAV_KEYS.down:
case NAV_KEYS.tab && !e.shiftKey: {
const { selectedKey, openKey } = getNavigationKeys(
keys,
keysMap,
currentKeyIndex,
'down',
);
setSelectedKeys([selectedKey]);
setOpenKeys(openKey ? [openKey] : []);
break;
}
// select the menu items going up
case NAV_KEYS.up:
case NAV_KEYS.tab && e.shiftKey: {
const { selectedKey, openKey } = getNavigationKeys(
keys,
keysMap,
currentKeyIndex,
'up',
);
setSelectedKeys([selectedKey]);
setOpenKeys(openKey ? [openKey] : []);
break;
}
case NAV_KEYS.escape:
// close dropdown menu
toggleDropdown();
break;
default:
break;
}
};

View File

@@ -75,7 +75,8 @@ const FILTER_NAME = 'Time filter 1';
const addFilterFlow = async () => {
// open filter config modal
userEvent.click(screen.getByTestId(getTestId('collapsable')));
userEvent.click(screen.getByTestId(getTestId('create-filter')));
userEvent.click(screen.getByLabelText('gear'));
userEvent.click(screen.getByText('Add or edit filters'));
// select filter
userEvent.click(screen.getByText('Value'));
userEvent.click(screen.getByText('Time range'));

View File

@@ -31,7 +31,7 @@ const initialState: { dashboardInfo: DashboardInfo } = {
id: 1,
userId: '1',
metadata: {
native_filter_configuration: {},
native_filter_configuration: [{}],
chart_configuration: {},
global_chart_configuration: {
scope: { rootPath: ['ROOT_ID'], excluded: [] },
@@ -81,15 +81,6 @@ test('Dropdown trigger renders with FF HORIZONTAL_FILTER_BAR on', async () => {
expect(screen.getByLabelText('gear')).toBeVisible();
});
test('Dropdown trigger does not render with FF HORIZONTAL_FILTER_BAR off', async () => {
// @ts-ignore
global.featureFlags = {
[FeatureFlag.HorizontalFilterBar]: false,
};
await setup();
expect(screen.queryByLabelText('gear')).not.toBeInTheDocument();
});
test('Dropdown trigger renders with dashboard edit permissions', async () => {
// @ts-ignore
global.featureFlags = {
@@ -128,7 +119,9 @@ test('Dropdown trigger does not render with FF DASHBOARD_CROSS_FILTERS off', asy
global.featureFlags = {
[FeatureFlag.DashboardCrossFilters]: false,
};
await setup();
await setup({
dash_edit_perm: false,
});
expect(screen.queryByRole('img', { name: 'gear' })).not.toBeInTheDocument();
});
@@ -175,7 +168,7 @@ test('Popover opens with "Vertical" selected', async () => {
expect(await screen.findByText('Vertical (Left)')).toBeInTheDocument();
expect(screen.getByText('Horizontal (Top)')).toBeInTheDocument();
expect(
within(screen.getAllByRole('menuitem')[1]).getByLabelText('check'),
within(screen.getAllByRole('menuitem')[2]).getByLabelText('check'),
).toBeInTheDocument();
});
@@ -190,7 +183,7 @@ test('Popover opens with "Horizontal" selected', async () => {
expect(await screen.findByText('Vertical (Left)')).toBeInTheDocument();
expect(screen.getByText('Horizontal (Top)')).toBeInTheDocument();
expect(
within(screen.getAllByRole('menuitem')[2]).getByLabelText('check'),
within(screen.getAllByRole('menuitem')[3]).getByLabelText('check'),
).toBeInTheDocument();
});
@@ -216,20 +209,20 @@ test('On selection change, send request and update checked value', async () => {
expect(await screen.findByText('Vertical (Left)')).toBeInTheDocument();
expect(screen.getByText('Horizontal (Top)')).toBeInTheDocument();
expect(
within(screen.getAllByRole('menuitem')[1]).getByLabelText('check'),
within(screen.getAllByRole('menuitem')[2]).getByLabelText('check'),
).toBeInTheDocument();
expect(
within(screen.getAllByRole('menuitem')[2]).queryByLabelText('check'),
within(screen.getAllByRole('menuitem')[3]).queryByLabelText('check'),
).not.toBeInTheDocument();
userEvent.click(screen.getByText('Horizontal (Top)'));
// 1st check - checkmark appears immediately after click
expect(
await within(screen.getAllByRole('menuitem')[2]).findByLabelText('check'),
await within(screen.getAllByRole('menuitem')[3]).findByLabelText('check'),
).toBeInTheDocument();
expect(
within(screen.getAllByRole('menuitem')[1]).queryByLabelText('check'),
within(screen.getAllByRole('menuitem')[2]).queryByLabelText('check'),
).not.toBeInTheDocument();
// successful query
@@ -246,10 +239,10 @@ test('On selection change, send request and update checked value', async () => {
// 2nd check - checkmark stays after successful query
expect(
await within(screen.getAllByRole('menuitem')[2]).findByLabelText('check'),
await within(screen.getAllByRole('menuitem')[3]).findByLabelText('check'),
).toBeInTheDocument();
expect(
within(screen.getAllByRole('menuitem')[1]).queryByLabelText('check'),
within(screen.getAllByRole('menuitem')[2]).queryByLabelText('check'),
).not.toBeInTheDocument();
fetchMock.reset();
@@ -273,10 +266,10 @@ test('On failed request, restore previous selection', async () => {
expect(screen.getByText('Horizontal (Top)')).toBeInTheDocument();
expect(
within(screen.getAllByRole('menuitem')[1]).getByLabelText('check'),
within(screen.getAllByRole('menuitem')[2]).getByLabelText('check'),
).toBeInTheDocument();
expect(
within(screen.getAllByRole('menuitem')[2]).queryByLabelText('check'),
within(screen.getAllByRole('menuitem')[3]).queryByLabelText('check'),
).not.toBeInTheDocument();
userEvent.click(await screen.findByText('Horizontal (Top)'));
@@ -294,10 +287,10 @@ test('On failed request, restore previous selection', async () => {
// checkmark gets rolled back to the original selection after successful query
expect(
await within(screen.getAllByRole('menuitem')[1]).findByLabelText('check'),
await within(screen.getAllByRole('menuitem')[2]).findByLabelText('check'),
).toBeInTheDocument();
expect(
within(screen.getAllByRole('menuitem')[2]).queryByLabelText('check'),
within(screen.getAllByRole('menuitem')[3]).queryByLabelText('check'),
).not.toBeInTheDocument();
fetchMock.reset();

View File

@@ -38,7 +38,9 @@ import DropdownSelectableIcon, {
} from 'src/components/DropdownSelectableIcon';
import Checkbox from 'src/components/Checkbox';
import { clearDataMaskState } from 'src/dataMask/actions';
import { useFilters } from 'src/dashboard/components/nativeFilters/FilterBar/state';
import { useCrossFiltersScopingModal } from '../CrossFilters/ScopingModal/useCrossFiltersScopingModal';
import FilterConfigurationLink from '../FilterConfigurationLink';
type SelectedKey = FilterBarOrientation | string | number;
@@ -65,6 +67,7 @@ const StyledCheckbox = styled(Checkbox)`
const CROSS_FILTERS_MENU_KEY = 'cross-filters-menu-key';
const CROSS_FILTERS_SCOPING_MENU_KEY = 'cross-filters-scoping-menu-key';
const ADD_EDIT_FILTERS_MENU_KEY = 'add-edit-filters-menu-key';
const isOrientation = (o: SelectedKey): o is FilterBarOrientation =>
o === FilterBarOrientation.Vertical || o === FilterBarOrientation.Horizontal;
@@ -91,6 +94,11 @@ const FilterBarSettings = () => {
const canEdit = useSelector<RootState, boolean>(
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
);
const filters = useFilters();
const filterValues = useMemo(() => Object.values(filters), [filters]);
const dashboardId = useSelector<RootState, number>(
({ dashboardInfo }) => dashboardInfo.id,
);
const canSetHorizontalFilterBar =
canEdit && isFeatureEnabled(FeatureFlag.HorizontalFilterBar);
@@ -166,6 +174,20 @@ const FilterBarSettings = () => {
const menuItems = useMemo(() => {
const items: DropDownSelectableProps['menuItems'] = [];
if (canEdit) {
items.push({
key: ADD_EDIT_FILTERS_MENU_KEY,
label: (
<FilterConfigurationLink
dashboardId={dashboardId}
createNewOnOpen={filterValues.length === 0}
>
{t('Add or edit filters')}
</FilterConfigurationLink>
),
divider: canSetHorizontalFilterBar,
});
}
if (isCrossFiltersFeatureEnabled && canEdit) {
items.push({
key: CROSS_FILTERS_MENU_KEY,
@@ -177,7 +199,6 @@ const FilterBarSettings = () => {
divider: canSetHorizontalFilterBar,
});
}
if (canSetHorizontalFilterBar) {
items.push({
key: 'placement',
@@ -199,6 +220,8 @@ const FilterBarSettings = () => {
canEdit,
canSetHorizontalFilterBar,
crossFiltersMenuItem,
dashboardId,
filterValues,
isCrossFiltersFeatureEnabled,
]);

View File

@@ -20,8 +20,6 @@ import { ReactNode, FC, useCallback, useState, memo } from 'react';
import { useDispatch } from 'react-redux';
import { setFilterConfiguration } from 'src/dashboard/actions/nativeFilters';
import Button from 'src/components/Button';
import { styled } from '@superset-ui/core';
import FiltersConfigModal from 'src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal';
import { getFilterBarTestId } from '../utils';
import { SaveFilterChangesType } from '../../FiltersConfigModal/types';
@@ -34,10 +32,6 @@ export interface FCBProps {
children?: ReactNode;
}
const HeaderButton = styled(Button)`
padding: 0;
`;
export const FilterConfigurationLink: FC<FCBProps> = ({
createNewOnOpen,
dashboardId,
@@ -69,14 +63,9 @@ export const FilterConfigurationLink: FC<FCBProps> = ({
return (
<>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<HeaderButton
{...getFilterBarTestId('create-filter')}
buttonStyle="link"
buttonSize="xsmall"
onClick={handleClick}
>
<span {...getFilterBarTestId('create-filter')} onClick={handleClick}>
{children}
</HeaderButton>
</span>
<FiltersConfigModal
isOpen={isOpen}
onSave={submit}

View File

@@ -18,13 +18,9 @@
*/
/* eslint-disable no-param-reassign */
import { css, styled, t, useTheme } from '@superset-ui/core';
import { memo, FC, useMemo } from 'react';
import { memo, FC } from 'react';
import Icons from 'src/components/Icons';
import Button from 'src/components/Button';
import { useSelector } from 'react-redux';
import FilterConfigurationLink from 'src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink';
import { useFilters } from 'src/dashboard/components/nativeFilters/FilterBar/state';
import { RootState } from 'src/dashboard/types';
import { getFilterBarTestId } from '../utils';
import FilterBarSettings from '../FilterBarSettings';
@@ -62,7 +58,6 @@ const Wrapper = styled.div`
padding: ${theme.gridUnit * 3}px ${theme.gridUnit * 2}px ${
theme.gridUnit
}px;
.ant-dropdown-trigger span {
padding-right: ${theme.gridUnit * 2}px;
}
@@ -73,35 +68,8 @@ type HeaderProps = {
toggleFiltersBar: (arg0: boolean) => void;
};
const AddFiltersButtonContainer = styled.div`
${({ theme }) => css`
margin-top: ${theme.gridUnit * 2}px;
& button > [role='img']:first-of-type {
margin-right: ${theme.gridUnit}px;
line-height: 0;
}
span[role='img'] {
padding-bottom: 1px;
}
.ant-btn > .anticon + span {
margin-left: 0;
}
`}
`;
const Header: FC<HeaderProps> = ({ toggleFiltersBar }) => {
const theme = useTheme();
const filters = useFilters();
const filterValues = useMemo(() => Object.values(filters), [filters]);
const canEdit = useSelector<RootState, boolean>(
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
);
const dashboardId = useSelector<RootState, number>(
({ dashboardInfo }) => dashboardInfo.id,
);
return (
<Wrapper>
@@ -117,16 +85,6 @@ const Header: FC<HeaderProps> = ({ toggleFiltersBar }) => {
<Icons.Expand iconColor={theme.colors.grayscale.base} />
</HeaderButton>
</TitleArea>
{canEdit && (
<AddFiltersButtonContainer>
<FilterConfigurationLink
dashboardId={dashboardId}
createNewOnOpen={filterValues.length === 0}
>
<Icons.PlusSmall /> {t('Add/Edit Filters')}
</FilterConfigurationLink>
</AddFiltersButtonContainer>
)}
</Wrapper>
);
};

View File

@@ -25,7 +25,6 @@ import {
styled,
t,
} from '@superset-ui/core';
import Icons from 'src/components/Icons';
import Loading from 'src/components/Loading';
import { RootState } from 'src/dashboard/types';
import { useChartLayoutItems } from 'src/dashboard/util/useChartLayoutItems';
@@ -35,7 +34,6 @@ import FilterControls from './FilterControls/FilterControls';
import { useChartsVerboseMaps, getFilterBarTestId } from './utils';
import { HorizontalBarProps } from './types';
import FilterBarSettings from './FilterBarSettings';
import FilterConfigurationLink from './FilterConfigurationLink';
import crossFiltersSelector from './CrossFilters/selectors';
import { CrossFilterIndicator } from '../selectors';
@@ -70,39 +68,13 @@ const FilterBarEmptyStateContainer = styled.div`
font-weight: ${theme.typography.weights.bold};
color: ${theme.colors.grayscale.base};
font-size: ${theme.typography.sizes.s}px;
`}
`;
const FiltersLinkContainer = styled.div<{ hasFilters: boolean }>`
${({ theme, hasFilters }) => `
height: 24px;
display: flex;
align-items: center;
padding: 0 ${theme.gridUnit * 4}px 0 ${theme.gridUnit * 4}px;
border-right: ${
hasFilters ? `1px solid ${theme.colors.grayscale.light2}` : 0
};
button {
display: flex;
align-items: center;
> .anticon {
height: 24px;
padding-right: ${theme.gridUnit}px;
}
> .anticon + span, > .anticon {
margin-right: 0;
margin-left: 0;
}
}
padding-left: ${theme.gridUnit * 2}px;
`}
`;
const EMPTY_ARRAY: CrossFilterIndicator[] = [];
const HorizontalFilterBar: FC<HorizontalBarProps> = ({
actions,
canEdit,
dashboardId,
dataMaskSelected,
filterValues,
isInitialized,
@@ -141,16 +113,6 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
) : (
<>
<FilterBarSettings />
{canEdit && (
<FiltersLinkContainer hasFilters={hasFilters}>
<FilterConfigurationLink
dashboardId={dashboardId}
createNewOnOpen={filterValues.length === 0}
>
<Icons.PlusSmall /> {t('Add/Edit Filters')}
</FilterConfigurationLink>
</FiltersLinkContainer>
)}
{!hasFilters && (
<FilterBarEmptyStateContainer data-test="horizontal-filterbar-empty">
{t('No filters are currently added to this dashboard.')}

View File

@@ -81,15 +81,3 @@ test('should render the loading icon', async () => {
});
expect(screen.getByRole('status', { name: 'Loading' })).toBeInTheDocument();
});
test('should render Add/Edit Filters', async () => {
await renderWrapper();
expect(screen.getByText('Add/Edit Filters')).toBeInTheDocument();
});
test('should not render Add/Edit Filters', async () => {
await renderWrapper({
canEdit: false,
});
expect(screen.queryByText('Add/Edit Filters')).not.toBeInTheDocument();
});

View File

@@ -174,7 +174,7 @@ const VerticalFilterBar: FC<VerticalBarProps> = ({
description={
canEdit &&
t(
'Click on "+Add/Edit Filters" button to create new dashboard filters',
'Click on "Add or Edit Filters" option in Settings to create new dashboard filters',
)
}
/>

View File

@@ -306,9 +306,7 @@ test('focus filter on filter card dependency click', () => {
test('edit filter button for dashboard viewer', () => {
renderContent();
expect(
screen.queryByRole('button', { name: /edit/i }),
).not.toBeInTheDocument();
expect(screen.queryByRole('img', { name: /edit/i })).not.toBeInTheDocument();
});
test('edit filter button for dashboard editor', () => {
@@ -317,7 +315,7 @@ test('edit filter button for dashboard editor', () => {
dashboardInfo: { dash_edit_perm: true },
});
expect(screen.getByRole('button', { name: /edit/i })).toBeVisible();
expect(screen.getByRole('img', { name: /edit/i })).toBeVisible();
});
test('open modal on edit filter button click', async () => {
@@ -326,7 +324,7 @@ test('open modal on edit filter button click', async () => {
dashboardInfo: { dash_edit_perm: true },
});
const editButton = screen.getByRole('button', { name: /edit/i });
const editButton = screen.getByRole('img', { name: /edit/i });
userEvent.click(editButton);
expect(
await screen.findByRole('dialog', { name: /add and edit filters/i }),

View File

@@ -62,7 +62,13 @@ export const NameRow = ({
onClick={hidePopover}
initialFilterId={filter.id}
>
<Icons.Edit iconSize="l" iconColor={theme.colors.grayscale.light1} />
<Icons.Edit
iconSize="l"
iconColor={theme.colors.grayscale.light1}
css={() => css`
cursor: pointer;
`}
/>
</FilterConfigurationLink>
)}
</Row>

View File

@@ -98,9 +98,7 @@ test('remove filter', async () => {
test('add filter', async () => {
defaultRender();
// First trash icon
const addButton = screen.getByText('Add filters and dividers')!;
fireEvent.mouseOver(addButton);
const addFilterButton = await screen.findByText('Filter');
const addFilterButton = await screen.findByText('Add Filter');
await act(async () => {
fireEvent(
@@ -116,9 +114,7 @@ test('add filter', async () => {
test('add divider', async () => {
defaultRender();
const addButton = screen.getByText('Add filters and dividers')!;
fireEvent.mouseOver(addButton);
const addFilterButton = await screen.findByText('Divider');
const addFilterButton = await screen.findByText('Add Divider');
await act(async () => {
fireEvent(
addFilterButton,
@@ -151,18 +147,18 @@ test('filter container should scroll to bottom when adding items', async () => {
defaultRender(state, props);
const addButton = screen.getByText('Add filters and dividers')!;
fireEvent.mouseOver(addButton);
const addFilterButton = await screen.findByText('Filter');
const addFilterButton = await screen.findByText('Add Filter');
// add enough filters to make it scroll in the next expectation.
await act(async () => {
fireEvent(
addFilterButton,
new MouseEvent('click', {
bubbles: true,
cancelable: true,
}),
);
for (let i = 0; i < 3; i += 1) {
fireEvent(
addFilterButton,
new MouseEvent('click', {
bubbles: true,
cancelable: true,
}),
);
}
});
const containerElement = screen.getByTestId('filter-title-container');

View File

@@ -19,8 +19,9 @@
import { useRef, FC } from 'react';
import { NativeFilterType, styled, t, useTheme } from '@superset-ui/core';
import { AntdDropdown } from 'src/components';
import { MainNav as Menu } from 'src/components/Menu';
import { Button } from 'src/components';
import Icons from 'src/components/Icons';
import FilterTitleContainer from './FilterTitleContainer';
import { FilterRemoval } from './types';
@@ -37,27 +38,14 @@ interface Props {
erroredFilters: string[];
}
const StyledAddBox = styled.div`
${({ theme }) => `
cursor: pointer;
margin: ${theme.gridUnit * 4}px;
color: ${theme.colors.primary.base};
&:hover {
color: ${theme.colors.primary.dark1};
}
`}
`;
const TabsContainer = styled.div`
height: 100%;
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.gridUnit * 3}px;
padding-top: 2px;
`;
const options = [
{ label: t('Filter'), type: NativeFilterType.NativeFilter },
{ label: t('Divider'), type: NativeFilterType.Divider },
];
const FilterTitlePane: FC<Props> = ({
getFilterTitle,
onChange,
@@ -70,9 +58,10 @@ const FilterTitlePane: FC<Props> = ({
removedFilters,
erroredFilters,
}) => {
const filtersContainerRef = useRef<HTMLDivElement>(null);
const theme = useTheme();
const filtersContainerRef = useRef<HTMLDivElement>(null);
const handleOnAdd = (type: NativeFilterType) => {
onAdd(type);
setTimeout(() => {
@@ -88,33 +77,12 @@ const FilterTitlePane: FC<Props> = ({
});
}, 0);
};
const menu = (
<Menu mode="horizontal">
{options.map(item => (
<Menu.Item onClick={() => handleOnAdd(item.type)}>
{item.label}
</Menu.Item>
))}
</Menu>
);
return (
<TabsContainer>
<AntdDropdown
overlay={menu}
arrow
placement="topLeft"
trigger={['hover']}
>
<StyledAddBox>
<div data-test="new-dropdown-icon" className="fa fa-plus" />{' '}
<span>{t('Add filters and dividers')}</span>
</StyledAddBox>
</AntdDropdown>
<div
css={{
height: '100%',
overflowY: 'auto',
marginLeft: theme.gridUnit * 3,
}}
>
<FilterTitleContainer
@@ -130,6 +98,33 @@ const FilterTitlePane: FC<Props> = ({
restoreFilter={restoreFilter}
/>
</div>
<div
css={{
display: 'flex',
justifyContent: 'space-around',
alignItems: 'flex-start',
paddingTop: theme.gridUnit * 3,
}}
>
<Button
buttonSize="default"
buttonStyle="secondary"
icon={<Icons.Filter iconSize="m" />}
data-test="add-new-filter-button"
onClick={() => handleOnAdd(NativeFilterType.NativeFilter)}
>
{t('Add Filter')}
</Button>
<Button
buttonSize="default"
buttonStyle="secondary"
icon={<Icons.PicCenterOutlined iconSize="m" />}
data-test="add-new-divider-button"
onClick={() => handleOnAdd(NativeFilterType.Divider)}
>
{t('Add Divider')}
</Button>
</div>
</TabsContainer>
);
};

View File

@@ -43,7 +43,7 @@ export function CancelConfirmationAlert({
css={{
textAlign: 'left',
flex: 1,
'& .ant-alert-action': { alignSelf: 'center' },
'& .antd5-alert-action': { alignSelf: 'center' },
}}
description={children}
action={

View File

@@ -88,13 +88,11 @@ describe('createNewOnOpen', () => {
test('shows correct alert message for unsaved filters', async () => {
const onCancel = jest.fn();
const { getByRole, getByTestId, findByRole } = setup({
const { getByRole, getByTestId } = setup({
onCancel,
createNewOnOpen: false,
});
fireEvent.mouseOver(getByTestId('new-dropdown-icon'));
const addFilterButton = await findByRole('menuitem', { name: 'Filter' });
fireEvent.click(addFilterButton);
fireEvent.click(getByTestId('add-new-filter-button'));
fireEvent.click(getByRole('button', { name: 'Cancel' }));
expect(onCancel).toHaveBeenCalledTimes(0);
expect(getByRole('alert')).toBeInTheDocument();

View File

@@ -166,82 +166,25 @@ export const antDModalStyles = (theme: SupersetTheme) => css`
`;
export const antDAlertStyles = (theme: SupersetTheme) => css`
border: 1px solid ${theme.colors.info.base};
padding: ${theme.gridUnit * 4}px;
margin: ${theme.gridUnit * 4}px 0;
.ant-alert-message {
color: ${theme.colors.info.dark2};
font-size: ${theme.typography.sizes.m}px;
font-weight: ${theme.typography.weights.bold};
}
.ant-alert-description {
color: ${theme.colors.info.dark2};
font-size: ${theme.typography.sizes.m}px;
line-height: ${theme.gridUnit * 5}px;
a {
text-decoration: underline;
}
.ant-alert-icon {
margin-right: ${theme.gridUnit * 2.5}px;
font-size: ${theme.typography.sizes.l}px;
position: relative;
top: ${theme.gridUnit / 4}px;
}
}
`;
export const StyledAlertMargin = styled.div`
${({ theme }) => css`
margin: 0 ${theme.gridUnit * 4}px -${theme.gridUnit * 4}px;
margin: 0 ${theme.gridUnit * 4}px ${theme.gridUnit * 4}px;
`}
`;
export const antDErrorAlertStyles = (theme: SupersetTheme) => css`
border: ${theme.colors.error.base} 1px solid;
padding: ${theme.gridUnit * 4}px;
margin: ${theme.gridUnit * 8}px ${theme.gridUnit * 4}px;
color: ${theme.colors.error.dark2};
.ant-alert-message {
font-size: ${theme.typography.sizes.m}px;
font-weight: ${theme.typography.weights.bold};
}
.ant-alert-description {
font-size: ${theme.typography.sizes.m}px;
line-height: ${theme.gridUnit * 5}px;
.ant-alert-icon {
margin-right: ${theme.gridUnit * 2.5}px;
font-size: ${theme.typography.sizes.l}px;
position: relative;
top: ${theme.gridUnit / 4}px;
}
}
`;
export const antdWarningAlertStyles = (theme: SupersetTheme) => css`
border: 1px solid ${theme.colors.warning.light1};
padding: ${theme.gridUnit * 4}px;
margin: ${theme.gridUnit * 4}px 0;
color: ${theme.colors.warning.dark2};
.ant-alert-message {
.antd5-alert-message {
margin: 0;
}
.ant-alert-description {
font-size: ${theme.typography.sizes.s + 1}px;
line-height: ${theme.gridUnit * 4}px;
.ant-alert-icon {
margin-right: ${theme.gridUnit * 2.5}px;
font-size: ${theme.typography.sizes.l + 1}px;
position: relative;
top: ${theme.gridUnit / 4}px;
}
}
`;
export const formHelperStyles = (theme: SupersetTheme) => css`

View File

@@ -278,7 +278,7 @@ const SavedQueries = ({
url={`/sqllab?savedQueryId=${q.id}`}
title={q.label}
imgFallbackURL="/static/assets/images/empty-query.svg"
description={t('Ran %s', q.changed_on_delta_humanized)}
description={t('Modified %s', q.changed_on_delta_humanized)}
cover={
q?.sql?.length && showThumbnails && featureFlag ? (
<QueryContainer>

View File

@@ -113,23 +113,6 @@ export const StyledRadioGroup = styled(Radio.Group)`
`;
export const antDErrorAlertStyles = (theme: SupersetTheme) => css`
border: ${theme.colors.error.base} 1px solid;
padding: ${theme.gridUnit * 4}px;
margin: ${theme.gridUnit * 4}px;
margin-top: 0;
color: ${theme.colors.error.dark2};
.ant-alert-message {
font-size: ${theme.typography.sizes.m}px;
font-weight: bold;
}
.ant-alert-description {
font-size: ${theme.typography.sizes.m}px;
line-height: ${theme.gridUnit * 4}px;
.ant-alert-icon {
margin-right: ${theme.gridUnit * 2.5}px;
font-size: ${theme.typography.sizes.l}px;
position: relative;
top: ${theme.gridUnit / 4}px;
}
}
`;

View File

@@ -38,6 +38,12 @@ export const usePermissions = () => {
);
const canDrillBy = (canExplore || canDrill) && canWriteExploreFormData;
const canDrillToDetail = (canExplore || canDrill) && canDatasourceSamples;
const canViewQuery = useSelector((state: RootState) =>
findPermission('can_view_query', 'Dashboard', state.user?.roles),
);
const canViewTable = useSelector((state: RootState) =>
findPermission('can_view_chart_as_table', 'Dashboard', state.user?.roles),
);
return {
canExplore,
@@ -47,5 +53,7 @@ export const usePermissions = () => {
canDrill,
canDrillBy,
canDrillToDetail,
canViewQuery,
canViewTable,
};
};

View File

@@ -296,6 +296,11 @@ function SavedQueryList({
{
accessor: 'label',
Header: t('Name'),
Cell: ({
row: {
original: { id, label },
},
}: any) => <Link to={`/sqllab?savedQueryId=${id}`}>{label}</Link>,
},
{
accessor: 'description',

View File

@@ -55,6 +55,15 @@ const baseConfig: ThemeConfig = {
zIndexPopupBase: supersetTheme.zIndex.max,
},
components: {
Alert: {
borderRadius: supersetTheme.borderRadius,
colorBgContainer: supersetTheme.colors.grayscale.light5,
colorBorder: supersetTheme.colors.grayscale.light3,
fontSize: supersetTheme.typography.sizes.m,
fontSizeLG: supersetTheme.typography.sizes.m,
fontSizeIcon: supersetTheme.typography.sizes.l,
colorText: supersetTheme.colors.grayscale.dark1,
},
Avatar: {
containerSize: 32,
fontSize: supersetTheme.typography.sizes.s,

View File

@@ -12,7 +12,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
FROM node:16-alpine as build
FROM node:16-alpine AS build
WORKDIR /home/superset-websocket

View File

@@ -23,7 +23,7 @@
"@types/cookie": "^0.6.0",
"@types/eslint__js": "^8.42.3",
"@types/ioredis": "^4.27.8",
"@types/jest": "^29.5.12",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.6",
"@types/lodash": "^4.17.7",
"@types/node": "^22.7.4",
@@ -1776,9 +1776,9 @@
}
},
"node_modules/@types/jest": {
"version": "29.5.12",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.12.tgz",
"integrity": "sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==",
"version": "29.5.14",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz",
"integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==",
"dev": true,
"dependencies": {
"expect": "^29.0.0",
@@ -7841,9 +7841,9 @@
}
},
"@types/jest": {
"version": "29.5.12",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.12.tgz",
"integrity": "sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==",
"version": "29.5.14",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz",
"integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==",
"dev": true,
"requires": {
"expect": "^29.0.0",

View File

@@ -31,7 +31,7 @@
"@types/cookie": "^0.6.0",
"@types/eslint__js": "^8.42.3",
"@types/ioredis": "^4.27.8",
"@types/jest": "^29.5.12",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.6",
"@types/lodash": "^4.17.7",
"@types/node": "^22.7.4",

View File

@@ -34,7 +34,8 @@ from superset.security import SupersetSecurityManager # noqa: F401
# All of the fields located here should be considered legacy. The correct way
# to declare "global" dependencies is to define it in extensions.py,
# then initialize it in app.create_app(). These fields will be removed
# in subsequent PRs as things are migrated towards the factory pattern
# in subsequent PRs as things are migrated towards the factory
# pattern
app: Flask = current_app
cache = cache_manager.cache
conf = LocalProxy(lambda: current_app.config)

View File

@@ -1342,7 +1342,7 @@ DISALLOWED_SQL_FUNCTIONS: dict[str, set[str]] = {
"table_to_xml_and_xmlschema",
"version",
},
"clickhouse": {"url"},
"clickhouse": {"url", "version", "currentDatabase", "hostName"},
"mysql": {"version"},
}

View File

@@ -35,25 +35,6 @@ logger = logging.getLogger(__name__)
class QueryDAO(BaseDAO[Query]):
base_filter = QueryFilter
@staticmethod
def update_saved_query_exec_info(query_id: int) -> None:
"""
Propagates query execution info back to saved query if applicable
:param query_id: The query id
:return:
"""
query = db.session.query(Query).get(query_id)
related_saved_queries = (
db.session.query(SavedQuery)
.filter(SavedQuery.database == query.database)
.filter(SavedQuery.sql == query.sql)
).all()
if related_saved_queries:
for saved_query in related_saved_queries:
saved_query.rows = query.rows
saved_query.last_run = datetime.now()
@staticmethod
def save_metadata(query: Query, payload: dict[str, Any]) -> None:
# pull relevant data from payload and store in extra_json

View File

@@ -14,7 +14,6 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# pylint: disable=consider-using-transaction
from __future__ import annotations
import contextlib
@@ -216,6 +215,8 @@ class TrinoEngineSpec(PrestoBaseEngineSpec):
if tracking_url := cls.get_tracking_url(cursor):
query.tracking_url = tracking_url
db.session.commit() # pylint: disable=consider-using-transaction
# if query cancelation was requested prior to the handle_cursor call, but
# the query was still executed, trigger the actual query cancelation now
if query.extra.get(QUERY_EARLY_CANCEL_KEY):
@@ -244,6 +245,7 @@ class TrinoEngineSpec(PrestoBaseEngineSpec):
# Fetch the query ID beforehand, since it might fail inside the thread due to
# how the SQLAlchemy session is handled.
query_id = query.id
query_database = query.database
execute_result: dict[str, Any] = {}
execute_event = threading.Event()
@@ -266,7 +268,7 @@ class TrinoEngineSpec(PrestoBaseEngineSpec):
with app.app_context():
for key, value in g_copy.__dict__.items():
setattr(g, key, value)
cls.execute(cursor, sql, query.database)
cls.execute(cursor, sql, query_database)
except Exception as ex: # pylint: disable=broad-except
results["error"] = ex
finally:
@@ -283,6 +285,8 @@ class TrinoEngineSpec(PrestoBaseEngineSpec):
)
execute_thread.start()
# Wait for the thread to start before continuing
time.sleep(0.1)
# Wait for a query ID to be available before handling the cursor, as
# it's required by that method; it may never become available on error.
while not cursor.query_id and not execute_event.is_set():
@@ -304,7 +308,7 @@ class TrinoEngineSpec(PrestoBaseEngineSpec):
def prepare_cancel_query(cls, query: Query) -> None:
if QUERY_CANCEL_KEY not in query.extra:
query.set_extra_json_key(QUERY_EARLY_CANCEL_KEY, True)
db.session.commit()
db.session.commit() # pylint: disable=consider-using-transaction
@classmethod
def cancel_query(cls, cursor: Cursor, query: Query, cancel_query_id: str) -> bool:

108
superset/db_engine_specs/ydb.py Executable file
View File

@@ -0,0 +1,108 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, TYPE_CHECKING
from sqlalchemy import types
from superset.constants import TimeGrain
from superset.db_engine_specs.base import BaseEngineSpec
from superset.utils import json
if TYPE_CHECKING:
from superset.models.core import Database
logger = logging.getLogger(__name__)
class YDBEngineSpec(BaseEngineSpec):
engine = "yql"
engine_aliases = {"ydb", "yql+ydb"}
engine_name = "YDB"
default_driver = "ydb"
sqlalchemy_uri_placeholder = "ydb://{host}:{port}/{database_name}"
# pylint: disable=invalid-name
encrypted_extra_sensitive_fields = {"$.connect_args.credentials", "$.credentials"}
disable_ssh_tunneling = False
supports_file_upload = False
allows_alias_in_orderby = True
_time_grain_expressions = {
None: "{col}",
TimeGrain.SECOND: "DateTime::MakeDatetime(DateTime::StartOf({col}, Interval('PT1S')))",
TimeGrain.THIRTY_SECONDS: "DateTime::MakeDatetime(DateTime::StartOf({col}, Interval('PT30S')))",
TimeGrain.MINUTE: "DateTime::MakeDatetime(DateTime::StartOf({col}, Interval('PT1M')))",
TimeGrain.FIVE_MINUTES: "DateTime::MakeDatetime(DateTime::StartOf({col}, Interval('PT5M')))",
TimeGrain.TEN_MINUTES: "DateTime::MakeDatetime(DateTime::StartOf({col}, Interval('PT10M')))",
TimeGrain.FIFTEEN_MINUTES: "DateTime::MakeDatetime(DateTime::StartOf({col}, Interval('PT15M')))",
TimeGrain.THIRTY_MINUTES: "DateTime::MakeDatetime(DateTime::StartOf({col}, Interval('PT30M')))",
TimeGrain.HOUR: "DateTime::MakeDatetime(DateTime::StartOf({col}, Interval('PT1H')))",
TimeGrain.DAY: "DateTime::MakeDatetime(DateTime::StartOf({col}, Interval('P1D')))",
TimeGrain.WEEK: "DateTime::MakeDatetime(DateTime::StartOfWeek({col}))",
TimeGrain.MONTH: "DateTime::MakeDatetime(DateTime::StartOfMonth({col}))",
TimeGrain.QUARTER: "DateTime::MakeDatetime(DateTime::StartOfQuarter({col}))",
TimeGrain.YEAR: "DateTime::MakeDatetime(DateTime::StartOfYear({col}))",
}
@classmethod
def epoch_to_dttm(cls) -> str:
return "DateTime::MakeDatetime({col})"
@classmethod
def convert_dttm(
cls, target_type: str, dttm: datetime, db_extra: dict[str, Any] | None = None
) -> str | None:
sqla_type = cls.get_sqla_column_type(target_type)
if isinstance(sqla_type, types.Date):
return f"DateTime::MakeDate(DateTime::ParseIso8601('{dttm.date().isoformat()}'))"
if isinstance(sqla_type, types.DateTime):
return f"""DateTime::MakeDatetime(DateTime::ParseIso8601('{dttm.isoformat(sep="T", timespec="seconds")}'))"""
return None
@staticmethod
def update_params_from_encrypted_extra(
database: Database,
params: dict[str, Any],
) -> None:
if not database.encrypted_extra:
return
try:
encrypted_extra = json.loads(database.encrypted_extra)
connect_args = params.setdefault("connect_args", {})
if "protocol" in encrypted_extra:
connect_args["protocol"] = encrypted_extra["protocol"]
if "credentials" in encrypted_extra:
credentials_info = encrypted_extra["credentials"]
connect_args["credentials"] = credentials_info
except json.JSONDecodeError as ex:
logger.error(ex, exc_info=True)
raise

View File

@@ -21,6 +21,7 @@ from collections.abc import Iterator
from typing import Any, Callable, Optional, Union
from uuid import uuid4
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql.base import MySQLDialect
@@ -182,3 +183,17 @@ def has_table(table_name: str) -> bool:
table_exists = insp.has_table(table_name)
return table_exists
def add_column_if_not_exists(table_name: str, column: sa.Column) -> None:
"""
Adds a column to a table if it does not already exist.
:param table_name: Name of the table.
:param column: SQLAlchemy Column object.
"""
if not table_has_column(table_name, column.name):
print(f"Adding column '{column.name}' to table '{table_name}'.\n")
op.add_column(table_name, column)
else:
print(f"Column '{column.name}' already exists in table '{table_name}'.\n")

View File

@@ -24,14 +24,17 @@ Create Date: 2024-04-01 22:44:40.386543
import sqlalchemy as sa
from alembic import op
from superset.migrations.shared.utils import add_column_if_not_exists
# revision identifiers, used by Alembic.
revision = "c22cb5c2e546"
down_revision = "678eefb4ab44"
def upgrade():
op.add_column(
"user_attribute", sa.Column("avatar_url", sa.String(length=100), nullable=True)
add_column_if_not_exists(
"user_attribute",
sa.Column("avatar_url", sa.String(length=100), nullable=True),
)

View File

@@ -25,6 +25,8 @@ Create Date: 2024-04-11 15:41:34.663989
import sqlalchemy as sa
from alembic import op
from superset.migrations.shared.utils import add_column_if_not_exists
# revision identifiers, used by Alembic.
revision = "5f57af97bc3f"
down_revision = "d60591c5515f"
@@ -34,7 +36,7 @@ tables = ["tables", "query", "saved_query", "tab_state", "table_schema"]
def upgrade():
for table in tables:
op.add_column(
add_column_if_not_exists(
table,
sa.Column("catalog", sa.String(length=256), nullable=True),
)

View File

@@ -29,6 +29,7 @@ from superset.migrations.shared.catalogs import (
downgrade_catalog_perms,
upgrade_catalog_perms,
)
from superset.migrations.shared.utils import add_column_if_not_exists
# revision identifiers, used by Alembic.
revision = "58d051681a3b"
@@ -36,11 +37,11 @@ down_revision = "4a33124c18ad"
def upgrade():
op.add_column(
add_column_if_not_exists(
"tables",
sa.Column("catalog_perm", sa.String(length=1000), nullable=True),
)
op.add_column(
add_column_if_not_exists(
"slices",
sa.Column("catalog_perm", sa.String(length=1000), nullable=True),
)

View File

@@ -95,7 +95,6 @@ class SynchronousSqlJsonExecutor(SqlJsonExecutorBase):
data = self._get_sql_results_with_timeout(
execution_context, rendered_query, log_params
)
self._query_dao.update_saved_query_exec_info(query_id)
execution_context.set_execution_result(data)
except SupersetTimeoutException:
raise
@@ -200,5 +199,4 @@ class ASynchronousSqlJsonExecutor(SqlJsonExecutorBase):
query.status = QueryStatus.FAILED
query.error_message = message
raise SupersetErrorException(error) from ex
self._query_dao.update_saved_query_exec_info(query_id)
return SqlJsonExecutionStatus.QUERY_IS_RUNNING

View File

@@ -17,7 +17,6 @@
# isort:skip_file
"""Unit tests for Sql Lab"""
from datetime import datetime
from textwrap import dedent
import pytest
@@ -26,7 +25,6 @@ from parameterized import parameterized
from unittest import mock
import prison
from freezegun import freeze_time
from superset import db, security_manager
from superset.connectors.sqla.models import SqlaTable # noqa: F401
from superset.db_engine_specs import BaseEngineSpec
@@ -34,7 +32,7 @@ from superset.db_engine_specs.hive import HiveEngineSpec
from superset.db_engine_specs.presto import PrestoEngineSpec
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetErrorException
from superset.models.sql_lab import Query, SavedQuery
from superset.models.sql_lab import Query
from superset.result_set import SupersetResultSet
from superset.sqllab.limiting_factor import LimitingFactor
from superset.sql_lab import (
@@ -156,34 +154,6 @@ class TestSqlLab(SupersetTestCase):
]
}
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_sql_json_to_saved_query_info(self):
"""
SQLLab: Test SQLLab query execution info propagation to saved queries
"""
self.login(ADMIN_USERNAME)
sql_statement = "SELECT * FROM birth_names LIMIT 10"
examples_db_id = get_example_database().id
saved_query = SavedQuery(db_id=examples_db_id, sql=sql_statement)
db.session.add(saved_query)
db.session.commit()
with freeze_time(datetime.now().isoformat(timespec="seconds")):
self.run_sql(sql_statement, "1")
saved_query_ = (
db.session.query(SavedQuery)
.filter(
SavedQuery.db_id == examples_db_id, SavedQuery.sql == sql_statement
)
.one_or_none()
)
assert saved_query_.rows is not None
assert saved_query_.last_run == datetime.now()
# Rollback changes
db.session.delete(saved_query_)
db.session.commit()
@parameterized.expand([CtasMethod.TABLE, CtasMethod.VIEW])
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_sql_json_cta_dynamic_db(self, ctas_method):

View File

@@ -0,0 +1,83 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# pylint: disable=unused-argument, import-outside-toplevel, protected-access
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
from unittest.mock import Mock
import pytest
from superset.utils import json
from tests.unit_tests.db_engine_specs.utils import assert_convert_dttm
from tests.unit_tests.fixtures.common import dttm # noqa: F401
def test_epoch_to_dttm() -> None:
from superset.db_engine_specs.ydb import YDBEngineSpec
assert YDBEngineSpec.epoch_to_dttm() == "DateTime::MakeDatetime({col})"
@pytest.mark.parametrize(
"target_type,expected_result",
[
("Date", "DateTime::MakeDate(DateTime::ParseIso8601('2019-01-02'))"),
(
"DateTime",
"DateTime::MakeDatetime(DateTime::ParseIso8601('2019-01-02T03:04:05'))",
),
("UnknownType", None),
],
)
def test_convert_dttm(
target_type: str,
expected_result: Optional[str],
dttm: datetime, # noqa: F811
) -> None:
from superset.db_engine_specs.ydb import YDBEngineSpec as spec
assert_convert_dttm(spec, target_type, expected_result, dttm)
def test_specify_protocol() -> None:
from superset.db_engine_specs.ydb import YDBEngineSpec
database = Mock()
extra = {"protocol": "grpcs"}
database.encrypted_extra = json.dumps(extra)
params: dict[str, Any] = {}
YDBEngineSpec.update_params_from_encrypted_extra(database, params)
connect_args = params.setdefault("connect_args", {})
assert connect_args.get("protocol") == "grpcs"
def test_specify_credentials() -> None:
from superset.db_engine_specs.ydb import YDBEngineSpec
database = Mock()
auth_params = {"username": "username", "password": "password"}
database.encrypted_extra = json.dumps({"credentials": auth_params})
params: dict[str, Any] = {}
YDBEngineSpec.update_params_from_encrypted_extra(database, params)
connect_args = params.setdefault("connect_args", {})
assert connect_args.get("credentials") == auth_params