mirror of
https://github.com/apache/superset.git
synced 2026-07-01 20:35:35 +00:00
Compare commits
36 Commits
supersetbo
...
nix-button
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79aff6827c | ||
|
|
079e7327a2 | ||
|
|
48864ce8c7 | ||
|
|
2816a70af3 | ||
|
|
6af22a9cdd | ||
|
|
827fe06903 | ||
|
|
45815d8642 | ||
|
|
cf5c770adc | ||
|
|
638f82b46d | ||
|
|
e0e1eea9ce | ||
|
|
27c7240185 | ||
|
|
5ca2a8f670 | ||
|
|
2d60a2d48c | ||
|
|
b70c8ee7a8 | ||
|
|
a3fd7423b0 | ||
|
|
f679a18e82 | ||
|
|
77f3764fea | ||
|
|
1e0c04fc15 | ||
|
|
56b973f3cc | ||
|
|
3479574bd4 | ||
|
|
aa55751b1d | ||
|
|
6c2aade375 | ||
|
|
f51f19bcba | ||
|
|
1d44662b1d | ||
|
|
25f4226dbb | ||
|
|
dd1ba96adf | ||
|
|
d4888fa4af | ||
|
|
b3559f644c | ||
|
|
fe80fb1090 | ||
|
|
43efa05113 | ||
|
|
e5e3f9e210 | ||
|
|
468dfed416 | ||
|
|
3564740255 | ||
|
|
8020729ced | ||
|
|
deec63bb5b | ||
|
|
339d491dfc |
4
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
4
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -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
|
||||
|
||||
8
.github/actions/setup-backend/action.yml
vendored
8
.github/actions/setup-backend/action.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/superset-frontend.yml
vendored
2
.github/workflows/superset-frontend.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -70,6 +70,7 @@ google-sheets.svg
|
||||
ibm-db2.svg
|
||||
postgresql.svg
|
||||
snowflake.svg
|
||||
ydb.svg
|
||||
|
||||
# docs-related
|
||||
erd.puml
|
||||
|
||||
179
Dockerfile
179
Dockerfile
@@ -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...
|
||||
######################################################################
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
51
docker/apt-install.sh
Executable 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
64
docker/pip-install.sh
Executable 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."
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
20
docs/static/img/databases/ydb.svg
vendored
Normal 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 |
867
docs/yarn.lock
867
docs/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/** ************************************************************************
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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"]',
|
||||
|
||||
68
superset-frontend/package-lock.json
generated
68
superset-frontend/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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],
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"handlebars": "^4.7.8",
|
||||
"handlebars-group-by": "^1.0.1",
|
||||
"just-handlebars-helpers": "^1.0.19"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -22,3 +22,4 @@ declare module '*.png' {
|
||||
}
|
||||
declare module '*.jpg';
|
||||
declare module 'just-handlebars-helpers';
|
||||
declare module 'handlebars-group-by';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
))()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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'));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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.')}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
14
superset-websocket/package-lock.json
generated
14
superset-websocket/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
108
superset/db_engine_specs/ydb.py
Executable 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
|
||||
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
83
tests/unit_tests/db_engine_specs/test_ydb.py
Normal file
83
tests/unit_tests/db_engine_specs/test_ydb.py
Normal 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
|
||||
Reference in New Issue
Block a user