mirror of
https://github.com/getredash/redash.git
synced 2025-12-20 01:47:39 -05:00
Compare commits
1 Commits
query-base
...
link-compo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d22060ebf3 |
@@ -1,12 +1,12 @@
|
|||||||
FROM cypress/browsers:node14.0.0-chrome84
|
FROM cypress/browsers:chrome67
|
||||||
|
|
||||||
ENV APP /usr/src/app
|
ENV APP /usr/src/app
|
||||||
WORKDIR $APP
|
WORKDIR $APP
|
||||||
|
|
||||||
COPY package.json package-lock.json $APP/
|
COPY package.json $APP/package.json
|
||||||
COPY viz-lib $APP/viz-lib
|
RUN npm run cypress:install > /dev/null
|
||||||
RUN npm ci > /dev/null
|
|
||||||
|
|
||||||
COPY . $APP
|
COPY client/cypress $APP/client/cypress
|
||||||
|
COPY cypress.json $APP/cypress.json
|
||||||
|
|
||||||
RUN ./node_modules/.bin/cypress verify
|
RUN ./node_modules/.bin/cypress verify
|
||||||
|
|||||||
@@ -57,9 +57,6 @@ jobs:
|
|||||||
- store_artifacts:
|
- store_artifacts:
|
||||||
path: coverage.xml
|
path: coverage.xml
|
||||||
frontend-lint:
|
frontend-lint:
|
||||||
environment:
|
|
||||||
CYPRESS_INSTALL_BINARY: 0
|
|
||||||
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
|
|
||||||
docker:
|
docker:
|
||||||
- image: circleci/node:12
|
- image: circleci/node:12
|
||||||
steps:
|
steps:
|
||||||
@@ -70,9 +67,6 @@ jobs:
|
|||||||
- store_test_results:
|
- store_test_results:
|
||||||
path: /tmp/test-results
|
path: /tmp/test-results
|
||||||
frontend-unit-tests:
|
frontend-unit-tests:
|
||||||
environment:
|
|
||||||
CYPRESS_INSTALL_BINARY: 0
|
|
||||||
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
|
|
||||||
docker:
|
docker:
|
||||||
- image: circleci/node:12
|
- image: circleci/node:12
|
||||||
steps:
|
steps:
|
||||||
@@ -96,20 +90,11 @@ jobs:
|
|||||||
PERCY_TOKEN_ENCODED: ZGRiY2ZmZDQ0OTdjMzM5ZWE0ZGQzNTZiOWNkMDRjOTk4Zjg0ZjMxMWRmMDZiM2RjOTYxNDZhOGExMjI4ZDE3MA==
|
PERCY_TOKEN_ENCODED: ZGRiY2ZmZDQ0OTdjMzM5ZWE0ZGQzNTZiOWNkMDRjOTk4Zjg0ZjMxMWRmMDZiM2RjOTYxNDZhOGExMjI4ZDE3MA==
|
||||||
CYPRESS_PROJECT_ID_ENCODED: OTI0Y2th
|
CYPRESS_PROJECT_ID_ENCODED: OTI0Y2th
|
||||||
CYPRESS_RECORD_KEY_ENCODED: YzA1OTIxMTUtYTA1Yy00NzQ2LWEyMDMtZmZjMDgwZGI2ODgx
|
CYPRESS_RECORD_KEY_ENCODED: YzA1OTIxMTUtYTA1Yy00NzQ2LWEyMDMtZmZjMDgwZGI2ODgx
|
||||||
CYPRESS_INSTALL_BINARY: 0
|
|
||||||
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
|
|
||||||
docker:
|
docker:
|
||||||
- image: circleci/node:12
|
- image: circleci/node:12
|
||||||
steps:
|
steps:
|
||||||
- setup_remote_docker
|
- setup_remote_docker
|
||||||
- checkout
|
- checkout
|
||||||
- run:
|
|
||||||
name: Enable Code Coverage report for master branch
|
|
||||||
command: |
|
|
||||||
if [ "$CIRCLE_BRANCH" = "master" ]; then
|
|
||||||
echo 'export CODE_COVERAGE=true' >> $BASH_ENV
|
|
||||||
source $BASH_ENV
|
|
||||||
fi
|
|
||||||
- run:
|
- run:
|
||||||
name: Install npm dependencies
|
name: Install npm dependencies
|
||||||
command: |
|
command: |
|
||||||
@@ -128,13 +113,6 @@ jobs:
|
|||||||
command: |
|
command: |
|
||||||
docker-compose logs
|
docker-compose logs
|
||||||
when: on_fail
|
when: on_fail
|
||||||
- run:
|
|
||||||
name: Copy Code Coverage results
|
|
||||||
command: |
|
|
||||||
docker cp cypress:/usr/src/app/coverage ./coverage || true
|
|
||||||
when: always
|
|
||||||
- store_artifacts:
|
|
||||||
path: coverage
|
|
||||||
build-docker-image: *build-docker-image-job
|
build-docker-image: *build-docker-image-job
|
||||||
build-preview-docker-image: *build-docker-image-job
|
build-preview-docker-image: *build-docker-image-job
|
||||||
workflows:
|
workflows:
|
||||||
|
|||||||
@@ -1,20 +1,7 @@
|
|||||||
version: "2.2"
|
version: '2.2'
|
||||||
x-redash-service: &redash-service
|
|
||||||
build:
|
|
||||||
context: ../
|
|
||||||
args:
|
|
||||||
skip_dev_deps: "true"
|
|
||||||
skip_ds_deps: "true"
|
|
||||||
code_coverage: ${CODE_COVERAGE}
|
|
||||||
x-redash-environment: &redash-environment
|
|
||||||
REDASH_LOG_LEVEL: "INFO"
|
|
||||||
REDASH_REDIS_URL: "redis://redis:6379/0"
|
|
||||||
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
|
||||||
REDASH_RATELIMIT_ENABLED: "false"
|
|
||||||
REDASH_ENFORCE_CSRF: "true"
|
|
||||||
services:
|
services:
|
||||||
server:
|
server:
|
||||||
<<: *redash-service
|
build: ../
|
||||||
command: server
|
command: server
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
@@ -22,25 +9,30 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
environment:
|
environment:
|
||||||
<<: *redash-environment
|
|
||||||
PYTHONUNBUFFERED: 0
|
PYTHONUNBUFFERED: 0
|
||||||
|
REDASH_LOG_LEVEL: "INFO"
|
||||||
|
REDASH_REDIS_URL: "redis://redis:6379/0"
|
||||||
|
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
||||||
|
REDASH_RATELIMIT_ENABLED: "false"
|
||||||
|
REDASH_ENFORCE_CSRF: "true"
|
||||||
scheduler:
|
scheduler:
|
||||||
<<: *redash-service
|
build: ../
|
||||||
command: scheduler
|
command: scheduler
|
||||||
depends_on:
|
depends_on:
|
||||||
- server
|
- server
|
||||||
environment:
|
environment:
|
||||||
<<: *redash-environment
|
REDASH_REDIS_URL: "redis://redis:6379/0"
|
||||||
worker:
|
worker:
|
||||||
<<: *redash-service
|
build: ../
|
||||||
command: worker
|
command: worker
|
||||||
depends_on:
|
depends_on:
|
||||||
- server
|
- server
|
||||||
environment:
|
environment:
|
||||||
<<: *redash-environment
|
|
||||||
PYTHONUNBUFFERED: 0
|
PYTHONUNBUFFERED: 0
|
||||||
|
REDASH_LOG_LEVEL: "INFO"
|
||||||
|
REDASH_REDIS_URL: "redis://redis:6379/0"
|
||||||
|
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
||||||
cypress:
|
cypress:
|
||||||
ipc: host
|
|
||||||
build:
|
build:
|
||||||
context: ../
|
context: ../
|
||||||
dockerfile: .circleci/Dockerfile.cypress
|
dockerfile: .circleci/Dockerfile.cypress
|
||||||
@@ -50,7 +42,6 @@ services:
|
|||||||
- scheduler
|
- scheduler
|
||||||
environment:
|
environment:
|
||||||
CYPRESS_baseUrl: "http://server:5000"
|
CYPRESS_baseUrl: "http://server:5000"
|
||||||
CYPRESS_coverage: ${CODE_COVERAGE}
|
|
||||||
PERCY_TOKEN: ${PERCY_TOKEN}
|
PERCY_TOKEN: ${PERCY_TOKEN}
|
||||||
PERCY_BRANCH: ${CIRCLE_BRANCH}
|
PERCY_BRANCH: ${CIRCLE_BRANCH}
|
||||||
PERCY_COMMIT: ${CIRCLE_SHA1}
|
PERCY_COMMIT: ${CIRCLE_SHA1}
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,8 +5,6 @@ venv/
|
|||||||
.coveralls.yml
|
.coveralls.yml
|
||||||
.idea
|
.idea
|
||||||
*.pyc
|
*.pyc
|
||||||
.nyc_output
|
|
||||||
coverage
|
|
||||||
.coverage
|
.coverage
|
||||||
coverage.xml
|
coverage.xml
|
||||||
client/dist
|
client/dist
|
||||||
|
|||||||
19
Dockerfile
19
Dockerfile
@@ -3,24 +3,13 @@ FROM node:12 as frontend-builder
|
|||||||
# Controls whether to build the frontend assets
|
# Controls whether to build the frontend assets
|
||||||
ARG skip_frontend_build
|
ARG skip_frontend_build
|
||||||
|
|
||||||
ENV CYPRESS_INSTALL_BINARY=0
|
|
||||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
|
|
||||||
|
|
||||||
RUN useradd -m -d /frontend redash
|
|
||||||
USER redash
|
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
COPY --chown=redash package.json package-lock.json /frontend/
|
COPY package.json package-lock.json /frontend/
|
||||||
COPY --chown=redash viz-lib /frontend/viz-lib
|
COPY viz-lib /frontend/viz-lib
|
||||||
|
|
||||||
# Controls whether to instrument code for coverage information
|
|
||||||
ARG code_coverage
|
|
||||||
ENV BABEL_ENV=${code_coverage:+test}
|
|
||||||
|
|
||||||
RUN if [ "x$skip_frontend_build" = "x" ] ; then npm ci --unsafe-perm; fi
|
RUN if [ "x$skip_frontend_build" = "x" ] ; then npm ci --unsafe-perm; fi
|
||||||
|
|
||||||
COPY --chown=redash client /frontend/client
|
COPY client /frontend/client
|
||||||
COPY --chown=redash webpack.config.js /frontend/
|
COPY webpack.config.js /frontend/
|
||||||
RUN if [ "x$skip_frontend_build" = "x" ] ; then npm run build; else mkdir -p /frontend/client/dist && touch /frontend/client/dist/multi_org.html && touch /frontend/client/dist/index.html; fi
|
RUN if [ "x$skip_frontend_build" = "x" ] ; then npm run build; else mkdir -p /frontend/client/dist && touch /frontend/client/dist/multi_org.html && touch /frontend/client/dist/index.html; fi
|
||||||
FROM python:3.7-slim
|
FROM python:3.7-slim
|
||||||
|
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -35,7 +35,7 @@ backend-unit-tests: up test_db
|
|||||||
docker-compose run --rm --name tests server tests
|
docker-compose run --rm --name tests server tests
|
||||||
|
|
||||||
frontend-unit-tests: bundle
|
frontend-unit-tests: bundle
|
||||||
CYPRESS_INSTALL_BINARY=0 PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 npm ci
|
npm ci
|
||||||
npm run bundle
|
npm run bundle
|
||||||
npm test
|
npm test
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ worker() {
|
|||||||
|
|
||||||
export WORKERS_COUNT=${WORKERS_COUNT:-2}
|
export WORKERS_COUNT=${WORKERS_COUNT:-2}
|
||||||
export QUEUES=${QUEUES:-}
|
export QUEUES=${QUEUES:-}
|
||||||
|
|
||||||
exec supervisord -c worker.conf
|
supervisord -c worker.conf
|
||||||
}
|
}
|
||||||
|
|
||||||
dev_worker() {
|
dev_worker() {
|
||||||
|
|||||||
@@ -20,10 +20,5 @@
|
|||||||
"globals": ["Error"]
|
"globals": ["Error"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
],
|
]
|
||||||
"env": {
|
|
||||||
"test": {
|
|
||||||
"plugins": ["istanbul"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,21 +20,6 @@ module.exports = {
|
|||||||
// allow debugger during development
|
// allow debugger during development
|
||||||
"no-debugger": process.env.NODE_ENV === "production" ? 2 : 0,
|
"no-debugger": process.env.NODE_ENV === "production" ? 2 : 0,
|
||||||
"jsx-a11y/anchor-is-valid": "off",
|
"jsx-a11y/anchor-is-valid": "off",
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
paths: [
|
|
||||||
{
|
|
||||||
name: "antd",
|
|
||||||
message: "Please use 'import XXX from antd/lib/XXX' import instead.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "antd/lib",
|
|
||||||
message: "Please use 'import XXX from antd/lib/XXX' import instead.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
@@ -49,8 +34,6 @@ module.exports = {
|
|||||||
// Do not complain about useless contructors in declaration files
|
// Do not complain about useless contructors in declaration files
|
||||||
"no-useless-constructor": "off",
|
"no-useless-constructor": "off",
|
||||||
"@typescript-eslint/no-useless-constructor": "error",
|
"@typescript-eslint/no-useless-constructor": "error",
|
||||||
// Many API fields and generated types use camelcase
|
|
||||||
"@typescript-eslint/camelcase": "off",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -16,6 +16,7 @@
|
|||||||
@import "~antd/lib/pagination/style/index";
|
@import "~antd/lib/pagination/style/index";
|
||||||
@import "~antd/lib/table/style/index";
|
@import "~antd/lib/table/style/index";
|
||||||
@import "~antd/lib/popover/style/index";
|
@import "~antd/lib/popover/style/index";
|
||||||
|
@import "~antd/lib/icon/style/index";
|
||||||
@import "~antd/lib/tag/style/index";
|
@import "~antd/lib/tag/style/index";
|
||||||
@import "~antd/lib/grid/style/index";
|
@import "~antd/lib/grid/style/index";
|
||||||
@import "~antd/lib/switch/style/index";
|
@import "~antd/lib/switch/style/index";
|
||||||
@@ -401,14 +402,3 @@
|
|||||||
.@{checkbox-prefix-cls} + span {
|
.@{checkbox-prefix-cls} + span {
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// make sure Multiple select has room for icons
|
|
||||||
.@{select-prefix-cls}-multiple {
|
|
||||||
&.@{select-prefix-cls}-show-arrow,
|
|
||||||
&.@{select-prefix-cls}-show-search,
|
|
||||||
&.@{select-prefix-cls}-loading {
|
|
||||||
.@{select-prefix-cls}-selector {
|
|
||||||
padding-right: 30px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,10 +23,6 @@
|
|||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-form-item-explain {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-last-triggered {
|
.alert-last-triggered {
|
||||||
color: @headings-color;
|
color: @headings-color;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,7 +141,6 @@ a.label-tag {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.query-fullscreen {
|
.query-fullscreen {
|
||||||
|
|||||||
@@ -3,21 +3,13 @@ import React, { useState } from "react";
|
|||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import Menu from "antd/lib/menu";
|
import Menu from "antd/lib/menu";
|
||||||
import Link from "@/components/Link";
|
import Link from "@/components/Link";
|
||||||
|
import Icon from "antd/lib/icon";
|
||||||
import HelpTrigger from "@/components/HelpTrigger";
|
import HelpTrigger from "@/components/HelpTrigger";
|
||||||
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
|
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
|
||||||
import { Auth, currentUser } from "@/services/auth";
|
import { Auth, currentUser } from "@/services/auth";
|
||||||
import settingsMenu from "@/services/settingsMenu";
|
import settingsMenu from "@/services/settingsMenu";
|
||||||
import logoUrl from "@/assets/images/redash_icon_small.png";
|
import logoUrl from "@/assets/images/redash_icon_small.png";
|
||||||
|
|
||||||
import DesktopOutlinedIcon from "@ant-design/icons/DesktopOutlined";
|
|
||||||
import CodeOutlinedIcon from "@ant-design/icons/CodeOutlined";
|
|
||||||
import AlertOutlinedIcon from "@ant-design/icons/AlertOutlined";
|
|
||||||
import PlusOutlinedIcon from "@ant-design/icons/PlusOutlined";
|
|
||||||
import QuestionCircleOutlinedIcon from "@ant-design/icons/QuestionCircleOutlined";
|
|
||||||
import SettingOutlinedIcon from "@ant-design/icons/SettingOutlined";
|
|
||||||
import MenuUnfoldOutlinedIcon from "@ant-design/icons/MenuUnfoldOutlined";
|
|
||||||
import MenuFoldOutlinedIcon from "@ant-design/icons/MenuFoldOutlined";
|
|
||||||
|
|
||||||
import VersionInfo from "./VersionInfo";
|
import VersionInfo from "./VersionInfo";
|
||||||
import "./DesktopNavbar.less";
|
import "./DesktopNavbar.less";
|
||||||
|
|
||||||
@@ -57,7 +49,7 @@ export default function DesktopNavbar() {
|
|||||||
{currentUser.hasPermission("list_dashboards") && (
|
{currentUser.hasPermission("list_dashboards") && (
|
||||||
<Menu.Item key="dashboards">
|
<Menu.Item key="dashboards">
|
||||||
<Link href="dashboards">
|
<Link href="dashboards">
|
||||||
<DesktopOutlinedIcon />
|
<Icon type="desktop" />
|
||||||
<span>Dashboards</span>
|
<span>Dashboards</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
@@ -65,7 +57,7 @@ export default function DesktopNavbar() {
|
|||||||
{currentUser.hasPermission("view_query") && (
|
{currentUser.hasPermission("view_query") && (
|
||||||
<Menu.Item key="queries">
|
<Menu.Item key="queries">
|
||||||
<Link href="queries">
|
<Link href="queries">
|
||||||
<CodeOutlinedIcon />
|
<Icon type="code" />
|
||||||
<span>Queries</span>
|
<span>Queries</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
@@ -73,7 +65,7 @@ export default function DesktopNavbar() {
|
|||||||
{currentUser.hasPermission("list_alerts") && (
|
{currentUser.hasPermission("list_alerts") && (
|
||||||
<Menu.Item key="alerts">
|
<Menu.Item key="alerts">
|
||||||
<Link href="alerts">
|
<Link href="alerts">
|
||||||
<AlertOutlinedIcon />
|
<Icon type="alert" />
|
||||||
<span>Alerts</span>
|
<span>Alerts</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
@@ -89,7 +81,7 @@ export default function DesktopNavbar() {
|
|||||||
title={
|
title={
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<span data-test="CreateButton">
|
<span data-test="CreateButton">
|
||||||
<PlusOutlinedIcon />
|
<Icon type="plus" />
|
||||||
<span>Create</span>
|
<span>Create</span>
|
||||||
</span>
|
</span>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
@@ -122,14 +114,14 @@ export default function DesktopNavbar() {
|
|||||||
<NavbarSection inlineCollapsed={collapsed}>
|
<NavbarSection inlineCollapsed={collapsed}>
|
||||||
<Menu.Item key="help">
|
<Menu.Item key="help">
|
||||||
<HelpTrigger showTooltip={false} type="HOME">
|
<HelpTrigger showTooltip={false} type="HOME">
|
||||||
<QuestionCircleOutlinedIcon />
|
<Icon type="question-circle" />
|
||||||
<span>Help</span>
|
<span>Help</span>
|
||||||
</HelpTrigger>
|
</HelpTrigger>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
{firstSettingsTab && (
|
{firstSettingsTab && (
|
||||||
<Menu.Item key="settings">
|
<Menu.Item key="settings">
|
||||||
<Link href={firstSettingsTab.path} data-test="SettingsLink">
|
<Link href={firstSettingsTab.path} data-test="SettingsLink">
|
||||||
<SettingOutlinedIcon />
|
<Icon type="setting" />
|
||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
@@ -169,7 +161,7 @@ export default function DesktopNavbar() {
|
|||||||
</NavbarSection>
|
</NavbarSection>
|
||||||
|
|
||||||
<Button onClick={() => setCollapsed(!collapsed)} className="desktop-navbar-collapse-button">
|
<Button onClick={() => setCollapsed(!collapsed)} className="desktop-navbar-collapse-button">
|
||||||
{collapsed ? <MenuUnfoldOutlinedIcon /> : <MenuFoldOutlinedIcon />}
|
<Icon type={collapsed ? "menu-unfold" : "menu-fold"} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { first } from "lodash";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import MenuOutlinedIcon from "@ant-design/icons/MenuOutlined";
|
import Icon from "antd/lib/icon";
|
||||||
import Dropdown from "antd/lib/dropdown";
|
import Dropdown from "antd/lib/dropdown";
|
||||||
import Menu from "antd/lib/menu";
|
import Menu from "antd/lib/menu";
|
||||||
import Link from "@/components/Link";
|
import Link from "@/components/Link";
|
||||||
@@ -71,7 +71,7 @@ export default function MobileNavbar({ getPopupContainer }) {
|
|||||||
</Menu>
|
</Menu>
|
||||||
}>
|
}>
|
||||||
<Button className="mobile-navbar-toggle-button" ghost>
|
<Button className="mobile-navbar-toggle-button" ghost>
|
||||||
<MenuOutlinedIcon />
|
<Icon type="menu" />
|
||||||
</Button>
|
</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,21 +13,19 @@ export default function ApplicationLayout({ children }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<DynamicComponent name="ApplicationWrapper">
|
<div className="application-layout-side-menu">
|
||||||
<div className="application-layout-side-menu">
|
<DynamicComponent name="ApplicationDesktopNavbar">
|
||||||
<DynamicComponent name="ApplicationDesktopNavbar">
|
<DesktopNavbar />
|
||||||
<DesktopNavbar />
|
</DynamicComponent>
|
||||||
|
</div>
|
||||||
|
<div className="application-layout-content">
|
||||||
|
<nav className="application-layout-top-menu" ref={mobileNavbarContainerRef}>
|
||||||
|
<DynamicComponent name="ApplicationMobileNavbar" getPopupContainer={getMobileNavbarPopupContainer}>
|
||||||
|
<MobileNavbar getPopupContainer={getMobileNavbarPopupContainer} />
|
||||||
</DynamicComponent>
|
</DynamicComponent>
|
||||||
</div>
|
</nav>
|
||||||
<div className="application-layout-content">
|
{children}
|
||||||
<nav className="application-layout-top-menu" ref={mobileNavbarContainerRef}>
|
</div>
|
||||||
<DynamicComponent name="ApplicationMobileNavbar" getPopupContainer={getMobileNavbarPopupContainer}>
|
|
||||||
<MobileNavbar getPopupContainer={getMobileNavbarPopupContainer} />
|
|
||||||
</DynamicComponent>
|
|
||||||
</nav>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</DynamicComponent>
|
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { get, isObject } from "lodash";
|
import { isObject, get } from "lodash";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
import "./ErrorMessage.less";
|
import "./ErrorMessage.less";
|
||||||
import DynamicComponent from "@/components/DynamicComponent";
|
|
||||||
import { ErrorMessageDetails } from "@/components/ApplicationArea/ErrorMessageDetails";
|
|
||||||
|
|
||||||
function getErrorMessageByStatus(status, defaultMessage) {
|
function getErrorMessageByStatus(status, defaultMessage) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -33,30 +31,21 @@ function getErrorMessage(error) {
|
|||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ErrorMessage({ error, message }) {
|
export default function ErrorMessage({ error }) {
|
||||||
if (!error) {
|
if (!error) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
const errorDetailsProps = {
|
|
||||||
error,
|
|
||||||
message: message || getErrorMessage(error),
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="error-message-container" data-test="ErrorMessage" role="alert">
|
<div className="error-message-container" data-test="ErrorMessage">
|
||||||
<div className="error-state bg-white tiled">
|
<div className="error-state bg-white tiled">
|
||||||
<div className="error-state__icon">
|
<div className="error-state__icon">
|
||||||
<i className="zmdi zmdi-alert-circle-o" />
|
<i className="zmdi zmdi-alert-circle-o" />
|
||||||
</div>
|
</div>
|
||||||
<div className="error-state__details">
|
<div className="error-state__details">
|
||||||
<DynamicComponent
|
<h4>{getErrorMessage(error)}</h4>
|
||||||
name="ErrorMessageDetails"
|
|
||||||
fallback={<ErrorMessageDetails {...errorDetailsProps} />}
|
|
||||||
{...errorDetailsProps}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,5 +54,4 @@ export default function ErrorMessage({ error, message }) {
|
|||||||
|
|
||||||
ErrorMessage.propTypes = {
|
ErrorMessage.propTypes = {
|
||||||
error: PropTypes.object.isRequired,
|
error: PropTypes.object.isRequired,
|
||||||
message: PropTypes.string,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import PropTypes from "prop-types";
|
|
||||||
|
|
||||||
export function ErrorMessageDetails(props) {
|
|
||||||
return <h4>{props.message}</h4>;
|
|
||||||
}
|
|
||||||
|
|
||||||
ErrorMessageDetails.propTypes = {
|
|
||||||
error: PropTypes.instanceOf(Error).isRequired,
|
|
||||||
message: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
@@ -9,7 +9,7 @@ export default function handleNavigationIntent(event) {
|
|||||||
}
|
}
|
||||||
element = element.parentNode;
|
element = element.parentNode;
|
||||||
}
|
}
|
||||||
if (!element || !element.hasAttribute("href") || element.hasAttribute("download") || element.dataset.skipRouter) {
|
if (!element || !element.hasAttribute("href") || element.hasAttribute("download")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState, useContext } from "react";
|
import React, { useEffect, useState, useContext } from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
|
import { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
|
||||||
import { Auth, clientConfig } from "@/services/auth";
|
import { Auth } from "@/services/auth";
|
||||||
|
|
||||||
// This wrapper modifies `route.render` function and instead of passing `currentRoute` passes an object
|
// This wrapper modifies `route.render` function and instead of passing `currentRoute` passes an object
|
||||||
// that contains:
|
// that contains:
|
||||||
@@ -33,7 +33,7 @@ function ApiKeySessionWrapper({ apiKey, currentRoute, renderChildren }) {
|
|||||||
};
|
};
|
||||||
}, [apiKey]);
|
}, [apiKey]);
|
||||||
|
|
||||||
if (!isAuthenticated || clientConfig.disablePublicUrls) {
|
if (!isAuthenticated) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,21 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
// @ts-expect-error (Must be removed after adding @redash/viz typing)
|
import PropTypes from "prop-types";
|
||||||
import ErrorBoundary, { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
|
import ErrorBoundary, { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
|
||||||
import { Auth } from "@/services/auth";
|
import { Auth } from "@/services/auth";
|
||||||
import { policy } from "@/services/policy";
|
import { policy } from "@/services/policy";
|
||||||
import { CurrentRoute } from "@/services/routes";
|
|
||||||
import organizationStatus from "@/services/organizationStatus";
|
import organizationStatus from "@/services/organizationStatus";
|
||||||
import DynamicComponent from "@/components/DynamicComponent";
|
|
||||||
import ApplicationLayout from "./ApplicationLayout";
|
import ApplicationLayout from "./ApplicationLayout";
|
||||||
import ErrorMessage from "./ErrorMessage";
|
import ErrorMessage from "./ErrorMessage";
|
||||||
|
|
||||||
export type UserSessionWrapperRenderChildrenProps<P> = {
|
|
||||||
pageTitle?: string;
|
|
||||||
onError: (error: Error) => void;
|
|
||||||
} & P;
|
|
||||||
|
|
||||||
export interface UserSessionWrapperProps<P> {
|
|
||||||
render: (props: UserSessionWrapperRenderChildrenProps<P>) => React.ReactNode;
|
|
||||||
currentRoute: CurrentRoute<P>;
|
|
||||||
bodyClass?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This wrapper modifies `route.render` function and instead of passing `currentRoute` passes an object
|
// This wrapper modifies `route.render` function and instead of passing `currentRoute` passes an object
|
||||||
// that contains:
|
// that contains:
|
||||||
// - `currentRoute.routeParams`
|
// - `currentRoute.routeParams`
|
||||||
// - `pageTitle` field which is equal to `currentRoute.title`
|
// - `pageTitle` field which is equal to `currentRoute.title`
|
||||||
// - `onError` field which is a `handleError` method of nearest error boundary
|
// - `onError` field which is a `handleError` method of nearest error boundary
|
||||||
|
|
||||||
export function UserSessionWrapper<P>({ bodyClass, currentRoute, render }: UserSessionWrapperProps<P>) {
|
function UserSessionWrapper({ bodyClass, currentRoute, renderChildren }) {
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(!!Auth.isAuthenticated());
|
const [isAuthenticated, setIsAuthenticated] = useState(!!Auth.isAuthenticated());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
Promise.all([Auth.requireSession(), organizationStatus.refresh(), policy.refresh()])
|
Promise.all([Auth.requireSession(), organizationStatus.refresh(), policy.refresh()])
|
||||||
@@ -62,10 +50,10 @@ export function UserSessionWrapper<P>({ bodyClass, currentRoute, render }: UserS
|
|||||||
return (
|
return (
|
||||||
<ApplicationLayout>
|
<ApplicationLayout>
|
||||||
<React.Fragment key={currentRoute.key}>
|
<React.Fragment key={currentRoute.key}>
|
||||||
<ErrorBoundary renderError={(error: Error) => <ErrorMessage error={error} />}>
|
<ErrorBoundary renderError={error => <ErrorMessage error={error} />}>
|
||||||
<ErrorBoundaryContext.Consumer>
|
<ErrorBoundaryContext.Consumer>
|
||||||
{({ handleError }: { handleError: UserSessionWrapperRenderChildrenProps<P>["onError"] }) =>
|
{({ handleError }) =>
|
||||||
render({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError })
|
renderChildren({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError })
|
||||||
}
|
}
|
||||||
</ErrorBoundaryContext.Consumer>
|
</ErrorBoundaryContext.Consumer>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
@@ -74,35 +62,21 @@ export function UserSessionWrapper<P>({ bodyClass, currentRoute, render }: UserS
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RouteWithUserSessionOptions<P> = {
|
UserSessionWrapper.propTypes = {
|
||||||
render: (props: UserSessionWrapperRenderChildrenProps<P>) => React.ReactNode;
|
bodyClass: PropTypes.string,
|
||||||
bodyClass?: string;
|
renderChildren: PropTypes.func,
|
||||||
title: string;
|
|
||||||
path: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UserSessionWrapperDynamicComponentName = "UserSessionWrapper";
|
UserSessionWrapper.defaultProps = {
|
||||||
|
bodyClass: null,
|
||||||
|
renderChildren: () => null,
|
||||||
|
};
|
||||||
|
|
||||||
export default function routeWithUserSession<P extends {} = {}>({
|
export default function routeWithUserSession({ render, bodyClass, ...rest }) {
|
||||||
render: originalRender,
|
|
||||||
bodyClass,
|
|
||||||
...rest
|
|
||||||
}: RouteWithUserSessionOptions<P>) {
|
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
render: (currentRoute: CurrentRoute<P>) => {
|
render: currentRoute => (
|
||||||
const props = {
|
<UserSessionWrapper bodyClass={bodyClass} currentRoute={currentRoute} renderChildren={render} />
|
||||||
render: originalRender,
|
),
|
||||||
bodyClass,
|
|
||||||
currentRoute,
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<DynamicComponent
|
|
||||||
{...props}
|
|
||||||
name={UserSessionWrapperDynamicComponentName}
|
|
||||||
fallback={<UserSessionWrapper {...props} />}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@ import React from "react";
|
|||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import Tooltip from "antd/lib/tooltip";
|
import Tooltip from "antd/lib/tooltip";
|
||||||
import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined";
|
|
||||||
import "./CodeBlock.less";
|
import "./CodeBlock.less";
|
||||||
|
|
||||||
export default class CodeBlock extends React.Component {
|
export default class CodeBlock extends React.Component {
|
||||||
@@ -60,7 +59,7 @@ export default class CodeBlock extends React.Component {
|
|||||||
|
|
||||||
const copyButton = (
|
const copyButton = (
|
||||||
<Tooltip title={this.state.copied || "Copy"}>
|
<Tooltip title={this.state.copied || "Copy"}>
|
||||||
<Button icon={<CopyOutlinedIcon />} type="dashed" size="small" onClick={this.copy} />
|
<Button icon="copy" type="dashed" size="small" onClick={this.copy} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
4
client/app/components/DialogWrapper.d.ts
vendored
4
client/app/components/DialogWrapper.d.ts
vendored
@@ -22,8 +22,8 @@ export function wrap<ROk = void, P = {}, RCancel = void>(
|
|||||||
props?: P
|
props?: P
|
||||||
) => {
|
) => {
|
||||||
update: (props: P) => void;
|
update: (props: P) => void;
|
||||||
onClose: (handler: (result: ROk) => Promise<void> | void) => void;
|
onClose: (handler: (result: ROk) => Promise<void>) => void;
|
||||||
onDismiss: (handler: (result: RCancel) => Promise<void> | void) => void;
|
onDismiss: (handler: (result: RCancel) => Promise<void>) => void;
|
||||||
close: (result: ROk) => void;
|
close: (result: ROk) => void;
|
||||||
dismiss: (result: RCancel) => void;
|
dismiss: (result: RCancel) => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isFunction, isString, isUndefined } from "lodash";
|
import { isFunction, isString } from "lodash";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
@@ -24,7 +24,6 @@ export function unregisterComponent(name) {
|
|||||||
export default class DynamicComponent extends React.Component {
|
export default class DynamicComponent extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
name: PropTypes.string.isRequired,
|
name: PropTypes.string.isRequired,
|
||||||
fallback: PropTypes.node,
|
|
||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,11 +40,10 @@ export default class DynamicComponent extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { name, children, fallback, ...props } = this.props;
|
const { name, children, ...props } = this.props;
|
||||||
const RealComponent = componentsRegistry.get(name);
|
const RealComponent = componentsRegistry.get(name);
|
||||||
if (!RealComponent) {
|
if (!RealComponent) {
|
||||||
// return fallback if any, otherwise return children
|
return children;
|
||||||
return isUndefined(fallback) ? children : fallback;
|
|
||||||
}
|
}
|
||||||
return <RealComponent {...props}>{children}</RealComponent>;
|
return <RealComponent {...props}>{children}</RealComponent>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { includes, words, capitalize, clone, isNull, map, get, find } from "lodash";
|
import { includes, words, capitalize, clone, isNull } from "lodash";
|
||||||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import Checkbox from "antd/lib/checkbox";
|
import Checkbox from "antd/lib/checkbox";
|
||||||
import Modal from "antd/lib/modal";
|
import Modal from "antd/lib/modal";
|
||||||
@@ -11,8 +11,6 @@ import Divider from "antd/lib/divider";
|
|||||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||||
import QuerySelector from "@/components/QuerySelector";
|
import QuerySelector from "@/components/QuerySelector";
|
||||||
import { Query } from "@/services/query";
|
import { Query } from "@/services/query";
|
||||||
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
|
|
||||||
import QueryBasedParameterMappingTable from "./query-based-parameter/QueryBasedParameterMappingTable";
|
|
||||||
|
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
|
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
|
||||||
@@ -71,27 +69,17 @@ NameInput.propTypes = {
|
|||||||
function EditParameterSettingsDialog(props) {
|
function EditParameterSettingsDialog(props) {
|
||||||
const [param, setParam] = useState(clone(props.parameter));
|
const [param, setParam] = useState(clone(props.parameter));
|
||||||
const [isNameValid, setIsNameValid] = useState(true);
|
const [isNameValid, setIsNameValid] = useState(true);
|
||||||
const [paramQuery, setParamQuery] = useState();
|
const [initialQuery, setInitialQuery] = useState();
|
||||||
const mappingParameters = useMemo(
|
|
||||||
() =>
|
|
||||||
map(paramQuery && paramQuery.getParametersDefs(), mappingParam => ({
|
|
||||||
mappingParam,
|
|
||||||
existingMapping: get(param.parameterMapping, mappingParam.name, {
|
|
||||||
mappingType: QueryBasedParameterMappingType.UNDEFINED,
|
|
||||||
}),
|
|
||||||
})),
|
|
||||||
[param.parameterMapping, paramQuery]
|
|
||||||
);
|
|
||||||
|
|
||||||
const isNew = !props.parameter.name;
|
const isNew = !props.parameter.name;
|
||||||
|
|
||||||
// fetch query by id
|
// fetch query by id
|
||||||
const initialQueryId = useRef(props.parameter.queryId);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialQueryId.current) {
|
const queryId = props.parameter.queryId;
|
||||||
Query.get({ id: initialQueryId.current }).then(setParamQuery);
|
if (queryId) {
|
||||||
|
Query.get({ id: queryId }).then(setInitialQuery);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [props.parameter.queryId]);
|
||||||
|
|
||||||
function isFulfilled() {
|
function isFulfilled() {
|
||||||
// name
|
// name
|
||||||
@@ -105,20 +93,14 @@ function EditParameterSettingsDialog(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// query
|
// query
|
||||||
if (param.type === "query") {
|
if (param.type === "query" && !param.queryId) {
|
||||||
if (!param.queryId) {
|
return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (find(mappingParameters, { existingMapping: { mappingType: QueryBasedParameterMappingType.UNDEFINED } })) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onConfirm() {
|
function onConfirm(e) {
|
||||||
// update title to default
|
// update title to default
|
||||||
if (!param.title) {
|
if (!param.title) {
|
||||||
// forced to do this cause param won't update in time for save
|
// forced to do this cause param won't update in time for save
|
||||||
@@ -127,6 +109,8 @@ function EditParameterSettingsDialog(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
props.dialog.close(param);
|
props.dialog.close(param);
|
||||||
|
|
||||||
|
e.preventDefault(); // stops form redirect
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -148,7 +132,7 @@ function EditParameterSettingsDialog(props) {
|
|||||||
{isNew ? "Add Parameter" : "OK"}
|
{isNew ? "Add Parameter" : "OK"}
|
||||||
</Button>,
|
</Button>,
|
||||||
]}>
|
]}>
|
||||||
<Form layout="horizontal" onFinish={onConfirm} id="paramForm">
|
<Form layout="horizontal" onSubmit={onConfirm} id="paramForm">
|
||||||
{isNew && (
|
{isNew && (
|
||||||
<NameInput
|
<NameInput
|
||||||
name={param.name}
|
name={param.name}
|
||||||
@@ -158,7 +142,7 @@ function EditParameterSettingsDialog(props) {
|
|||||||
type={param.type}
|
type={param.type}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Form.Item required label="Title" {...formItemProps}>
|
<Form.Item label="Title" {...formItemProps}>
|
||||||
<Input
|
<Input
|
||||||
value={isNull(param.title) ? getDefaultTitle(param.name) : param.title}
|
value={isNull(param.title) ? getDefaultTitle(param.name) : param.title}
|
||||||
onChange={e => setParam({ ...param, title: e.target.value })}
|
onChange={e => setParam({ ...param, title: e.target.value })}
|
||||||
@@ -205,28 +189,14 @@ function EditParameterSettingsDialog(props) {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
{param.type === "query" && (
|
{param.type === "query" && (
|
||||||
<Form.Item label="Query" help="Select query to load dropdown values from" required {...formItemProps}>
|
<Form.Item label="Query" help="Select query to load dropdown values from" {...formItemProps}>
|
||||||
<QuerySelector
|
<QuerySelector
|
||||||
selectedQuery={paramQuery}
|
selectedQuery={initialQuery}
|
||||||
onChange={q => {
|
onChange={q => setParam({ ...param, queryId: q && q.id })}
|
||||||
if (q) {
|
|
||||||
setParamQuery(q);
|
|
||||||
setParam({ ...param, queryId: q.id, parameterMapping: {} });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
type="select"
|
type="select"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
{param.type === "query" && paramQuery && paramQuery.hasParameters() && (
|
|
||||||
<Form.Item className="m-t-15 m-b-5" label="Parameters" required {...formItemProps}>
|
|
||||||
<QueryBasedParameterMappingTable
|
|
||||||
param={param}
|
|
||||||
mappingParameters={mappingParameters}
|
|
||||||
onChangeParam={setParam}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
)}
|
|
||||||
{(param.type === "enum" || param.type === "query") && (
|
{(param.type === "enum" || param.type === "query") && (
|
||||||
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
|
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef, useReducer } from "react";
|
|
||||||
import PropTypes from "prop-types";
|
|
||||||
import { values } from "lodash";
|
|
||||||
import Button from "antd/lib/button";
|
|
||||||
import Tooltip from "antd/lib/tooltip";
|
|
||||||
import Radio from "antd/lib/radio";
|
|
||||||
import Typography from "antd/lib/typography/Typography";
|
|
||||||
import ParameterValueInput from "@/components/ParameterValueInput";
|
|
||||||
import InputPopover from "@/components/InputPopover";
|
|
||||||
import Form from "antd/lib/form";
|
|
||||||
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
|
|
||||||
|
|
||||||
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
|
|
||||||
import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
|
|
||||||
export default function QueryBasedParameterMappingEditor({ parameter, mapping, searchAvailable, onChange }) {
|
|
||||||
const [showPopover, setShowPopover] = useState(false);
|
|
||||||
const [newMapping, setNewMapping] = useReducer((prevState, updates) => ({ ...prevState, ...updates }), mapping);
|
|
||||||
|
|
||||||
const newMappingRef = useRef(newMapping);
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
mapping.mappingType !== newMappingRef.current.mappingType ||
|
|
||||||
mapping.staticValue !== newMappingRef.current.staticValue
|
|
||||||
) {
|
|
||||||
setNewMapping(mapping);
|
|
||||||
}
|
|
||||||
}, [mapping]);
|
|
||||||
|
|
||||||
const parameterRef = useRef(parameter);
|
|
||||||
useEffect(() => {
|
|
||||||
parameterRef.current.setValue(mapping.staticValue);
|
|
||||||
}, [mapping.staticValue]);
|
|
||||||
|
|
||||||
const onCancel = () => {
|
|
||||||
setNewMapping(mapping);
|
|
||||||
setShowPopover(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onOk = () => {
|
|
||||||
onChange(newMapping);
|
|
||||||
setShowPopover(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentState = <Text type="secondary">Pick a type</Text>;
|
|
||||||
if (mapping.mappingType === QueryBasedParameterMappingType.DROPDOWN_SEARCH) {
|
|
||||||
currentState = "Dropdown Search";
|
|
||||||
} else if (mapping.mappingType === QueryBasedParameterMappingType.STATIC) {
|
|
||||||
currentState = `Value: ${mapping.staticValue}`;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{currentState}
|
|
||||||
<InputPopover
|
|
||||||
placement="left"
|
|
||||||
trigger="click"
|
|
||||||
header="Edit Parameter Source"
|
|
||||||
okButtonProps={{
|
|
||||||
disabled: newMapping.mappingType === QueryBasedParameterMappingType.STATIC && parameter.isEmpty,
|
|
||||||
}}
|
|
||||||
onOk={onOk}
|
|
||||||
onCancel={onCancel}
|
|
||||||
content={
|
|
||||||
<Form>
|
|
||||||
<Form.Item className="m-b-15" label="Source" {...formItemProps}>
|
|
||||||
<Radio.Group
|
|
||||||
value={newMapping.mappingType}
|
|
||||||
onChange={({ target }) => setNewMapping({ mappingType: target.value })}>
|
|
||||||
<Radio
|
|
||||||
className="radio"
|
|
||||||
value={QueryBasedParameterMappingType.DROPDOWN_SEARCH}
|
|
||||||
disabled={!searchAvailable || parameter.type !== "text"}>
|
|
||||||
Dropdown Search{" "}
|
|
||||||
{(!searchAvailable || parameter.type !== "text") && (
|
|
||||||
<Tooltip
|
|
||||||
title={
|
|
||||||
parameter.type !== "text"
|
|
||||||
? "Dropdown Search is only available for Text Parameters"
|
|
||||||
: "There is already a parameter mapped with the Dropdown Search type."
|
|
||||||
}>
|
|
||||||
<QuestionCircleFilledIcon />
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Radio>
|
|
||||||
<Radio className="radio" value={QueryBasedParameterMappingType.STATIC}>
|
|
||||||
Static Value
|
|
||||||
</Radio>
|
|
||||||
</Radio.Group>
|
|
||||||
</Form.Item>
|
|
||||||
{newMapping.mappingType === QueryBasedParameterMappingType.STATIC && (
|
|
||||||
<Form.Item label="Value" required {...formItemProps}>
|
|
||||||
<ParameterValueInput
|
|
||||||
type={parameter.type}
|
|
||||||
value={parameter.normalizedValue}
|
|
||||||
enumOptions={parameter.enumOptions}
|
|
||||||
queryId={parameter.queryId}
|
|
||||||
parameter={parameter}
|
|
||||||
onSelect={value => {
|
|
||||||
parameter.setValue(value);
|
|
||||||
setNewMapping({ staticValue: parameter.getExecutionValue({ joinListValues: true }) });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
)}
|
|
||||||
</Form>
|
|
||||||
}
|
|
||||||
visible={showPopover}
|
|
||||||
onVisibleChange={setShowPopover}>
|
|
||||||
<Button className="m-l-5" size="small" type="dashed">
|
|
||||||
<EditOutlinedIcon />
|
|
||||||
</Button>
|
|
||||||
</InputPopover>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
QueryBasedParameterMappingEditor.propTypes = {
|
|
||||||
parameter: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
|
||||||
mapping: PropTypes.shape({
|
|
||||||
mappingType: PropTypes.oneOf(values(QueryBasedParameterMappingType)),
|
|
||||||
staticValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
|
||||||
}),
|
|
||||||
searchAvailable: PropTypes.bool,
|
|
||||||
onChange: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
QueryBasedParameterMappingEditor.defaultProps = {
|
|
||||||
mapping: { mappingType: QueryBasedParameterMappingType.UNDEFINED, staticValue: undefined },
|
|
||||||
searchAvailable: false,
|
|
||||||
onChange: () => {},
|
|
||||||
};
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { findKey } from "lodash";
|
|
||||||
import PropTypes from "prop-types";
|
|
||||||
import Table from "antd/lib/table";
|
|
||||||
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
|
|
||||||
import QueryBasedParameterMappingEditor from "./QueryBasedParameterMappingEditor";
|
|
||||||
|
|
||||||
export default function QueryBasedParameterMappingTable({ param, mappingParameters, onChangeParam }) {
|
|
||||||
return (
|
|
||||||
<Table
|
|
||||||
dataSource={mappingParameters}
|
|
||||||
size="middle"
|
|
||||||
pagination={false}
|
|
||||||
rowKey={({ mappingParam }) => `param${mappingParam.name}`}>
|
|
||||||
<Table.Column title="Title" key="title" render={({ mappingParam }) => mappingParam.getTitle()} />
|
|
||||||
<Table.Column
|
|
||||||
title="Keyword"
|
|
||||||
key="keyword"
|
|
||||||
className="keyword"
|
|
||||||
render={({ mappingParam }) => <code>{`{{ ${mappingParam.name} }}`}</code>}
|
|
||||||
/>
|
|
||||||
<Table.Column
|
|
||||||
title="Value Source"
|
|
||||||
key="source"
|
|
||||||
render={({ mappingParam, existingMapping }) => (
|
|
||||||
<QueryBasedParameterMappingEditor
|
|
||||||
parameter={mappingParam.setValue(existingMapping.staticValue)}
|
|
||||||
mapping={existingMapping}
|
|
||||||
searchAvailable={
|
|
||||||
!findKey(param.parameterMapping, {
|
|
||||||
mappingType: QueryBasedParameterMappingType.DROPDOWN_SEARCH,
|
|
||||||
}) || existingMapping.mappingType === QueryBasedParameterMappingType.DROPDOWN_SEARCH
|
|
||||||
}
|
|
||||||
onChange={mapping =>
|
|
||||||
onChangeParam({
|
|
||||||
...param,
|
|
||||||
parameterMapping: { ...param.parameterMapping, [mappingParam.name]: mapping },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
QueryBasedParameterMappingTable.propTypes = {
|
|
||||||
param: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
|
||||||
mappingParameters: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
|
|
||||||
onChangeParam: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
QueryBasedParameterMappingTable.defaultProps = {
|
|
||||||
mappingParameters: [],
|
|
||||||
onChangeParam: () => {},
|
|
||||||
};
|
|
||||||
@@ -3,13 +3,7 @@ import PropTypes from "prop-types";
|
|||||||
import Dropdown from "antd/lib/dropdown";
|
import Dropdown from "antd/lib/dropdown";
|
||||||
import Menu from "antd/lib/menu";
|
import Menu from "antd/lib/menu";
|
||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import { clientConfig } from "@/services/auth";
|
import Icon from "antd/lib/icon";
|
||||||
|
|
||||||
import PlusCircleFilledIcon from "@ant-design/icons/PlusCircleFilled";
|
|
||||||
import ShareAltOutlinedIcon from "@ant-design/icons/ShareAltOutlined";
|
|
||||||
import FileOutlinedIcon from "@ant-design/icons/FileOutlined";
|
|
||||||
import FileExcelOutlinedIcon from "@ant-design/icons/FileExcelOutlined";
|
|
||||||
import EllipsisOutlinedIcon from "@ant-design/icons/EllipsisOutlined";
|
|
||||||
|
|
||||||
import QueryResultsLink from "./QueryResultsLink";
|
import QueryResultsLink from "./QueryResultsLink";
|
||||||
|
|
||||||
@@ -19,14 +13,14 @@ export default function QueryControlDropdown(props) {
|
|||||||
{!props.query.isNew() && (!props.query.is_draft || !props.query.is_archived) && (
|
{!props.query.isNew() && (!props.query.is_draft || !props.query.is_archived) && (
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
<a target="_self" onClick={() => props.openAddToDashboardForm(props.selectedTab)}>
|
<a target="_self" onClick={() => props.openAddToDashboardForm(props.selectedTab)}>
|
||||||
<PlusCircleFilledIcon /> Add to Dashboard
|
<Icon type="plus-circle" theme="filled" /> Add to Dashboard
|
||||||
</a>
|
</a>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
{!clientConfig.disablePublicUrls && !props.query.isNew() && (
|
{!props.query.isNew() && (
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
<a onClick={() => props.showEmbedDialog(props.query, props.selectedTab)} data-test="ShowEmbedDialogButton">
|
<a onClick={() => props.showEmbedDialog(props.query, props.selectedTab)} data-test="ShowEmbedDialogButton">
|
||||||
<ShareAltOutlinedIcon /> Embed Elsewhere
|
<Icon type="share-alt" /> Embed Elsewhere
|
||||||
</a>
|
</a>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
@@ -38,7 +32,7 @@ export default function QueryControlDropdown(props) {
|
|||||||
queryResult={props.queryResult}
|
queryResult={props.queryResult}
|
||||||
embed={props.embed}
|
embed={props.embed}
|
||||||
apiKey={props.apiKey}>
|
apiKey={props.apiKey}>
|
||||||
<FileOutlinedIcon /> Download as CSV File
|
<Icon type="file" /> Download as CSV File
|
||||||
</QueryResultsLink>
|
</QueryResultsLink>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
@@ -49,7 +43,7 @@ export default function QueryControlDropdown(props) {
|
|||||||
queryResult={props.queryResult}
|
queryResult={props.queryResult}
|
||||||
embed={props.embed}
|
embed={props.embed}
|
||||||
apiKey={props.apiKey}>
|
apiKey={props.apiKey}>
|
||||||
<FileOutlinedIcon /> Download as TSV File
|
<Icon type="file" /> Download as TSV File
|
||||||
</QueryResultsLink>
|
</QueryResultsLink>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
@@ -60,7 +54,7 @@ export default function QueryControlDropdown(props) {
|
|||||||
queryResult={props.queryResult}
|
queryResult={props.queryResult}
|
||||||
embed={props.embed}
|
embed={props.embed}
|
||||||
apiKey={props.apiKey}>
|
apiKey={props.apiKey}>
|
||||||
<FileExcelOutlinedIcon /> Download as Excel File
|
<Icon type="file-excel" /> Download as Excel File
|
||||||
</QueryResultsLink>
|
</QueryResultsLink>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu>
|
</Menu>
|
||||||
@@ -69,7 +63,7 @@ export default function QueryControlDropdown(props) {
|
|||||||
return (
|
return (
|
||||||
<Dropdown trigger={["click"]} overlay={menu} overlayClassName="query-control-dropdown-overlay">
|
<Dropdown trigger={["click"]} overlay={menu} overlayClassName="query-control-dropdown-overlay">
|
||||||
<Button data-test="QueryControlDropdownButton">
|
<Button data-test="QueryControlDropdownButton">
|
||||||
<EllipsisOutlinedIcon rotate={90} />
|
<Icon type="ellipsis" rotate={90} />
|
||||||
</Button>
|
</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import FormOutlinedIcon from "@ant-design/icons/FormOutlined";
|
import Icon from "antd/lib/icon";
|
||||||
|
|
||||||
export default function EditVisualizationButton(props) {
|
export default function EditVisualizationButton(props) {
|
||||||
return (
|
return (
|
||||||
@@ -9,7 +9,7 @@ export default function EditVisualizationButton(props) {
|
|||||||
data-test="EditVisualization"
|
data-test="EditVisualization"
|
||||||
className="edit-visualization"
|
className="edit-visualization"
|
||||||
onClick={() => props.openVisualizationEditor(props.selectedTab)}>
|
onClick={() => props.openVisualizationEditor(props.selectedTab)}>
|
||||||
<FormOutlinedIcon />
|
<Icon type="form" />
|
||||||
<span className="hidden-xs hidden-s hidden-m">Edit Visualization</span>
|
<span className="hidden-xs hidden-s hidden-m">Edit Visualization</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { startsWith, get, some, mapValues } from "lodash";
|
import { startsWith, get } from "lodash";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import Tooltip from "antd/lib/tooltip";
|
import Tooltip from "antd/lib/tooltip";
|
||||||
import Drawer from "antd/lib/drawer";
|
import Drawer from "antd/lib/drawer";
|
||||||
import Link from "@/components/Link";
|
import Link from "@/components/Link";
|
||||||
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
|
import Icon from "antd/lib/icon";
|
||||||
import BigMessage from "@/components/BigMessage";
|
import BigMessage from "@/components/BigMessage";
|
||||||
import DynamicComponent, { registerComponent } from "@/components/DynamicComponent";
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
|
|
||||||
import "./HelpTrigger.less";
|
import "./HelpTrigger.less";
|
||||||
|
|
||||||
@@ -16,242 +16,204 @@ const HELP_PATH = "/help";
|
|||||||
const IFRAME_TIMEOUT = 20000;
|
const IFRAME_TIMEOUT = 20000;
|
||||||
const IFRAME_URL_UPDATE_MESSAGE = "iframe_url";
|
const IFRAME_URL_UPDATE_MESSAGE = "iframe_url";
|
||||||
|
|
||||||
export const TYPES = mapValues(
|
export const TYPES = {
|
||||||
{
|
HOME: ["", "Help"],
|
||||||
HOME: ["", "Help"],
|
VALUE_SOURCE_OPTIONS: ["/user-guide/querying/query-parameters#Value-Source-Options", "Guide: Value Source Options"],
|
||||||
VALUE_SOURCE_OPTIONS: ["/user-guide/querying/query-parameters#Value-Source-Options", "Guide: Value Source Options"],
|
SHARE_DASHBOARD: ["/user-guide/dashboards/sharing-dashboards", "Guide: Sharing and Embedding Dashboards"],
|
||||||
SHARE_DASHBOARD: ["/user-guide/dashboards/sharing-dashboards", "Guide: Sharing and Embedding Dashboards"],
|
AUTHENTICATION_OPTIONS: ["/user-guide/users/authentication-options", "Guide: Authentication Options"],
|
||||||
AUTHENTICATION_OPTIONS: ["/user-guide/users/authentication-options", "Guide: Authentication Options"],
|
USAGE_DATA_SHARING: ["/open-source/admin-guide/usage-data", "Help: Anonymous Usage Data Sharing"],
|
||||||
USAGE_DATA_SHARING: ["/open-source/admin-guide/usage-data", "Help: Anonymous Usage Data Sharing"],
|
DS_ATHENA: ["/data-sources/amazon-athena-setup", "Guide: Help Setting up Amazon Athena"],
|
||||||
DS_ATHENA: ["/data-sources/amazon-athena-setup", "Guide: Help Setting up Amazon Athena"],
|
DS_BIGQUERY: ["/data-sources/bigquery-setup", "Guide: Help Setting up BigQuery"],
|
||||||
DS_BIGQUERY: ["/data-sources/bigquery-setup", "Guide: Help Setting up BigQuery"],
|
DS_URL: ["/data-sources/querying-urls", "Guide: Help Setting up URL"],
|
||||||
DS_URL: ["/data-sources/querying-urls", "Guide: Help Setting up URL"],
|
DS_MONGODB: ["/data-sources/mongodb-setup", "Guide: Help Setting up MongoDB"],
|
||||||
DS_MONGODB: ["/data-sources/mongodb-setup", "Guide: Help Setting up MongoDB"],
|
DS_GOOGLE_SPREADSHEETS: ["/data-sources/querying-a-google-spreadsheet", "Guide: Help Setting up Google Spreadsheets"],
|
||||||
DS_GOOGLE_SPREADSHEETS: [
|
DS_GOOGLE_ANALYTICS: ["/data-sources/google-analytics-setup", "Guide: Help Setting up Google Analytics"],
|
||||||
"/data-sources/querying-a-google-spreadsheet",
|
DS_AXIBASETSD: ["/data-sources/axibase-time-series-database", "Guide: Help Setting up Axibase Time Series"],
|
||||||
"Guide: Help Setting up Google Spreadsheets",
|
DS_RESULTS: ["/user-guide/querying/query-results-data-source", "Guide: Help Setting up Query Results"],
|
||||||
],
|
ALERT_SETUP: ["/user-guide/alerts/setting-up-an-alert", "Guide: Setting Up a New Alert"],
|
||||||
DS_GOOGLE_ANALYTICS: ["/data-sources/google-analytics-setup", "Guide: Help Setting up Google Analytics"],
|
MAIL_CONFIG: ["/open-source/setup/#Mail-Configuration", "Guide: Mail Configuration"],
|
||||||
DS_AXIBASETSD: ["/data-sources/axibase-time-series-database", "Guide: Help Setting up Axibase Time Series"],
|
ALERT_NOTIF_TEMPLATE_GUIDE: ["/user-guide/alerts/custom-alert-notifications", "Guide: Custom Alerts Notifications"],
|
||||||
DS_RESULTS: ["/user-guide/querying/query-results-data-source", "Guide: Help Setting up Query Results"],
|
FAVORITES: ["/user-guide/querying/favorites-tagging/#Favorites", "Guide: Favorites"],
|
||||||
ALERT_SETUP: ["/user-guide/alerts/setting-up-an-alert", "Guide: Setting Up a New Alert"],
|
MANAGE_PERMISSIONS: [
|
||||||
MAIL_CONFIG: ["/open-source/setup/#Mail-Configuration", "Guide: Mail Configuration"],
|
"/user-guide/querying/writing-queries#Managing-Query-Permissions",
|
||||||
ALERT_NOTIF_TEMPLATE_GUIDE: ["/user-guide/alerts/custom-alert-notifications", "Guide: Custom Alerts Notifications"],
|
"Guide: Managing Query Permissions",
|
||||||
FAVORITES: ["/user-guide/querying/favorites-tagging/#Favorites", "Guide: Favorites"],
|
],
|
||||||
MANAGE_PERMISSIONS: [
|
NUMBER_FORMAT_SPECS: ["/user-guide/visualizations/formatting-numbers", "Formatting Numbers"],
|
||||||
"/user-guide/querying/writing-queries#Managing-Query-Permissions",
|
|
||||||
"Guide: Managing Query Permissions",
|
|
||||||
],
|
|
||||||
NUMBER_FORMAT_SPECS: ["/user-guide/visualizations/formatting-numbers", "Formatting Numbers"],
|
|
||||||
GETTING_STARTED: ["/user-guide/getting-started", "Guide: Getting Started"],
|
|
||||||
DASHBOARDS: ["/user-guide/dashboards", "Guide: Dashboards"],
|
|
||||||
QUERIES: ["/help/user-guide/querying", "Guide: Queries"],
|
|
||||||
ALERTS: ["/user-guide/alerts", "Guide: Alerts"],
|
|
||||||
},
|
|
||||||
([url, title]) => [DOMAIN + HELP_PATH + url, title]
|
|
||||||
);
|
|
||||||
|
|
||||||
const HelpTriggerPropTypes = {
|
|
||||||
type: PropTypes.string,
|
|
||||||
href: PropTypes.string,
|
|
||||||
title: PropTypes.node,
|
|
||||||
className: PropTypes.string,
|
|
||||||
showTooltip: PropTypes.bool,
|
|
||||||
renderAsLink: PropTypes.bool,
|
|
||||||
children: PropTypes.node,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const HelpTriggerDefaultProps = {
|
export default class HelpTrigger extends React.Component {
|
||||||
type: null,
|
static propTypes = {
|
||||||
href: null,
|
type: PropTypes.oneOf(Object.keys(TYPES)),
|
||||||
title: null,
|
href: PropTypes.string,
|
||||||
className: null,
|
title: PropTypes.node,
|
||||||
showTooltip: true,
|
className: PropTypes.string,
|
||||||
renderAsLink: false,
|
showTooltip: PropTypes.bool,
|
||||||
children: <i className="fa fa-question-circle" />,
|
children: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName = null) {
|
static defaultProps = {
|
||||||
return class HelpTrigger extends React.Component {
|
type: null,
|
||||||
static propTypes = {
|
href: null,
|
||||||
...HelpTriggerPropTypes,
|
title: null,
|
||||||
type: PropTypes.oneOf(Object.keys(types)),
|
className: null,
|
||||||
};
|
showTooltip: true,
|
||||||
|
children: <i className="fa fa-question-circle" />,
|
||||||
|
};
|
||||||
|
|
||||||
static defaultProps = HelpTriggerDefaultProps;
|
iframeRef = React.createRef();
|
||||||
|
|
||||||
iframeRef = React.createRef();
|
iframeLoadingTimeout = null;
|
||||||
|
|
||||||
iframeLoadingTimeout = null;
|
state = {
|
||||||
|
visible: false,
|
||||||
|
loading: false,
|
||||||
|
error: false,
|
||||||
|
currentUrl: null,
|
||||||
|
};
|
||||||
|
|
||||||
state = {
|
componentDidMount() {
|
||||||
visible: false,
|
window.addEventListener("message", this.onPostMessageReceived, false);
|
||||||
loading: false,
|
}
|
||||||
error: false,
|
|
||||||
currentUrl: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount() {
|
componentWillUnmount() {
|
||||||
window.addEventListener("message", this.onPostMessageReceived, false);
|
window.removeEventListener("message", this.onPostMessageReceived);
|
||||||
|
clearTimeout(this.iframeLoadingTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadIframe = url => {
|
||||||
|
clearTimeout(this.iframeLoadingTimeout);
|
||||||
|
this.setState({ loading: true, error: false });
|
||||||
|
|
||||||
|
this.iframeRef.current.src = url;
|
||||||
|
this.iframeLoadingTimeout = setTimeout(() => {
|
||||||
|
this.setState({ error: url, loading: false });
|
||||||
|
}, IFRAME_TIMEOUT); // safety
|
||||||
|
};
|
||||||
|
|
||||||
|
onIframeLoaded = () => {
|
||||||
|
this.setState({ loading: false });
|
||||||
|
clearTimeout(this.iframeLoadingTimeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
onPostMessageReceived = event => {
|
||||||
|
if (!startsWith(event.origin, DOMAIN)) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
const { type, message: currentUrl } = event.data || {};
|
||||||
window.removeEventListener("message", this.onPostMessageReceived);
|
if (type !== IFRAME_URL_UPDATE_MESSAGE) {
|
||||||
clearTimeout(this.iframeLoadingTimeout);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadIframe = url => {
|
this.setState({ currentUrl });
|
||||||
clearTimeout(this.iframeLoadingTimeout);
|
};
|
||||||
this.setState({ loading: true, error: false });
|
|
||||||
|
|
||||||
this.iframeRef.current.src = url;
|
getUrl = () => {
|
||||||
this.iframeLoadingTimeout = setTimeout(() => {
|
const helpTriggerType = get(TYPES, this.props.type);
|
||||||
this.setState({ error: url, loading: false });
|
return helpTriggerType ? DOMAIN + HELP_PATH + helpTriggerType[0] : this.props.href;
|
||||||
}, IFRAME_TIMEOUT); // safety
|
};
|
||||||
};
|
|
||||||
|
|
||||||
onIframeLoaded = () => {
|
openDrawer = () => {
|
||||||
this.setState({ loading: false });
|
this.setState({ visible: true });
|
||||||
clearTimeout(this.iframeLoadingTimeout);
|
// wait for drawer animation to complete so there's no animation jank
|
||||||
};
|
setTimeout(() => this.loadIframe(this.getUrl()), 300);
|
||||||
|
};
|
||||||
|
|
||||||
onPostMessageReceived = event => {
|
closeDrawer = event => {
|
||||||
if (!some(allowedDomains, domain => startsWith(event.origin, domain))) {
|
if (event) {
|
||||||
return;
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
|
this.setState({ visible: false });
|
||||||
|
this.setState({ visible: false, currentUrl: null });
|
||||||
|
};
|
||||||
|
|
||||||
const { type, message: currentUrl } = event.data || {};
|
render() {
|
||||||
if (type !== IFRAME_URL_UPDATE_MESSAGE) {
|
const tooltip = get(TYPES, `${this.props.type}[1]`, this.props.title);
|
||||||
return;
|
const className = cx("help-trigger", this.props.className);
|
||||||
}
|
const url = this.state.currentUrl;
|
||||||
|
|
||||||
this.setState({ currentUrl });
|
const isAllowedDomain = startsWith(url || this.getUrl(), DOMAIN);
|
||||||
};
|
|
||||||
|
|
||||||
getUrl = () => {
|
return (
|
||||||
const helpTriggerType = get(types, this.props.type);
|
<React.Fragment>
|
||||||
return helpTriggerType ? helpTriggerType[0] : this.props.href;
|
<Tooltip
|
||||||
};
|
title={
|
||||||
|
this.props.showTooltip ? (
|
||||||
openDrawer = e => {
|
<>
|
||||||
// keep "open in new tab" behavior
|
{tooltip}
|
||||||
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
{!isAllowedDomain && <i className="fa fa-external-link" style={{ marginLeft: 5 }} />}
|
||||||
e.preventDefault();
|
</>
|
||||||
this.setState({ visible: true });
|
) : null
|
||||||
// wait for drawer animation to complete so there's no animation jank
|
}>
|
||||||
setTimeout(() => this.loadIframe(this.getUrl()), 300);
|
{isAllowedDomain ? (
|
||||||
}
|
<a onClick={this.openDrawer} className={className}>
|
||||||
};
|
{this.props.children}
|
||||||
|
</a>
|
||||||
closeDrawer = event => {
|
) : (
|
||||||
if (event) {
|
<Link href={url || this.getUrl()} className={className} rel="noopener noreferrer" target="_blank">
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
this.setState({ visible: false });
|
|
||||||
this.setState({ visible: false, currentUrl: null });
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const targetUrl = this.getUrl();
|
|
||||||
if (!targetUrl) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tooltip = get(types, `${this.props.type}[1]`, this.props.title);
|
|
||||||
const className = cx("help-trigger", this.props.className);
|
|
||||||
const url = this.state.currentUrl;
|
|
||||||
const isAllowedDomain = some(allowedDomains, domain => startsWith(url || targetUrl, domain));
|
|
||||||
const shouldRenderAsLink = this.props.renderAsLink || !isAllowedDomain;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
<Tooltip
|
|
||||||
title={
|
|
||||||
this.props.showTooltip ? (
|
|
||||||
<>
|
|
||||||
{tooltip}
|
|
||||||
{shouldRenderAsLink && <i className="fa fa-external-link" style={{ marginLeft: 5 }} />}
|
|
||||||
</>
|
|
||||||
) : null
|
|
||||||
}>
|
|
||||||
<Link
|
|
||||||
href={url || this.getUrl()}
|
|
||||||
className={className}
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
onClick={shouldRenderAsLink ? () => {} : this.openDrawer}>
|
|
||||||
{this.props.children}
|
{this.props.children}
|
||||||
</Link>
|
</Link>
|
||||||
</Tooltip>
|
)}
|
||||||
<Drawer
|
</Tooltip>
|
||||||
placement="right"
|
<Drawer
|
||||||
closable={false}
|
placement="right"
|
||||||
onClose={this.closeDrawer}
|
closable={false}
|
||||||
visible={this.state.visible}
|
onClose={this.closeDrawer}
|
||||||
className={cx("help-drawer", drawerClassName)}
|
visible={this.state.visible}
|
||||||
destroyOnClose
|
className="help-drawer"
|
||||||
width={400}>
|
destroyOnClose
|
||||||
<div className="drawer-wrapper">
|
width={400}>
|
||||||
<div className="drawer-menu">
|
<div className="drawer-wrapper">
|
||||||
{url && (
|
<div className="drawer-menu">
|
||||||
<Tooltip title="Open page in a new window" placement="left">
|
{url && (
|
||||||
{/* eslint-disable-next-line react/jsx-no-target-blank */}
|
<Tooltip title="Open page in a new window" placement="left">
|
||||||
<Link href={url} target="_blank">
|
|
||||||
<i className="fa fa-external-link" />
|
|
||||||
</Link>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Tooltip title="Close" placement="bottom">
|
|
||||||
<a onClick={this.closeDrawer}>
|
|
||||||
<CloseOutlinedIcon />
|
|
||||||
</a>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* iframe */}
|
|
||||||
{!this.state.error && (
|
|
||||||
<iframe
|
|
||||||
ref={this.iframeRef}
|
|
||||||
title="Usage Help"
|
|
||||||
src="about:blank"
|
|
||||||
className={cx({ ready: !this.state.loading })}
|
|
||||||
onLoad={this.onIframeLoaded}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* loading indicator */}
|
|
||||||
{this.state.loading && (
|
|
||||||
<BigMessage icon="fa-spinner fa-2x fa-pulse" message="Loading..." className="help-message" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* error message */}
|
|
||||||
{this.state.error && (
|
|
||||||
<BigMessage icon="fa-exclamation-circle" className="help-message">
|
|
||||||
Something went wrong.
|
|
||||||
<br />
|
|
||||||
{/* eslint-disable-next-line react/jsx-no-target-blank */}
|
{/* eslint-disable-next-line react/jsx-no-target-blank */}
|
||||||
<Link href={this.state.error} target="_blank" rel="noopener">
|
<Link href={url} target="_blank">
|
||||||
Click here
|
<i className="fa fa-external-link" />
|
||||||
</Link>{" "}
|
</Link>
|
||||||
to open the page in a new window.
|
</Tooltip>
|
||||||
</BigMessage>
|
|
||||||
)}
|
)}
|
||||||
|
<Tooltip title="Close" placement="bottom">
|
||||||
|
<a onClick={this.closeDrawer}>
|
||||||
|
<Icon type="close" />
|
||||||
|
</a>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* extra content */}
|
{/* iframe */}
|
||||||
<DynamicComponent name="HelpDrawerExtraContent" onLeave={this.closeDrawer} openPageUrl={this.loadIframe} />
|
{!this.state.error && (
|
||||||
</Drawer>
|
<iframe
|
||||||
</React.Fragment>
|
ref={this.iframeRef}
|
||||||
);
|
title="Redash Help"
|
||||||
}
|
src="about:blank"
|
||||||
};
|
className={cx({ ready: !this.state.loading })}
|
||||||
|
onLoad={this.onIframeLoaded}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* loading indicator */}
|
||||||
|
{this.state.loading && (
|
||||||
|
<BigMessage icon="fa-spinner fa-2x fa-pulse" message="Loading..." className="help-message" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* error message */}
|
||||||
|
{this.state.error && (
|
||||||
|
<BigMessage icon="fa-exclamation-circle" className="help-message">
|
||||||
|
Something went wrong.
|
||||||
|
<br />
|
||||||
|
{/* eslint-disable-next-line react/jsx-no-target-blank */}
|
||||||
|
<Link href={this.state.error} target="_blank" rel="noopener">
|
||||||
|
Click here
|
||||||
|
</Link>{" "}
|
||||||
|
to open the page in a new window.
|
||||||
|
</BigMessage>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* extra content */}
|
||||||
|
<DynamicComponent name="HelpDrawerExtraContent" onLeave={this.closeDrawer} openPageUrl={this.loadIframe} />
|
||||||
|
</Drawer>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
registerComponent("HelpTrigger", helpTriggerWithTypes(TYPES, [DOMAIN]));
|
|
||||||
|
|
||||||
export default function HelpTrigger(props) {
|
|
||||||
return <DynamicComponent {...props} name="HelpTrigger" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
HelpTrigger.propTypes = HelpTriggerPropTypes;
|
|
||||||
HelpTrigger.defaultProps = HelpTriggerDefaultProps;
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import PropTypes from "prop-types";
|
|
||||||
import Button from "antd/lib/button";
|
|
||||||
import Popover from "antd/lib/popover";
|
|
||||||
|
|
||||||
import "./index.less";
|
|
||||||
|
|
||||||
export default function InputPopover({
|
|
||||||
header,
|
|
||||||
content,
|
|
||||||
children,
|
|
||||||
okButtonProps,
|
|
||||||
cancelButtonProps,
|
|
||||||
onCancel,
|
|
||||||
onOk,
|
|
||||||
...props
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
{...props}
|
|
||||||
content={
|
|
||||||
<div className="input-popover-content" data-test="InputPopoverContent">
|
|
||||||
{header && <header>{header}</header>}
|
|
||||||
{content}
|
|
||||||
<footer>
|
|
||||||
<Button onClick={onCancel} {...cancelButtonProps}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={onOk} type="primary" {...okButtonProps}>
|
|
||||||
OK
|
|
||||||
</Button>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
{children}
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
InputPopover.propTypes = {
|
|
||||||
header: PropTypes.node,
|
|
||||||
content: PropTypes.node,
|
|
||||||
children: PropTypes.node,
|
|
||||||
okButtonProps: PropTypes.object,
|
|
||||||
cancelButtonProps: PropTypes.object,
|
|
||||||
onOk: PropTypes.func,
|
|
||||||
onCancel: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
InputPopover.defaultProps = {
|
|
||||||
header: null,
|
|
||||||
children: null,
|
|
||||||
okButtonProps: null,
|
|
||||||
cancelButtonProps: null,
|
|
||||||
onOk: () => {},
|
|
||||||
onCancel: () => {},
|
|
||||||
};
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
@import "~antd/lib/modal/style/index"; // for ant @vars
|
|
||||||
|
|
||||||
.input-popover-content {
|
|
||||||
width: 390px;
|
|
||||||
|
|
||||||
.radio {
|
|
||||||
display: block;
|
|
||||||
height: 30px;
|
|
||||||
line-height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-item {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
padding: 0 16px 10px;
|
|
||||||
margin: 0 -16px 20px;
|
|
||||||
border-bottom: @border-width-base @border-style-base @border-color-split;
|
|
||||||
font-size: @font-size-lg;
|
|
||||||
font-weight: 500;
|
|
||||||
color: @heading-color;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
border-top: @border-width-base @border-style-base @border-color-split;
|
|
||||||
padding: 10px 16px 0;
|
|
||||||
margin: 0 -16px;
|
|
||||||
text-align: right;
|
|
||||||
|
|
||||||
button {
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Input from "antd/lib/input";
|
import Input from "antd/lib/input";
|
||||||
import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined";
|
import Icon from "antd/lib/icon";
|
||||||
import Tooltip from "antd/lib/tooltip";
|
import Tooltip from "antd/lib/tooltip";
|
||||||
|
|
||||||
export default class InputWithCopy extends React.Component {
|
export default class InputWithCopy extends React.Component {
|
||||||
@@ -42,7 +42,7 @@ export default class InputWithCopy extends React.Component {
|
|||||||
render() {
|
render() {
|
||||||
const copyButton = (
|
const copyButton = (
|
||||||
<Tooltip title={this.state.copied || "Copy"}>
|
<Tooltip title={this.state.copied || "Copy"}>
|
||||||
<CopyOutlinedIcon style={{ cursor: "pointer" }} onClick={this.copy} />
|
<Icon type="copy" style={{ cursor: "pointer" }} onClick={this.copy} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function Link(props) {
|
|||||||
Link.Component = DefaultLinkComponent;
|
Link.Component = DefaultLinkComponent;
|
||||||
|
|
||||||
function DefaultButtonLinkComponent(props) {
|
function DefaultButtonLinkComponent(props) {
|
||||||
return <Button role="button" {...props} />;
|
return <Button {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ButtonLink(props) {
|
function ButtonLink(props) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Select from "antd/lib/select";
|
|||||||
import Table from "antd/lib/table";
|
import Table from "antd/lib/table";
|
||||||
import Popover from "antd/lib/popover";
|
import Popover from "antd/lib/popover";
|
||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
|
import Icon from "antd/lib/icon";
|
||||||
import Tag from "antd/lib/tag";
|
import Tag from "antd/lib/tag";
|
||||||
import Input from "antd/lib/input";
|
import Input from "antd/lib/input";
|
||||||
import Radio from "antd/lib/radio";
|
import Radio from "antd/lib/radio";
|
||||||
@@ -17,15 +18,11 @@ import ParameterValueInput from "@/components/ParameterValueInput";
|
|||||||
import { ParameterMappingType } from "@/services/widget";
|
import { ParameterMappingType } from "@/services/widget";
|
||||||
import { Parameter, cloneParameter } from "@/services/parameters";
|
import { Parameter, cloneParameter } from "@/services/parameters";
|
||||||
import HelpTrigger from "@/components/HelpTrigger";
|
import HelpTrigger from "@/components/HelpTrigger";
|
||||||
import InputPopover from "@/components/InputPopover";
|
|
||||||
|
|
||||||
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
|
|
||||||
import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
|
|
||||||
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
|
|
||||||
import CheckOutlinedIcon from "@ant-design/icons/CheckOutlined";
|
|
||||||
|
|
||||||
import "./ParameterMappingInput.less";
|
import "./ParameterMappingInput.less";
|
||||||
|
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
export const MappingType = {
|
export const MappingType = {
|
||||||
DashboardAddNew: "dashboard-add-new",
|
DashboardAddNew: "dashboard-add-new",
|
||||||
DashboardMapToExisting: "dashboard-map-to-existing",
|
DashboardMapToExisting: "dashboard-map-to-existing",
|
||||||
@@ -184,7 +181,7 @@ export class ParameterMappingInput extends React.Component {
|
|||||||
Existing dashboard parameter{" "}
|
Existing dashboard parameter{" "}
|
||||||
{noExisting ? (
|
{noExisting ? (
|
||||||
<Tooltip title="There are no dashboard parameters corresponding to this data type">
|
<Tooltip title="There are no dashboard parameters corresponding to this data type">
|
||||||
<QuestionCircleFilledIcon />
|
<Icon type="question-circle" theme="filled" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
</Radio>
|
</Radio>
|
||||||
@@ -207,9 +204,19 @@ export class ParameterMappingInput extends React.Component {
|
|||||||
|
|
||||||
renderDashboardMapToExisting() {
|
renderDashboardMapToExisting() {
|
||||||
const { mapping, existingParamNames } = this.props;
|
const { mapping, existingParamNames } = this.props;
|
||||||
const options = map(existingParamNames, paramName => ({ label: paramName, value: paramName }));
|
|
||||||
|
|
||||||
return <Select value={mapping.mapTo} onChange={mapTo => this.updateParamMapping({ mapTo })} options={options} />;
|
return (
|
||||||
|
<Select
|
||||||
|
value={mapping.mapTo}
|
||||||
|
onChange={mapTo => this.updateParamMapping({ mapTo })}
|
||||||
|
dropdownMatchSelectWidth={false}>
|
||||||
|
{map(existingParamNames, name => (
|
||||||
|
<Option value={name} key={name}>
|
||||||
|
{name}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderStaticValue() {
|
renderStaticValue() {
|
||||||
@@ -314,34 +321,43 @@ class MappingEditor extends React.Component {
|
|||||||
this.setState({ visible: false });
|
this.setState({ visible: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
renderContent() {
|
||||||
const { visible, mapping, inputError } = this.state;
|
const { mapping, inputError } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InputPopover
|
<div className="parameter-mapping-editor" data-test="EditParamMappingPopover">
|
||||||
|
<header>
|
||||||
|
Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" />
|
||||||
|
</header>
|
||||||
|
<ParameterMappingInput
|
||||||
|
mapping={mapping}
|
||||||
|
existingParamNames={this.props.existingParamNames}
|
||||||
|
onChange={this.onChange}
|
||||||
|
inputError={inputError}
|
||||||
|
/>
|
||||||
|
<footer>
|
||||||
|
<Button onClick={this.hide}>Cancel</Button>
|
||||||
|
<Button onClick={this.save} disabled={!!inputError} type="primary">
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { visible, mapping } = this.state;
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
placement="left"
|
placement="left"
|
||||||
trigger="click"
|
trigger="click"
|
||||||
header={
|
content={this.renderContent()}
|
||||||
<>
|
|
||||||
Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
content={
|
|
||||||
<ParameterMappingInput
|
|
||||||
mapping={mapping}
|
|
||||||
existingParamNames={this.props.existingParamNames}
|
|
||||||
onChange={this.onChange}
|
|
||||||
inputError={inputError}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onOk={this.save}
|
|
||||||
onCancel={this.hide}
|
|
||||||
okButtonProps={{ disabled: !!inputError }}
|
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onVisibleChange={this.onVisibleChange}>
|
onVisibleChange={this.onVisibleChange}>
|
||||||
<Button size="small" type="dashed" data-test={`EditParamMappingButton-${mapping.param.name}`}>
|
<Button size="small" type="dashed" data-test={`EditParamMappingButon-${mapping.param.name}`}>
|
||||||
<EditOutlinedIcon />
|
<Icon type="edit" />
|
||||||
</Button>
|
</Button>
|
||||||
</InputPopover>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -418,10 +434,10 @@ class TitleEditor extends React.Component {
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Button size="small" type="dashed" onClick={this.hide}>
|
<Button size="small" type="dashed" onClick={this.hide}>
|
||||||
<CloseOutlinedIcon />
|
<Icon type="close" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="small" type="dashed" onClick={this.save}>
|
<Button size="small" type="dashed" onClick={this.save}>
|
||||||
<CheckOutlinedIcon />
|
<Icon type="check" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -444,7 +460,7 @@ class TitleEditor extends React.Component {
|
|||||||
visible={this.state.showPopup}
|
visible={this.state.showPopup}
|
||||||
onVisibleChange={this.onPopupVisibleChange}>
|
onVisibleChange={this.onPopupVisibleChange}>
|
||||||
<Button size="small" type="dashed">
|
<Button size="small" type="dashed">
|
||||||
<EditOutlinedIcon />
|
<Icon type="edit" />
|
||||||
</Button>
|
</Button>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "~antd/lib/modal/style/index"; // for ant @vars
|
@import '~antd/lib/modal/style/index'; // for ant @vars
|
||||||
|
|
||||||
.parameters-mapping-list {
|
.parameters-mapping-list {
|
||||||
.keyword {
|
.keyword {
|
||||||
@@ -22,13 +22,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.parameter-mapping-editor {
|
||||||
|
width: 390px;
|
||||||
|
|
||||||
|
.radio {
|
||||||
|
display: block;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-item {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
padding: 0 16px 10px;
|
||||||
|
margin: 0 -16px 20px;
|
||||||
|
border-bottom: @border-width-base @border-style-base @border-color-split;
|
||||||
|
font-size: @font-size-lg;
|
||||||
|
font-weight: 500;
|
||||||
|
color: @heading-color;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
border-top: @border-width-base @border-style-base @border-color-split;
|
||||||
|
padding: 10px 16px 0;
|
||||||
|
margin: 0 -16px;
|
||||||
|
text-align: right;
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.parameter-mapping-title {
|
.parameter-mapping-title {
|
||||||
.text {
|
.text {
|
||||||
margin-right: 3px;
|
margin-right: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.disabled,
|
&.disabled, .fa {
|
||||||
.fa {
|
|
||||||
color: #a4a4a4;
|
color: #a4a4a4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { isEqual, isEmpty, map } from "lodash";
|
import { isEqual, isEmpty } from "lodash";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll";
|
import Select from "antd/lib/select";
|
||||||
import Input from "antd/lib/input";
|
import Input from "antd/lib/input";
|
||||||
import InputNumber from "antd/lib/input-number";
|
import InputNumber from "antd/lib/input-number";
|
||||||
import DateParameter from "@/components/dynamic-parameters/DateParameter";
|
import DateParameter from "@/components/dynamic-parameters/DateParameter";
|
||||||
@@ -10,6 +10,8 @@ import QueryBasedParameterInput from "./QueryBasedParameterInput";
|
|||||||
|
|
||||||
import "./ParameterValueInput.less";
|
import "./ParameterValueInput.less";
|
||||||
|
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
const multipleValuesProps = {
|
const multipleValuesProps = {
|
||||||
maxTagCount: 3,
|
maxTagCount: 3,
|
||||||
maxTagTextLength: 10,
|
maxTagTextLength: 10,
|
||||||
@@ -96,20 +98,25 @@ class ParameterValueInput extends React.Component {
|
|||||||
const enumOptionsArray = enumOptions.split("\n").filter(v => v !== "");
|
const enumOptionsArray = enumOptions.split("\n").filter(v => v !== "");
|
||||||
// Antd Select doesn't handle null in multiple mode
|
// Antd Select doesn't handle null in multiple mode
|
||||||
const normalize = val => (parameter.multiValuesOptions && val === null ? [] : val);
|
const normalize = val => (parameter.multiValuesOptions && val === null ? [] : val);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectWithVirtualScroll
|
<Select
|
||||||
className={this.props.className}
|
className={this.props.className}
|
||||||
mode={parameter.multiValuesOptions ? "multiple" : "default"}
|
mode={parameter.multiValuesOptions ? "multiple" : "default"}
|
||||||
optionFilterProp="children"
|
optionFilterProp="children"
|
||||||
value={normalize(value)}
|
value={normalize(value)}
|
||||||
onChange={this.onSelect}
|
onChange={this.onSelect}
|
||||||
options={map(enumOptionsArray, opt => ({ label: String(opt), value: opt }))}
|
dropdownMatchSelectWidth={false}
|
||||||
showSearch
|
showSearch
|
||||||
showArrow
|
showArrow
|
||||||
|
style={{ minWidth: 60 }}
|
||||||
notFoundContent={isEmpty(enumOptionsArray) ? "No options available" : null}
|
notFoundContent={isEmpty(enumOptionsArray) ? "No options available" : null}
|
||||||
{...multipleValuesProps}
|
{...multipleValuesProps}>
|
||||||
/>
|
{enumOptionsArray.map(option => (
|
||||||
|
<Option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "~antd/lib/input-number/style/index"; // for ant @vars
|
@import '~antd/lib/input-number/style/index'; // for ant @vars
|
||||||
|
|
||||||
@input-dirty: #fffce1;
|
@input-dirty: #fffce1;
|
||||||
|
|
||||||
@@ -17,11 +17,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&[data-dirty] {
|
&[data-dirty] {
|
||||||
.@{ant-prefix}-input,
|
.@{ant-prefix}-input, // covers also ant date component
|
||||||
.@{ant-prefix}-input-number,
|
.@{ant-prefix}-input-number,
|
||||||
.@{ant-prefix}-select-selector,
|
.@{ant-prefix}-select-selection {
|
||||||
.@{ant-prefix}-picker {
|
background-color: @input-dirty;
|
||||||
background-color: @input-dirty !important;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Parameter, createParameter } from "@/services/parameters";
|
|||||||
import ParameterApplyButton from "@/components/ParameterApplyButton";
|
import ParameterApplyButton from "@/components/ParameterApplyButton";
|
||||||
import ParameterValueInput from "@/components/ParameterValueInput";
|
import ParameterValueInput from "@/components/ParameterValueInput";
|
||||||
import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
|
import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
|
||||||
|
import { toHuman } from "@/lib/utils";
|
||||||
|
|
||||||
import "./Parameters.less";
|
import "./Parameters.less";
|
||||||
|
|
||||||
@@ -120,7 +121,7 @@ export default class Parameters extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<div key={param.name} className="di-block" data-test={`ParameterName-${param.name}`}>
|
<div key={param.name} className="di-block" data-test={`ParameterName-${param.name}`}>
|
||||||
<div className="parameter-heading">
|
<div className="parameter-heading">
|
||||||
<label>{param.getTitle()}</label>
|
<label>{param.title || toHuman(param.name)}</label>
|
||||||
{editable && (
|
{editable && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-default btn-xs m-l-5"
|
className="btn btn-default btn-xs m-l-5"
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
import { find, isArray, get, first, map, intersection, isEqual, isEmpty, trim, debounce, isNil } from "lodash";
|
import { find, isArray, get, first, map, intersection, isEqual, isEmpty } from "lodash";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll";
|
import Select from "antd/lib/select";
|
||||||
|
|
||||||
const SEARCH_DEBOUNCE_TIME = 300;
|
const { Option } = Select;
|
||||||
|
|
||||||
function filterValuesThatAreNotInOptions(value, options) {
|
|
||||||
if (isArray(value)) {
|
|
||||||
const optionValues = map(options, option => option.value);
|
|
||||||
return intersection(value, optionValues);
|
|
||||||
}
|
|
||||||
const found = find(options, option => option.value === value) !== undefined;
|
|
||||||
return found ? value : get(first(options), "value");
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class QueryBasedParameterInput extends React.Component {
|
export default class QueryBasedParameterInput extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
@@ -39,7 +30,6 @@ export default class QueryBasedParameterInput extends React.Component {
|
|||||||
options: [],
|
options: [],
|
||||||
value: null,
|
value: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
currentSearchTerm: null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,10 +38,9 @@ export default class QueryBasedParameterInput extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
if (this.props.queryId !== prevProps.queryId || this.props.parameter !== prevProps.parameter) {
|
if (this.props.queryId !== prevProps.queryId) {
|
||||||
this._loadOptions(this.props.queryId);
|
this._loadOptions(this.props.queryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.props.value !== prevProps.value) {
|
if (this.props.value !== prevProps.value) {
|
||||||
this.setValue(this.props.value);
|
this.setValue(this.props.value);
|
||||||
}
|
}
|
||||||
@@ -59,86 +48,60 @@ export default class QueryBasedParameterInput extends React.Component {
|
|||||||
|
|
||||||
setValue(value) {
|
setValue(value) {
|
||||||
const { options } = this.state;
|
const { options } = this.state;
|
||||||
const { mode, parameter } = this.props;
|
if (this.props.mode === "multiple") {
|
||||||
|
|
||||||
if (mode === "multiple") {
|
|
||||||
if (isNil(value)) {
|
|
||||||
value = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
value = isArray(value) ? value : [value];
|
value = isArray(value) ? value : [value];
|
||||||
|
const optionValues = map(options, option => option.value);
|
||||||
|
const validValues = intersection(value, optionValues);
|
||||||
|
this.setState({ value: validValues });
|
||||||
|
return validValues;
|
||||||
}
|
}
|
||||||
|
const found = find(options, option => option.value === this.props.value) !== undefined;
|
||||||
// parameters with search don't have options available, so we trust what we get
|
value = found ? value : get(first(options), "value");
|
||||||
if (!parameter.searchFunction) {
|
|
||||||
value = filterValuesThatAreNotInOptions(value, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({ value });
|
this.setState({ value });
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateOptions(options) {
|
|
||||||
this.setState({ options, loading: false }, () => {
|
|
||||||
const updatedValue = this.setValue(this.props.value);
|
|
||||||
if (!isEqual(updatedValue, this.props.value)) {
|
|
||||||
this.props.onSelect(updatedValue);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async _loadOptions(queryId) {
|
async _loadOptions(queryId) {
|
||||||
if (queryId && queryId !== this.state.queryId) {
|
if (queryId && queryId !== this.state.queryId) {
|
||||||
this.setState({ loading: true });
|
this.setState({ loading: true });
|
||||||
const options = await this.props.parameter.loadDropdownValues(this.state.currentSearchTerm);
|
const options = await this.props.parameter.loadDropdownValues();
|
||||||
|
|
||||||
// stale queryId check
|
// stale queryId check
|
||||||
if (this.props.queryId === queryId) {
|
if (this.props.queryId === queryId) {
|
||||||
this.updateOptions(options);
|
this.setState({ options, loading: false }, () => {
|
||||||
|
const updatedValue = this.setValue(this.props.value);
|
||||||
|
if (!isEqual(updatedValue, this.props.value)) {
|
||||||
|
this.props.onSelect(updatedValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
searchFunction = debounce(searchTerm => {
|
|
||||||
const { parameter } = this.props;
|
|
||||||
if (parameter.searchFunction && trim(searchTerm)) {
|
|
||||||
this.setState({ loading: true, currentSearchTerm: searchTerm });
|
|
||||||
parameter.searchFunction(searchTerm).then(options => {
|
|
||||||
if (this.state.currentSearchTerm === searchTerm) {
|
|
||||||
this.updateOptions(options);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, SEARCH_DEBOUNCE_TIME);
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { parameter, className, mode, onSelect, queryId, value, ...otherProps } = this.props;
|
const { className, value, mode, onSelect, ...otherProps } = this.props;
|
||||||
const { loading, options } = this.state;
|
const { loading, options } = this.state;
|
||||||
const selectProps = { ...otherProps };
|
|
||||||
|
|
||||||
if (parameter.searchColumn) {
|
|
||||||
selectProps.filterOption = false;
|
|
||||||
selectProps.onSearch = this.searchFunction;
|
|
||||||
selectProps.onChange = value => onSelect(parameter.normalizeValue(value));
|
|
||||||
selectProps.notFoundContent = null;
|
|
||||||
selectProps.labelInValue = true;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
<SelectWithVirtualScroll
|
<Select
|
||||||
className={className}
|
className={className}
|
||||||
disabled={!parameter.searchFunction && loading}
|
disabled={loading}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
value={this.state.value || undefined}
|
value={this.state.value}
|
||||||
onChange={onSelect}
|
onChange={onSelect}
|
||||||
options={options}
|
dropdownMatchSelectWidth={false}
|
||||||
optionFilterProp="children"
|
optionFilterProp="children"
|
||||||
showSearch
|
showSearch
|
||||||
showArrow
|
showArrow
|
||||||
notFoundContent={isEmpty(options) ? "No options available" : null}
|
notFoundContent={isEmpty(options) ? "No options available" : null}
|
||||||
{...selectProps}
|
{...otherProps}>
|
||||||
/>
|
{options.map(option => (
|
||||||
|
<Option value={option.value} key={option.value}>
|
||||||
|
{option.name}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import React, { useMemo } from "react";
|
|
||||||
import { maxBy } from "lodash";
|
|
||||||
import AntdSelect, { SelectProps, LabeledValue } from "antd/lib/select";
|
|
||||||
import { calculateTextWidth } from "@/lib/calculateTextWidth";
|
|
||||||
|
|
||||||
const MIN_LEN_FOR_VIRTUAL_SCROLL = 400;
|
|
||||||
|
|
||||||
interface VirtualScrollLabeledValue extends LabeledValue {
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VirtualScrollSelectProps extends SelectProps<string> {
|
|
||||||
options: Array<VirtualScrollLabeledValue>;
|
|
||||||
}
|
|
||||||
function SelectWithVirtualScroll({ options, ...props }: VirtualScrollSelectProps): JSX.Element {
|
|
||||||
const dropdownMatchSelectWidth = useMemo<number | boolean>(() => {
|
|
||||||
if (options && options.length > MIN_LEN_FOR_VIRTUAL_SCROLL) {
|
|
||||||
const largestOpt = maxBy(options, "label.length");
|
|
||||||
|
|
||||||
if (largestOpt) {
|
|
||||||
const offset = 40;
|
|
||||||
const optionText = largestOpt.label;
|
|
||||||
const width = calculateTextWidth(optionText);
|
|
||||||
if (width) {
|
|
||||||
return width + offset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}, [options]);
|
|
||||||
|
|
||||||
return <AntdSelect<string> dropdownMatchSelectWidth={dropdownMatchSelectWidth} options={options} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SelectWithVirtualScroll;
|
|
||||||
82
client/app/components/TagsList.jsx
Normal file
82
client/app/components/TagsList.jsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { map } from "lodash";
|
||||||
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import Badge from "antd/lib/badge";
|
||||||
|
import Menu from "antd/lib/menu";
|
||||||
|
import getTags from "@/services/getTags";
|
||||||
|
|
||||||
|
import "./TagsList.less";
|
||||||
|
|
||||||
|
export default class TagsList extends React.Component {
|
||||||
|
static propTypes = {
|
||||||
|
tagsUrl: PropTypes.string.isRequired,
|
||||||
|
onUpdate: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
onUpdate: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
// An array of objects that with the name and count of the tagged items
|
||||||
|
allTags: [],
|
||||||
|
// A set of tag names
|
||||||
|
selectedTags: new Set(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
getTags(this.props.tagsUrl).then(allTags => {
|
||||||
|
this.setState({ allTags });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleTag(event, tag) {
|
||||||
|
const { selectedTags } = this.state;
|
||||||
|
if (event.shiftKey) {
|
||||||
|
// toggle tag
|
||||||
|
if (selectedTags.has(tag)) {
|
||||||
|
selectedTags.delete(tag);
|
||||||
|
} else {
|
||||||
|
selectedTags.add(tag);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// if the tag is the only selected, deselect it, otherwise select only it
|
||||||
|
if (selectedTags.has(tag) && selectedTags.size === 1) {
|
||||||
|
selectedTags.clear();
|
||||||
|
} else {
|
||||||
|
selectedTags.clear();
|
||||||
|
selectedTags.add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.forceUpdate();
|
||||||
|
|
||||||
|
this.props.onUpdate([...this.state.selectedTags]);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { allTags, selectedTags } = this.state;
|
||||||
|
if (allTags.length > 0) {
|
||||||
|
return (
|
||||||
|
<div className="m-t-10 tags-list tiled">
|
||||||
|
<Menu className="invert-stripe-position" mode="inline" selectedKeys={[...selectedTags]}>
|
||||||
|
{map(allTags, tag => (
|
||||||
|
<Menu.Item key={tag.name} className="m-0">
|
||||||
|
<a
|
||||||
|
className="d-flex align-items-center justify-content-between"
|
||||||
|
onClick={event => this.toggleTag(event, tag.name)}>
|
||||||
|
<span className="max-character col-xs-11">{tag.name}</span>
|
||||||
|
<Badge count={tag.count} />
|
||||||
|
</a>
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,47 +1,15 @@
|
|||||||
@import "~@/assets/less/ant";
|
@import '~@/assets/less/ant';
|
||||||
|
|
||||||
.tags-list {
|
.tags-list {
|
||||||
.tags-list-title {
|
|
||||||
margin: 15px 5px 5px 5px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: block;
|
|
||||||
white-space: nowrap;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
display: block;
|
|
||||||
white-space: nowrap;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
.anticon {
|
|
||||||
font-size: 75%;
|
|
||||||
margin-right: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-badge-count {
|
.ant-badge-count {
|
||||||
background-color: fade(@redash-gray, 10%);
|
background-color: fade(@redash-gray, 10%);
|
||||||
color: fade(@redash-gray, 75%);
|
color: fade(@redash-gray, 75%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-menu.ant-menu-inline {
|
.ant-menu-item-selected {
|
||||||
border: none;
|
.ant-badge-count {
|
||||||
|
background-color: @primary-color;
|
||||||
.ant-menu-item {
|
color: white;
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-menu-item-selected {
|
|
||||||
.ant-badge-count {
|
|
||||||
background-color: @primary-color;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import { map, includes, difference } from "lodash";
|
|
||||||
import React, { useState, useCallback, useEffect } from "react";
|
|
||||||
import Badge from "antd/lib/badge";
|
|
||||||
import Menu from "antd/lib/menu";
|
|
||||||
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
|
|
||||||
import getTags from "@/services/getTags";
|
|
||||||
|
|
||||||
import "./TagsList.less";
|
|
||||||
|
|
||||||
type Tag = {
|
|
||||||
name: string;
|
|
||||||
count?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TagsListProps = {
|
|
||||||
tagsUrl: string;
|
|
||||||
showUnselectAll: boolean;
|
|
||||||
onUpdate?: (selectedTags: string[]) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function TagsList({ tagsUrl, showUnselectAll = false, onUpdate }: TagsListProps): JSX.Element | null {
|
|
||||||
const [allTags, setAllTags] = useState<Tag[]>([]);
|
|
||||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isCancelled = false;
|
|
||||||
|
|
||||||
getTags(tagsUrl).then(tags => {
|
|
||||||
if (!isCancelled) {
|
|
||||||
setAllTags(tags);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isCancelled = true;
|
|
||||||
};
|
|
||||||
}, [tagsUrl]);
|
|
||||||
|
|
||||||
const toggleTag = useCallback(
|
|
||||||
(event, tag) => {
|
|
||||||
let newSelectedTags;
|
|
||||||
if (event.shiftKey) {
|
|
||||||
// toggle tag
|
|
||||||
if (includes(selectedTags, tag)) {
|
|
||||||
newSelectedTags = difference(selectedTags, [tag]);
|
|
||||||
} else {
|
|
||||||
newSelectedTags = [...selectedTags, tag];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// if the tag is the only selected, deselect it, otherwise select only it
|
|
||||||
if (includes(selectedTags, tag) && selectedTags.length === 1) {
|
|
||||||
newSelectedTags = [];
|
|
||||||
} else {
|
|
||||||
newSelectedTags = [tag];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedTags(newSelectedTags);
|
|
||||||
if (onUpdate) {
|
|
||||||
onUpdate([...newSelectedTags]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[selectedTags, onUpdate]
|
|
||||||
);
|
|
||||||
|
|
||||||
const unselectAll = useCallback(() => {
|
|
||||||
setSelectedTags([]);
|
|
||||||
if (onUpdate) {
|
|
||||||
onUpdate([]);
|
|
||||||
}
|
|
||||||
}, [onUpdate]);
|
|
||||||
|
|
||||||
if (allTags.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="tags-list">
|
|
||||||
<div className="tags-list-title">
|
|
||||||
<label>Tags</label>
|
|
||||||
{showUnselectAll && selectedTags.length > 0 && (
|
|
||||||
<a onClick={unselectAll}>
|
|
||||||
<CloseOutlinedIcon />
|
|
||||||
clear selection
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="tiled">
|
|
||||||
<Menu className="invert-stripe-position" mode="inline" selectedKeys={selectedTags}>
|
|
||||||
{map(allTags, tag => (
|
|
||||||
<Menu.Item key={tag.name} className="m-0">
|
|
||||||
<a
|
|
||||||
className="d-flex align-items-center justify-content-between"
|
|
||||||
onClick={event => toggleTag(event, tag.name)}>
|
|
||||||
<span className="max-character col-xs-11">{tag.name}</span>
|
|
||||||
<Badge count={tag.count} />
|
|
||||||
</a>
|
|
||||||
</Menu.Item>
|
|
||||||
))}
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TagsList;
|
|
||||||
@@ -11,7 +11,7 @@ function toMoment(value) {
|
|||||||
return value && value.isValid() ? value : null;
|
return value && value.isValid() ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TimeAgo({ date, placeholder, autoUpdate, variation }) {
|
export default function TimeAgo({ date, placeholder, autoUpdate }) {
|
||||||
const startDate = toMoment(date);
|
const startDate = toMoment(date);
|
||||||
const [value, setValue] = useState(null);
|
const [value, setValue] = useState(null);
|
||||||
const title = useMemo(() => (startDate ? startDate.format(clientConfig.dateTimeFormat) : null), [startDate]);
|
const title = useMemo(() => (startDate ? startDate.format(clientConfig.dateTimeFormat) : null), [startDate]);
|
||||||
@@ -28,13 +28,6 @@ export default function TimeAgo({ date, placeholder, autoUpdate, variation }) {
|
|||||||
}
|
}
|
||||||
}, [autoUpdate, startDate, placeholder]);
|
}, [autoUpdate, startDate, placeholder]);
|
||||||
|
|
||||||
if (variation === "timeAgoInTooltip") {
|
|
||||||
return (
|
|
||||||
<Tooltip title={value}>
|
|
||||||
<span data-test="TimeAgo">{title}</span>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<Tooltip title={title}>
|
<Tooltip title={title}>
|
||||||
<span data-test="TimeAgo">{value}</span>
|
<span data-test="TimeAgo">{value}</span>
|
||||||
@@ -46,7 +39,6 @@ TimeAgo.propTypes = {
|
|||||||
date: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.instanceOf(Date), Moment]),
|
date: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.instanceOf(Date), Moment]),
|
||||||
placeholder: PropTypes.string,
|
placeholder: PropTypes.string,
|
||||||
autoUpdate: PropTypes.bool,
|
autoUpdate: PropTypes.bool,
|
||||||
variation: PropTypes.oneOf(["timeAgoInTooltip"]),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
TimeAgo.defaultProps = {
|
TimeAgo.defaultProps = {
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
.user-groups {
|
|
||||||
margin: -5px 0 0 -5px;
|
|
||||||
|
|
||||||
.ant-tag {
|
|
||||||
margin: 5px 0 0 5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import Menu from "antd/lib/menu";
|
import Tabs from "antd/lib/tabs";
|
||||||
import PageHeader from "@/components/PageHeader";
|
import PageHeader from "@/components/PageHeader";
|
||||||
import Link from "@/components/Link";
|
import Link from "@/components/Link";
|
||||||
|
|
||||||
@@ -11,19 +11,19 @@ export default function Layout({ activeTab, children }) {
|
|||||||
<div className="admin-page-layout">
|
<div className="admin-page-layout">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<PageHeader title="Admin" />
|
<PageHeader title="Admin" />
|
||||||
|
|
||||||
<div className="bg-white tiled">
|
<div className="bg-white tiled">
|
||||||
<Menu selectedKeys={[activeTab]} selectable={false} mode="horizontal">
|
<Tabs className="admin-page-layout-tabs" defaultActiveKey={activeTab} animated={false} tabBarGutter={0}>
|
||||||
<Menu.Item key="system_status">
|
<Tabs.TabPane key="system_status" tab={<Link href="admin/status">System Status</Link>}>
|
||||||
<Link href="admin/status">System Status</Link>
|
{activeTab === "system_status" ? children : null}
|
||||||
</Menu.Item>
|
</Tabs.TabPane>
|
||||||
<Menu.Item key="jobs">
|
<Tabs.TabPane key="jobs" tab={<Link href="admin/queries/jobs">RQ Status</Link>}>
|
||||||
<Link href="admin/queries/jobs">RQ Status</Link>
|
{activeTab === "jobs" ? children : null}
|
||||||
</Menu.Item>
|
</Tabs.TabPane>
|
||||||
<Menu.Item key="outdated_queries">
|
<Tabs.TabPane key="outdated_queries" tab={<Link href="admin/queries/outdated">Outdated Queries</Link>}>
|
||||||
<Link href="admin/queries/outdated">Outdated Queries</Link>
|
{activeTab === "outdated_queries" ? children : null}
|
||||||
</Menu.Item>
|
</Tabs.TabPane>
|
||||||
</Menu>
|
</Tabs>
|
||||||
{children}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
.admin-page-layout {
|
.admin-page-layout {
|
||||||
.ant-table {
|
&-tabs.ant-tabs {
|
||||||
overflow-x: auto;
|
> .ant-tabs-bar {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
.ant-tabs-tab {
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
a {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
84
client/app/components/cards-list/CardsList.jsx
Normal file
84
client/app/components/cards-list/CardsList.jsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import Input from "antd/lib/input";
|
||||||
|
import { includes, isEmpty } from "lodash";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import React from "react";
|
||||||
|
import Link from "@/components/Link";
|
||||||
|
import EmptyState from "@/components/items-list/components/EmptyState";
|
||||||
|
|
||||||
|
import "./CardsList.less";
|
||||||
|
|
||||||
|
const { Search } = Input;
|
||||||
|
|
||||||
|
export default class CardsList extends React.Component {
|
||||||
|
static propTypes = {
|
||||||
|
items: PropTypes.arrayOf(
|
||||||
|
PropTypes.shape({
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
imgSrc: PropTypes.string.isRequired,
|
||||||
|
onClick: PropTypes.func,
|
||||||
|
href: PropTypes.string,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
showSearch: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
items: [],
|
||||||
|
showSearch: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
searchText: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.items = [];
|
||||||
|
|
||||||
|
let itemId = 1;
|
||||||
|
props.items.forEach(item => {
|
||||||
|
this.items.push({ id: itemId, ...item });
|
||||||
|
itemId += 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
renderListItem(item) {
|
||||||
|
return (
|
||||||
|
<Link key={`card${item.id}`} className="visual-card" onClick={item.onClick} href={item.href}>
|
||||||
|
<img alt={item.title} src={item.imgSrc} />
|
||||||
|
<h3>{item.title}</h3>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { showSearch } = this.props;
|
||||||
|
const { searchText } = this.state;
|
||||||
|
|
||||||
|
const filteredItems = this.items.filter(
|
||||||
|
item => isEmpty(searchText) || includes(item.title.toLowerCase(), searchText.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-test="CardsList">
|
||||||
|
{showSearch && (
|
||||||
|
<div className="row p-10">
|
||||||
|
<div className="col-md-4 col-md-offset-4">
|
||||||
|
<Search placeholder="Search..." onChange={e => this.setState({ searchText: e.target.value })} autoFocus />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isEmpty(filteredItems) ? (
|
||||||
|
<EmptyState className="" />
|
||||||
|
) : (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-12 d-inline-flex flex-wrap visual-card-list">
|
||||||
|
{filteredItems.map(item => this.renderListItem(item))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { includes, isEmpty } from "lodash";
|
|
||||||
import PropTypes from "prop-types";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import Input from "antd/lib/input";
|
|
||||||
import Link from "@/components/Link";
|
|
||||||
import EmptyState from "@/components/items-list/components/EmptyState";
|
|
||||||
|
|
||||||
import "./CardsList.less";
|
|
||||||
|
|
||||||
export interface CardsListItem {
|
|
||||||
title: string;
|
|
||||||
imgSrc: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
href?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CardsListProps {
|
|
||||||
items?: CardsListItem[];
|
|
||||||
showSearch?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ListItemProps {
|
|
||||||
item: CardsListItem;
|
|
||||||
keySuffix: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ListItem({ item, keySuffix }: ListItemProps) {
|
|
||||||
return (
|
|
||||||
<Link key={`card${keySuffix}`} className="visual-card" onClick={item.onClick} href={item.href}>
|
|
||||||
<img alt={item.title} src={item.imgSrc} />
|
|
||||||
<h3>{item.title}</h3>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CardsList({ items = [], showSearch = false }: CardsListProps) {
|
|
||||||
const [searchText, setSearchText] = useState("");
|
|
||||||
const filteredItems = items.filter(
|
|
||||||
item => isEmpty(searchText) || includes(item.title.toLowerCase(), searchText.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div data-test="CardsList">
|
|
||||||
{showSearch && (
|
|
||||||
<div className="row p-10">
|
|
||||||
<div className="col-md-4 col-md-offset-4">
|
|
||||||
<Input.Search
|
|
||||||
placeholder="Search..."
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchText(e.target.value)}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isEmpty(filteredItems) ? (
|
|
||||||
<EmptyState className="" />
|
|
||||||
) : (
|
|
||||||
<div className="row">
|
|
||||||
<div className="col-lg-12 d-inline-flex flex-wrap visual-card-list">
|
|
||||||
{filteredItems.map((item: CardsListItem, index: number) => (
|
|
||||||
<ListItem key={index} item={item} keySuffix={index.toString()} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
CardsList.propTypes = {
|
|
||||||
items: PropTypes.arrayOf(
|
|
||||||
PropTypes.shape({
|
|
||||||
title: PropTypes.string.isRequired,
|
|
||||||
imgSrc: PropTypes.string.isRequired,
|
|
||||||
onClick: PropTypes.func,
|
|
||||||
href: PropTypes.string,
|
|
||||||
})
|
|
||||||
),
|
|
||||||
showSearch: PropTypes.bool,
|
|
||||||
};
|
|
||||||
@@ -238,7 +238,6 @@ class DashboardGrid extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<ResponsiveGridLayout
|
<ResponsiveGridLayout
|
||||||
draggableCancel="input"
|
|
||||||
className={cx("layout", { "disable-animations": this.state.disableAnimations })}
|
className={cx("layout", { "disable-animations": this.state.disableAnimations })}
|
||||||
cols={{ [MULTI]: cfg.columns, [SINGLE]: 1 }}
|
cols={{ [MULTI]: cfg.columns, [SINGLE]: 1 }}
|
||||||
rowHeight={cfg.rowHeight - cfg.margins}
|
rowHeight={cfg.rowHeight - cfg.margins}
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ import PropTypes from "prop-types";
|
|||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import Modal from "antd/lib/modal";
|
import Modal from "antd/lib/modal";
|
||||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||||
import { FiltersType } from "@/components/Filters";
|
|
||||||
import VisualizationRenderer from "@/components/visualizations/VisualizationRenderer";
|
import VisualizationRenderer from "@/components/visualizations/VisualizationRenderer";
|
||||||
import VisualizationName from "@/components/visualizations/VisualizationName";
|
import VisualizationName from "@/components/visualizations/VisualizationName";
|
||||||
|
|
||||||
function ExpandedWidgetDialog({ dialog, widget, filters }) {
|
function ExpandedWidgetDialog({ dialog, widget }) {
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
{...dialog.props}
|
{...dialog.props}
|
||||||
@@ -21,7 +20,6 @@ function ExpandedWidgetDialog({ dialog, widget, filters }) {
|
|||||||
<VisualizationRenderer
|
<VisualizationRenderer
|
||||||
visualization={widget.visualization}
|
visualization={widget.visualization}
|
||||||
queryResult={widget.getQueryResult()}
|
queryResult={widget.getQueryResult()}
|
||||||
filters={filters}
|
|
||||||
context="widget"
|
context="widget"
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -31,11 +29,6 @@ function ExpandedWidgetDialog({ dialog, widget, filters }) {
|
|||||||
ExpandedWidgetDialog.propTypes = {
|
ExpandedWidgetDialog.propTypes = {
|
||||||
dialog: DialogPropType.isRequired,
|
dialog: DialogPropType.isRequired,
|
||||||
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||||
filters: FiltersType,
|
|
||||||
};
|
|
||||||
|
|
||||||
ExpandedWidgetDialog.defaultProps = {
|
|
||||||
filters: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default wrapDialog(ExpandedWidgetDialog);
|
export default wrapDialog(ExpandedWidgetDialog);
|
||||||
|
|||||||
@@ -48,10 +48,10 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
bottom: 85px;
|
bottom: 85px;
|
||||||
right: 0;
|
right: 15px;
|
||||||
background: linear-gradient(to bottom, transparent, transparent 2px, #f6f8f9 2px, #f6f8f9 5px),
|
background: linear-gradient(to bottom, transparent, transparent 2px, #f6f8f9 2px, #f6f8f9 5px),
|
||||||
linear-gradient(to left, #b3babf, #b3babf 1px, transparent 1px, transparent);
|
linear-gradient(to left, #b3babf, #b3babf 1px, transparent 1px, transparent);
|
||||||
background-size: calc((100% + 15px) / 6) 5px;
|
background-size: calc((100vw - 15px) / 6) 5px;
|
||||||
background-position: -7px 1px;
|
background-position: -7px 1px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,10 +209,7 @@ class VisualizationWidget extends React.Component {
|
|||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = { localParameters: props.widget.getLocalParameters() };
|
||||||
localParameters: props.widget.getLocalParameters(),
|
|
||||||
localFilters: props.filters,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@@ -222,12 +219,8 @@ class VisualizationWidget extends React.Component {
|
|||||||
onLoad();
|
onLoad();
|
||||||
}
|
}
|
||||||
|
|
||||||
onLocalFiltersChange = localFilters => {
|
|
||||||
this.setState({ localFilters });
|
|
||||||
};
|
|
||||||
|
|
||||||
expandWidget = () => {
|
expandWidget = () => {
|
||||||
ExpandedWidgetDialog.showModal({ widget: this.props.widget, filters: this.state.localFilters });
|
ExpandedWidgetDialog.showModal({ widget: this.props.widget });
|
||||||
};
|
};
|
||||||
|
|
||||||
editParameterMappings = () => {
|
editParameterMappings = () => {
|
||||||
@@ -267,7 +260,6 @@ class VisualizationWidget extends React.Component {
|
|||||||
visualization={widget.visualization}
|
visualization={widget.visualization}
|
||||||
queryResult={widgetQueryResult}
|
queryResult={widgetQueryResult}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onFiltersChange={this.onLocalFiltersChange}
|
|
||||||
context="widget"
|
context="widget"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,29 +1,24 @@
|
|||||||
import React, { useState, useReducer, useCallback } from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import Form from "antd/lib/form";
|
import Form from "antd/lib/form";
|
||||||
|
import Input from "antd/lib/input";
|
||||||
|
import InputNumber from "antd/lib/input-number";
|
||||||
|
import Checkbox from "antd/lib/checkbox";
|
||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import { includes, isFunction, filter, find, difference, isEmpty, mapValues } from "lodash";
|
import Upload from "antd/lib/upload";
|
||||||
|
import Icon from "antd/lib/icon";
|
||||||
|
import { includes, isFunction, filter, difference, isEmpty } from "lodash";
|
||||||
|
import Select from "antd/lib/select";
|
||||||
import notification from "@/services/notification";
|
import notification from "@/services/notification";
|
||||||
import Collapse from "@/components/Collapse";
|
import Collapse from "@/components/Collapse";
|
||||||
import DynamicFormField, { FieldType } from "./DynamicFormField";
|
import AceEditorInput from "@/components/AceEditorInput";
|
||||||
import getFieldLabel from "./getFieldLabel";
|
import { toHuman } from "@/lib/utils";
|
||||||
|
import { Field, Action, AntdForm } from "../proptypes";
|
||||||
import helper from "./dynamicFormHelper";
|
import helper from "./dynamicFormHelper";
|
||||||
|
|
||||||
import "./DynamicForm.less";
|
import "./DynamicForm.less";
|
||||||
|
|
||||||
const ActionType = PropTypes.shape({
|
|
||||||
name: PropTypes.string.isRequired,
|
|
||||||
callback: PropTypes.func.isRequired,
|
|
||||||
type: PropTypes.string,
|
|
||||||
pullRight: PropTypes.bool,
|
|
||||||
disabledWhenDirty: PropTypes.bool,
|
|
||||||
});
|
|
||||||
|
|
||||||
const AntdFormType = PropTypes.shape({
|
|
||||||
validateFieldsAndScroll: PropTypes.func,
|
|
||||||
});
|
|
||||||
|
|
||||||
const fieldRules = ({ type, required, minLength }) => {
|
const fieldRules = ({ type, required, minLength }) => {
|
||||||
const requiredRule = required;
|
const requiredRule = required;
|
||||||
const minLengthRule = minLength && includes(["text", "email", "password"], type);
|
const minLengthRule = minLength && includes(["text", "email", "password"], type);
|
||||||
@@ -36,206 +31,282 @@ const fieldRules = ({ type, required, minLength }) => {
|
|||||||
].filter(rule => rule);
|
].filter(rule => rule);
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeEmptyValuesToNull(fields, values) {
|
class DynamicForm extends React.Component {
|
||||||
return mapValues(values, (value, key) => {
|
static propTypes = {
|
||||||
const { initialValue } = find(fields, { name: key }) || {};
|
id: PropTypes.string,
|
||||||
if ((initialValue === null || initialValue === undefined || initialValue === "") && value === "") {
|
fields: PropTypes.arrayOf(Field),
|
||||||
return null;
|
actions: PropTypes.arrayOf(Action),
|
||||||
|
feedbackIcons: PropTypes.bool,
|
||||||
|
hideSubmitButton: PropTypes.bool,
|
||||||
|
defaultShowExtraFields: PropTypes.bool,
|
||||||
|
saveText: PropTypes.string,
|
||||||
|
onSubmit: PropTypes.func,
|
||||||
|
form: AntdForm.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
id: null,
|
||||||
|
fields: [],
|
||||||
|
actions: [],
|
||||||
|
feedbackIcons: false,
|
||||||
|
hideSubmitButton: false,
|
||||||
|
defaultShowExtraFields: false,
|
||||||
|
saveText: "Save",
|
||||||
|
onSubmit: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
const inProgressActions = {};
|
||||||
|
props.actions.forEach(action => (inProgressActions[action.name] = false));
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
isSubmitting: false,
|
||||||
|
showExtraFields: props.defaultShowExtraFields,
|
||||||
|
inProgressActions,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.actionCallbacks = this.props.actions.reduce(
|
||||||
|
(acc, cur) => ({
|
||||||
|
...acc,
|
||||||
|
[cur.name]: cur.callback,
|
||||||
|
}),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setActionInProgress = (actionName, inProgress) => {
|
||||||
|
this.setState(prevState => ({
|
||||||
|
inProgressActions: {
|
||||||
|
...prevState.inProgressActions,
|
||||||
|
[actionName]: inProgress,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSubmit = e => {
|
||||||
|
this.setState({ isSubmitting: true });
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
this.props.form.validateFieldsAndScroll((err, values) => {
|
||||||
|
Object.entries(values).forEach(([key, value]) => {
|
||||||
|
const initialValue = this.props.fields.find(f => f.name === key).initialValue;
|
||||||
|
if ((initialValue === null || initialValue === undefined || initialValue === "") && value === "") {
|
||||||
|
values[key] = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!err) {
|
||||||
|
this.props.onSubmit(
|
||||||
|
values,
|
||||||
|
msg => {
|
||||||
|
const { setFieldsValue, getFieldsValue } = this.props.form;
|
||||||
|
this.setState({ isSubmitting: false });
|
||||||
|
setFieldsValue(getFieldsValue()); // reset form touched state
|
||||||
|
notification.success(msg);
|
||||||
|
},
|
||||||
|
msg => {
|
||||||
|
this.setState({ isSubmitting: false });
|
||||||
|
notification.error(msg);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else this.setState({ isSubmitting: false });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
handleAction = e => {
|
||||||
|
const actionName = e.target.dataset.action;
|
||||||
|
|
||||||
|
this.setActionInProgress(actionName, true);
|
||||||
|
this.actionCallbacks[actionName](() => {
|
||||||
|
this.setActionInProgress(actionName, false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
base64File = (fieldName, e) => {
|
||||||
|
if (e && e.fileList[0]) {
|
||||||
|
helper.getBase64(e.file).then(value => {
|
||||||
|
this.props.form.setFieldsValue({ [fieldName]: value });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return value;
|
};
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function DynamicFormFields({ fields, feedbackIcons, form }) {
|
renderUpload(field, props) {
|
||||||
return fields.map(field => {
|
const { getFieldDecorator, getFieldValue } = this.props.form;
|
||||||
const { name, type, initialValue, contentAfter } = field;
|
const { name, initialValue } = field;
|
||||||
const fieldLabel = getFieldLabel(field);
|
|
||||||
|
|
||||||
const formItemProps = {
|
const fileOptions = {
|
||||||
|
rules: fieldRules(field),
|
||||||
|
initialValue,
|
||||||
|
getValueFromEvent: this.base64File.bind(this, name),
|
||||||
|
};
|
||||||
|
|
||||||
|
const disabled = getFieldValue(name) !== undefined && getFieldValue(name) !== initialValue;
|
||||||
|
|
||||||
|
const upload = (
|
||||||
|
<Upload {...props} beforeUpload={() => false}>
|
||||||
|
<Button disabled={disabled}>
|
||||||
|
<Icon type="upload" /> Click to upload
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
);
|
||||||
|
|
||||||
|
return getFieldDecorator(name, fileOptions)(upload);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSelect(field, props) {
|
||||||
|
const { getFieldDecorator } = this.props.form;
|
||||||
|
const { name, options, mode, initialValue, readOnly, loading } = field;
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
const decoratorOptions = {
|
||||||
|
rules: fieldRules(field),
|
||||||
|
initialValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
return getFieldDecorator(
|
||||||
name,
|
name,
|
||||||
className: "m-b-10",
|
decoratorOptions
|
||||||
hasFeedback: type !== "checkbox" && type !== "file" && feedbackIcons,
|
)(
|
||||||
label: type === "checkbox" ? "" : fieldLabel,
|
<Select
|
||||||
|
{...props}
|
||||||
|
optionFilterProp="children"
|
||||||
|
loading={loading || false}
|
||||||
|
mode={mode}
|
||||||
|
getPopupContainer={trigger => trigger.parentNode}>
|
||||||
|
{options &&
|
||||||
|
options.map(option => (
|
||||||
|
<Option key={`${option.value}`} value={option.value} disabled={readOnly}>
|
||||||
|
{option.name || option.value}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderField(field, props) {
|
||||||
|
const { getFieldDecorator } = this.props.form;
|
||||||
|
const { name, type, initialValue } = field;
|
||||||
|
const fieldLabel = field.title || toHuman(name);
|
||||||
|
|
||||||
|
const options = {
|
||||||
rules: fieldRules(field),
|
rules: fieldRules(field),
|
||||||
valuePropName: type === "checkbox" ? "checked" : "value",
|
valuePropName: type === "checkbox" ? "checked" : "value",
|
||||||
initialValue,
|
initialValue,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (type === "file") {
|
if (type === "checkbox") {
|
||||||
formItemProps.valuePropName = "data-value";
|
return getFieldDecorator(name, options)(<Checkbox {...props}>{fieldLabel}</Checkbox>);
|
||||||
formItemProps.getValueFromEvent = e => {
|
} else if (type === "file") {
|
||||||
if (e && e.fileList[0]) {
|
return this.renderUpload(field, props);
|
||||||
helper.getBase64(e.file).then(value => {
|
} else if (type === "select") {
|
||||||
form.setFieldsValue({ [name]: value });
|
return this.renderSelect(field, props);
|
||||||
});
|
} else if (type === "content") {
|
||||||
}
|
return field.content;
|
||||||
return undefined;
|
} else if (type === "number") {
|
||||||
};
|
return getFieldDecorator(name, options)(<InputNumber {...props} />);
|
||||||
|
} else if (type === "textarea") {
|
||||||
|
return getFieldDecorator(name, options)(<Input.TextArea {...props} />);
|
||||||
|
} else if (type === "ace") {
|
||||||
|
return getFieldDecorator(name, options)(<AceEditorInput {...props} />);
|
||||||
}
|
}
|
||||||
|
return getFieldDecorator(name, options)(<Input {...props} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFields(fields) {
|
||||||
|
return fields.map(field => {
|
||||||
|
const FormItem = Form.Item;
|
||||||
|
const { name, title, type, readOnly, autoFocus, contentAfter } = field;
|
||||||
|
const fieldLabel = title || toHuman(name);
|
||||||
|
const { feedbackIcons, form } = this.props;
|
||||||
|
|
||||||
|
const formItemProps = {
|
||||||
|
className: "m-b-10",
|
||||||
|
hasFeedback: type !== "checkbox" && type !== "file" && feedbackIcons,
|
||||||
|
label: type === "checkbox" ? "" : fieldLabel,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldProps = {
|
||||||
|
...field.props,
|
||||||
|
className: "w-100",
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
readOnly,
|
||||||
|
autoFocus,
|
||||||
|
placeholder: field.placeholder,
|
||||||
|
"data-test": fieldLabel,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={name}>
|
||||||
|
<FormItem {...formItemProps}>{this.renderField(field, fieldProps)}</FormItem>
|
||||||
|
{isFunction(contentAfter) ? contentAfter(form.getFieldValue(name)) : contentAfter}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderActions() {
|
||||||
|
return this.props.actions.map(action => {
|
||||||
|
const inProgress = this.state.inProgressActions[action.name];
|
||||||
|
const { isFieldsTouched } = this.props.form;
|
||||||
|
|
||||||
|
const actionProps = {
|
||||||
|
key: action.name,
|
||||||
|
htmlType: "button",
|
||||||
|
className: action.pullRight ? "pull-right m-t-10" : "m-t-10",
|
||||||
|
type: action.type,
|
||||||
|
disabled: isFieldsTouched() && action.disableWhenDirty,
|
||||||
|
loading: inProgress,
|
||||||
|
onClick: this.handleAction,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button {...actionProps} data-action={action.name}>
|
||||||
|
{action.name}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const submitProps = {
|
||||||
|
type: "primary",
|
||||||
|
htmlType: "submit",
|
||||||
|
className: "w-100 m-t-20",
|
||||||
|
disabled: this.state.isSubmitting,
|
||||||
|
loading: this.state.isSubmitting,
|
||||||
|
};
|
||||||
|
const { id, hideSubmitButton, saveText, fields } = this.props;
|
||||||
|
const { showExtraFields } = this.state;
|
||||||
|
const saveButton = !hideSubmitButton;
|
||||||
|
const extraFields = filter(fields, { extra: true });
|
||||||
|
const regularFields = difference(fields, extraFields);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={name}>
|
<Form id={id} className="dynamic-form" layout="vertical" onSubmit={this.handleSubmit}>
|
||||||
<Form.Item {...formItemProps}>
|
{this.renderFields(regularFields)}
|
||||||
<DynamicFormField field={field} form={form} />
|
{!isEmpty(extraFields) && (
|
||||||
</Form.Item>
|
<div className="extra-options">
|
||||||
{isFunction(contentAfter) ? contentAfter(form.getFieldValue(name)) : contentAfter}
|
<Button
|
||||||
</React.Fragment>
|
type="dashed"
|
||||||
|
block
|
||||||
|
className="extra-options-button"
|
||||||
|
onClick={() => this.setState({ showExtraFields: !showExtraFields })}>
|
||||||
|
Additional Settings
|
||||||
|
<i className={cx("fa m-l-5", { "fa-caret-up": showExtraFields, "fa-caret-down": !showExtraFields })} />
|
||||||
|
</Button>
|
||||||
|
<Collapse collapsed={!showExtraFields} className="extra-options-content">
|
||||||
|
{this.renderFields(extraFields)}
|
||||||
|
</Collapse>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{saveButton && <Button {...submitProps}>{saveText}</Button>}
|
||||||
|
{this.renderActions()}
|
||||||
|
</Form>
|
||||||
);
|
);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
DynamicFormFields.propTypes = {
|
|
||||||
fields: PropTypes.arrayOf(FieldType),
|
|
||||||
feedbackIcons: PropTypes.bool,
|
|
||||||
form: AntdFormType.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
DynamicFormFields.defaultProps = {
|
|
||||||
fields: [],
|
|
||||||
feedbackIcons: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const reducerForActionSet = (state, action) => {
|
|
||||||
if (action.inProgress) {
|
|
||||||
state.add(action.actionName);
|
|
||||||
} else {
|
|
||||||
state.delete(action.actionName);
|
|
||||||
}
|
}
|
||||||
return new Set(state);
|
|
||||||
};
|
|
||||||
|
|
||||||
function DynamicFormActions({ actions, isFormDirty }) {
|
|
||||||
const [inProgressActions, setActionInProgress] = useReducer(reducerForActionSet, new Set());
|
|
||||||
|
|
||||||
const handleAction = useCallback(action => {
|
|
||||||
const actionName = action.name;
|
|
||||||
if (isFunction(action.callback)) {
|
|
||||||
setActionInProgress({ actionName, inProgress: true });
|
|
||||||
action.callback(() => {
|
|
||||||
setActionInProgress({ actionName, inProgress: false });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
return actions.map(action => (
|
|
||||||
<Button
|
|
||||||
key={action.name}
|
|
||||||
htmlType="button"
|
|
||||||
className={cx("m-t-10", { "pull-right": action.pullRight })}
|
|
||||||
type={action.type}
|
|
||||||
disabled={isFormDirty && action.disableWhenDirty}
|
|
||||||
loading={inProgressActions.has(action.name)}
|
|
||||||
onClick={() => handleAction(action)}>
|
|
||||||
{action.name}
|
|
||||||
</Button>
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DynamicFormActions.propTypes = {
|
export default Form.create()(DynamicForm);
|
||||||
actions: PropTypes.arrayOf(ActionType),
|
|
||||||
isFormDirty: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
DynamicFormActions.defaultProps = {
|
|
||||||
actions: [],
|
|
||||||
isFormDirty: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function DynamicForm({
|
|
||||||
id,
|
|
||||||
fields,
|
|
||||||
actions,
|
|
||||||
feedbackIcons,
|
|
||||||
hideSubmitButton,
|
|
||||||
defaultShowExtraFields,
|
|
||||||
saveText,
|
|
||||||
onSubmit,
|
|
||||||
}) {
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [showExtraFields, setShowExtraFields] = useState(defaultShowExtraFields);
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
const extraFields = filter(fields, { extra: true });
|
|
||||||
const regularFields = difference(fields, extraFields);
|
|
||||||
|
|
||||||
const handleFinish = useCallback(
|
|
||||||
values => {
|
|
||||||
setIsSubmitting(true);
|
|
||||||
values = normalizeEmptyValuesToNull(fields, values);
|
|
||||||
onSubmit(
|
|
||||||
values,
|
|
||||||
msg => {
|
|
||||||
const { setFieldsValue, getFieldsValue } = form;
|
|
||||||
setIsSubmitting(false);
|
|
||||||
setFieldsValue(getFieldsValue()); // reset form touched state
|
|
||||||
notification.success(msg);
|
|
||||||
},
|
|
||||||
msg => {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
notification.error(msg);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[form, fields, onSubmit]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFinishFailed = useCallback(
|
|
||||||
({ errorFields }) => {
|
|
||||||
form.scrollToField(errorFields[0].name);
|
|
||||||
},
|
|
||||||
[form]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form
|
|
||||||
form={form}
|
|
||||||
id={id}
|
|
||||||
className="dynamic-form"
|
|
||||||
layout="vertical"
|
|
||||||
onFinish={handleFinish}
|
|
||||||
onFinishFailed={handleFinishFailed}>
|
|
||||||
<DynamicFormFields fields={regularFields} feedbackIcons={feedbackIcons} form={form} />
|
|
||||||
{!isEmpty(extraFields) && (
|
|
||||||
<div className="extra-options">
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
block
|
|
||||||
className="extra-options-button"
|
|
||||||
onClick={() => setShowExtraFields(currentShowExtraFields => !currentShowExtraFields)}>
|
|
||||||
Additional Settings
|
|
||||||
<i className={cx("fa m-l-5", { "fa-caret-up": showExtraFields, "fa-caret-down": !showExtraFields })} />
|
|
||||||
</Button>
|
|
||||||
<Collapse collapsed={!showExtraFields} className="extra-options-content">
|
|
||||||
<DynamicFormFields fields={extraFields} feedbackIcons={feedbackIcons} form={form} />
|
|
||||||
</Collapse>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!hideSubmitButton && (
|
|
||||||
<Button className="w-100 m-t-20" type="primary" htmlType="submit" disabled={isSubmitting}>
|
|
||||||
{saveText}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<DynamicFormActions actions={actions} isFormDirty={form.isFieldsTouched()} />
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
DynamicForm.propTypes = {
|
|
||||||
id: PropTypes.string,
|
|
||||||
fields: PropTypes.arrayOf(FieldType),
|
|
||||||
actions: PropTypes.arrayOf(ActionType),
|
|
||||||
feedbackIcons: PropTypes.bool,
|
|
||||||
hideSubmitButton: PropTypes.bool,
|
|
||||||
defaultShowExtraFields: PropTypes.bool,
|
|
||||||
saveText: PropTypes.string,
|
|
||||||
onSubmit: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
DynamicForm.defaultProps = {
|
|
||||||
id: null,
|
|
||||||
fields: [],
|
|
||||||
actions: [],
|
|
||||||
feedbackIcons: false,
|
|
||||||
hideSubmitButton: false,
|
|
||||||
defaultShowExtraFields: false,
|
|
||||||
saveText: "Save",
|
|
||||||
onSubmit: () => {},
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { get } from "lodash";
|
|
||||||
import PropTypes from "prop-types";
|
|
||||||
import getFieldLabel from "./getFieldLabel";
|
|
||||||
|
|
||||||
import {
|
|
||||||
AceEditorField,
|
|
||||||
CheckboxField,
|
|
||||||
ContentField,
|
|
||||||
FileField,
|
|
||||||
InputField,
|
|
||||||
NumberField,
|
|
||||||
SelectField,
|
|
||||||
TextAreaField,
|
|
||||||
} from "./fields";
|
|
||||||
|
|
||||||
export const FieldType = PropTypes.shape({
|
|
||||||
name: PropTypes.string.isRequired,
|
|
||||||
title: PropTypes.string,
|
|
||||||
type: PropTypes.oneOf([
|
|
||||||
"ace",
|
|
||||||
"text",
|
|
||||||
"textarea",
|
|
||||||
"email",
|
|
||||||
"password",
|
|
||||||
"number",
|
|
||||||
"checkbox",
|
|
||||||
"file",
|
|
||||||
"select",
|
|
||||||
"content",
|
|
||||||
]).isRequired,
|
|
||||||
initialValue: PropTypes.oneOfType([
|
|
||||||
PropTypes.string,
|
|
||||||
PropTypes.number,
|
|
||||||
PropTypes.bool,
|
|
||||||
PropTypes.arrayOf(PropTypes.string),
|
|
||||||
PropTypes.arrayOf(PropTypes.number),
|
|
||||||
]),
|
|
||||||
content: PropTypes.node,
|
|
||||||
mode: PropTypes.string,
|
|
||||||
required: PropTypes.bool,
|
|
||||||
extra: PropTypes.bool,
|
|
||||||
readOnly: PropTypes.bool,
|
|
||||||
autoFocus: PropTypes.bool,
|
|
||||||
minLength: PropTypes.number,
|
|
||||||
placeholder: PropTypes.string,
|
|
||||||
contentAfter: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
|
|
||||||
loading: PropTypes.bool,
|
|
||||||
props: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
|
||||||
});
|
|
||||||
|
|
||||||
const FieldTypeComponent = {
|
|
||||||
checkbox: CheckboxField,
|
|
||||||
file: FileField,
|
|
||||||
select: SelectField,
|
|
||||||
number: NumberField,
|
|
||||||
textarea: TextAreaField,
|
|
||||||
ace: AceEditorField,
|
|
||||||
content: ContentField,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function DynamicFormField({ form, field, ...otherProps }) {
|
|
||||||
const { name, type, readOnly, autoFocus } = field;
|
|
||||||
const fieldLabel = getFieldLabel(field);
|
|
||||||
|
|
||||||
const fieldProps = {
|
|
||||||
...field.props,
|
|
||||||
className: "w-100",
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
readOnly,
|
|
||||||
autoFocus,
|
|
||||||
placeholder: field.placeholder,
|
|
||||||
"data-test": fieldLabel,
|
|
||||||
...otherProps,
|
|
||||||
};
|
|
||||||
|
|
||||||
const FieldComponent = get(FieldTypeComponent, type, InputField);
|
|
||||||
return <FieldComponent {...fieldProps} form={form} field={field} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
DynamicFormField.propTypes = { field: FieldType.isRequired };
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import AceEditorInput from "@/components/AceEditorInput";
|
|
||||||
|
|
||||||
export default function AceEditorField({ form, field, ...otherProps }) {
|
|
||||||
return <AceEditorInput {...otherProps} />;
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import Checkbox from "antd/lib/checkbox";
|
|
||||||
import getFieldLabel from "../getFieldLabel";
|
|
||||||
|
|
||||||
export default function CheckboxField({ form, field, ...otherProps }) {
|
|
||||||
const fieldLabel = getFieldLabel(field);
|
|
||||||
return <Checkbox {...otherProps}>{fieldLabel}</Checkbox>;
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function ContentField({ field }) {
|
|
||||||
return field.content;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import Button from "antd/lib/button";
|
|
||||||
import Upload from "antd/lib/upload";
|
|
||||||
import UploadOutlinedIcon from "@ant-design/icons/UploadOutlined";
|
|
||||||
|
|
||||||
export default function FileField({ form, field, ...otherProps }) {
|
|
||||||
const { name, initialValue } = field;
|
|
||||||
const { getFieldValue } = form;
|
|
||||||
const disabled = getFieldValue(name) !== undefined && getFieldValue(name) !== initialValue;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Upload {...otherProps} beforeUpload={() => false}>
|
|
||||||
<Button disabled={disabled}>
|
|
||||||
<UploadOutlinedIcon /> Click to upload
|
|
||||||
</Button>
|
|
||||||
</Upload>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import Input from "antd/lib/input";
|
|
||||||
|
|
||||||
export default function InputField({ form, field, ...otherProps }) {
|
|
||||||
return <Input {...otherProps} />;
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import InputNumber from "antd/lib/input-number";
|
|
||||||
|
|
||||||
export default function NumberField({ form, field, ...otherProps }) {
|
|
||||||
return <InputNumber {...otherProps} />;
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import Select from "antd/lib/select";
|
|
||||||
|
|
||||||
export default function SelectField({ form, field, ...otherProps }) {
|
|
||||||
const { readOnly } = field;
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
{...otherProps}
|
|
||||||
optionFilterProp="children"
|
|
||||||
loading={field.loading || false}
|
|
||||||
mode={field.mode}
|
|
||||||
getPopupContainer={trigger => trigger.parentNode}>
|
|
||||||
{field.options &&
|
|
||||||
field.options.map(option => (
|
|
||||||
<Select.Option key={`${option.value}`} value={option.value} disabled={readOnly}>
|
|
||||||
{option.name || option.value}
|
|
||||||
</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import Input from "antd/lib/input";
|
|
||||||
|
|
||||||
export default function TextAreaField({ form, field, ...otherProps }) {
|
|
||||||
return <Input.TextArea {...otherProps} />;
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export { default as AceEditorField } from "./AceEditorField";
|
|
||||||
export { default as CheckboxField } from "./CheckboxField";
|
|
||||||
export { default as ContentField } from "./ContentField";
|
|
||||||
export { default as FileField } from "./FileField";
|
|
||||||
export { default as InputField } from "./InputField";
|
|
||||||
export { default as NumberField } from "./NumberField";
|
|
||||||
export { default as SelectField } from "./SelectField";
|
|
||||||
export { default as TextAreaField } from "./TextAreaField";
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { toHuman } from "@/lib/utils";
|
|
||||||
|
|
||||||
export default function getFieldLabel(field) {
|
|
||||||
const { title, name } = field;
|
|
||||||
return title || toHuman(name);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { getDynamicDateFromString } from "@/services/parameters/DateParameter";
|
import classNames from "classnames";
|
||||||
import DynamicDatePicker from "@/components/dynamic-parameters/DynamicDatePicker";
|
import moment from "moment";
|
||||||
|
import { includes } from "lodash";
|
||||||
|
import { isDynamicDate, getDynamicDateFromString } from "@/services/parameters/DateParameter";
|
||||||
|
import DateInput from "@/components/DateInput";
|
||||||
|
import DateTimeInput from "@/components/DateTimeInput";
|
||||||
|
import DynamicButton from "@/components/dynamic-parameters/DynamicButton";
|
||||||
|
|
||||||
|
import "./DynamicParameters.less";
|
||||||
|
|
||||||
const DYNAMIC_DATE_OPTIONS = [
|
const DYNAMIC_DATE_OPTIONS = [
|
||||||
{
|
{
|
||||||
@@ -22,24 +29,86 @@ const DYNAMIC_DATE_OPTIONS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function DateParameter(props) {
|
class DateParameter extends React.Component {
|
||||||
return <DynamicDatePicker dynamicButtonOptions={{ options: DYNAMIC_DATE_OPTIONS }} {...props} />;
|
static propTypes = {
|
||||||
|
type: PropTypes.string,
|
||||||
|
className: PropTypes.string,
|
||||||
|
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||||
|
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||||
|
onSelect: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
type: "",
|
||||||
|
className: "",
|
||||||
|
value: null,
|
||||||
|
parameter: null,
|
||||||
|
onSelect: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.dateComponentRef = React.createRef();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDynamicValueSelect = dynamicValue => {
|
||||||
|
const { onSelect, parameter } = this.props;
|
||||||
|
if (dynamicValue === "static") {
|
||||||
|
const parameterValue = parameter.getExecutionValue();
|
||||||
|
if (parameterValue) {
|
||||||
|
onSelect(moment(parameterValue));
|
||||||
|
} else {
|
||||||
|
onSelect(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onSelect(dynamicValue.value);
|
||||||
|
}
|
||||||
|
// give focus to the DatePicker to get keyboard shortcuts to work
|
||||||
|
this.dateComponentRef.current.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { type, value, className, onSelect } = this.props;
|
||||||
|
const hasDynamicValue = isDynamicDate(value);
|
||||||
|
const isDateTime = includes(type, "datetime");
|
||||||
|
|
||||||
|
const additionalAttributes = {};
|
||||||
|
|
||||||
|
let DateComponent = DateInput;
|
||||||
|
if (isDateTime) {
|
||||||
|
DateComponent = DateTimeInput;
|
||||||
|
if (includes(type, "with-seconds")) {
|
||||||
|
additionalAttributes.withSeconds = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moment.isMoment(value) || value === null) {
|
||||||
|
additionalAttributes.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDynamicValue) {
|
||||||
|
const dynamicDate = value;
|
||||||
|
additionalAttributes.placeholder = dynamicDate && dynamicDate.name;
|
||||||
|
additionalAttributes.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DateComponent
|
||||||
|
ref={this.dateComponentRef}
|
||||||
|
className={classNames("redash-datepicker", { "dynamic-value": hasDynamicValue }, className)}
|
||||||
|
onSelect={onSelect}
|
||||||
|
suffixIcon={
|
||||||
|
<DynamicButton
|
||||||
|
options={DYNAMIC_DATE_OPTIONS}
|
||||||
|
selectedDynamicValue={hasDynamicValue ? value : null}
|
||||||
|
enabled={hasDynamicValue}
|
||||||
|
onSelect={this.onDynamicValueSelect}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{...additionalAttributes}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DateParameter.propTypes = {
|
|
||||||
type: PropTypes.string,
|
|
||||||
className: PropTypes.string,
|
|
||||||
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
|
||||||
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
|
||||||
onSelect: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
DateParameter.defaultProps = {
|
|
||||||
type: "",
|
|
||||||
className: "",
|
|
||||||
value: null,
|
|
||||||
parameter: null,
|
|
||||||
onSelect: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DateParameter;
|
export default DateParameter;
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { includes } from "lodash";
|
import classNames from "classnames";
|
||||||
import { getDynamicDateRangeFromString } from "@/services/parameters/DateRangeParameter";
|
import moment from "moment";
|
||||||
import DynamicDateRangePicker from "@/components/dynamic-parameters/DynamicDateRangePicker";
|
import { includes, isArray, isObject } from "lodash";
|
||||||
|
import { isDynamicDateRange, getDynamicDateRangeFromString } from "@/services/parameters/DateRangeParameter";
|
||||||
|
import DateRangeInput from "@/components/DateRangeInput";
|
||||||
|
import DateTimeRangeInput from "@/components/DateTimeRangeInput";
|
||||||
|
import DynamicButton from "@/components/dynamic-parameters/DynamicButton";
|
||||||
|
|
||||||
|
import "./DynamicParameters.less";
|
||||||
|
|
||||||
const DYNAMIC_DATE_OPTIONS = [
|
const DYNAMIC_DATE_OPTIONS = [
|
||||||
{
|
{
|
||||||
@@ -128,25 +134,97 @@ const DYNAMIC_DATETIME_OPTIONS = [
|
|||||||
...DYNAMIC_DATE_OPTIONS,
|
...DYNAMIC_DATE_OPTIONS,
|
||||||
];
|
];
|
||||||
|
|
||||||
function DateRangeParameter(props) {
|
const widthByType = {
|
||||||
const options = includes(props.type, "datetime-range") ? DYNAMIC_DATETIME_OPTIONS : DYNAMIC_DATE_OPTIONS;
|
"date-range": 294,
|
||||||
return <DynamicDateRangePicker {...props} dynamicButtonOptions={{ options }} />;
|
"datetime-range": 352,
|
||||||
|
"datetime-range-with-seconds": 382,
|
||||||
|
};
|
||||||
|
|
||||||
|
function isValidDateRangeValue(value) {
|
||||||
|
return isArray(value) && value.length === 2 && moment.isMoment(value[0]) && moment.isMoment(value[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
DateRangeParameter.propTypes = {
|
class DateRangeParameter extends React.Component {
|
||||||
type: PropTypes.string,
|
static propTypes = {
|
||||||
className: PropTypes.string,
|
type: PropTypes.string,
|
||||||
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
className: PropTypes.string,
|
||||||
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||||
onSelect: PropTypes.func,
|
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||||
};
|
onSelect: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
DateRangeParameter.defaultProps = {
|
static defaultProps = {
|
||||||
type: "",
|
type: "",
|
||||||
className: "",
|
className: "",
|
||||||
value: null,
|
value: null,
|
||||||
parameter: null,
|
parameter: null,
|
||||||
onSelect: () => {},
|
onSelect: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.dateRangeComponentRef = React.createRef();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDynamicValueSelect = dynamicValue => {
|
||||||
|
const { onSelect, parameter } = this.props;
|
||||||
|
if (dynamicValue === "static") {
|
||||||
|
const parameterValue = parameter.getExecutionValue();
|
||||||
|
if (isObject(parameterValue) && parameterValue.start && parameterValue.end) {
|
||||||
|
onSelect([moment(parameterValue.start), moment(parameterValue.end)]);
|
||||||
|
} else {
|
||||||
|
onSelect(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onSelect(dynamicValue.value);
|
||||||
|
}
|
||||||
|
// give focus to the DatePicker to get keyboard shortcuts to work
|
||||||
|
this.dateRangeComponentRef.current.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { type, value, onSelect, className } = this.props;
|
||||||
|
const isDateTimeRange = includes(type, "datetime-range");
|
||||||
|
const hasDynamicValue = isDynamicDateRange(value);
|
||||||
|
const options = isDateTimeRange ? DYNAMIC_DATETIME_OPTIONS : DYNAMIC_DATE_OPTIONS;
|
||||||
|
|
||||||
|
const additionalAttributes = {};
|
||||||
|
|
||||||
|
let DateRangeComponent = DateRangeInput;
|
||||||
|
if (isDateTimeRange) {
|
||||||
|
DateRangeComponent = DateTimeRangeInput;
|
||||||
|
if (includes(type, "with-seconds")) {
|
||||||
|
additionalAttributes.withSeconds = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValidDateRangeValue(value) || value === null) {
|
||||||
|
additionalAttributes.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDynamicValue) {
|
||||||
|
additionalAttributes.placeholder = [value && value.name];
|
||||||
|
additionalAttributes.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DateRangeComponent
|
||||||
|
ref={this.dateRangeComponentRef}
|
||||||
|
className={classNames("redash-datepicker date-range-input", { "dynamic-value": hasDynamicValue }, className)}
|
||||||
|
onSelect={onSelect}
|
||||||
|
style={{ width: hasDynamicValue ? 195 : widthByType[type] }}
|
||||||
|
suffixIcon={
|
||||||
|
<DynamicButton
|
||||||
|
options={options}
|
||||||
|
selectedDynamicValue={hasDynamicValue ? value : null}
|
||||||
|
enabled={hasDynamicValue}
|
||||||
|
onSelect={this.onDynamicValueSelect}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{...additionalAttributes}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default DateRangeParameter;
|
export default DateRangeParameter;
|
||||||
|
|||||||
@@ -2,20 +2,17 @@ import React, { useRef } from "react";
|
|||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { isFunction, get, findIndex } from "lodash";
|
import { isFunction, get, findIndex } from "lodash";
|
||||||
import Dropdown from "antd/lib/dropdown";
|
import Dropdown from "antd/lib/dropdown";
|
||||||
|
import Icon from "antd/lib/icon";
|
||||||
import Menu from "antd/lib/menu";
|
import Menu from "antd/lib/menu";
|
||||||
import Typography from "antd/lib/typography";
|
import Typography from "antd/lib/typography";
|
||||||
import { DynamicDateType } from "@/services/parameters/DateParameter";
|
import { DynamicDateType } from "@/services/parameters/DateParameter";
|
||||||
import { DynamicDateRangeType } from "@/services/parameters/DateRangeParameter";
|
import { DynamicDateRangeType } from "@/services/parameters/DateRangeParameter";
|
||||||
|
|
||||||
import ArrowLeftOutlinedIcon from "@ant-design/icons/ArrowLeftOutlined";
|
|
||||||
import ThunderboltTwoToneIcon from "@ant-design/icons/ThunderboltTwoTone";
|
|
||||||
import ThunderboltOutlinedIcon from "@ant-design/icons/ThunderboltOutlined";
|
|
||||||
|
|
||||||
import "./DynamicButton.less";
|
import "./DynamicButton.less";
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
function DynamicButton({ options, selectedDynamicValue, onSelect, enabled, staticValueLabel }) {
|
function DynamicButton({ options, selectedDynamicValue, onSelect, enabled }) {
|
||||||
const menu = (
|
const menu = (
|
||||||
<Menu
|
<Menu
|
||||||
className="dynamic-menu"
|
className="dynamic-menu"
|
||||||
@@ -31,8 +28,8 @@ function DynamicButton({ options, selectedDynamicValue, onSelect, enabled, stati
|
|||||||
{enabled && <Menu.Divider />}
|
{enabled && <Menu.Divider />}
|
||||||
{enabled && (
|
{enabled && (
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
<ArrowLeftOutlinedIcon />
|
<Icon type="arrow-left" />
|
||||||
<Text type="secondary">{staticValueLabel}</Text>
|
<Text type="secondary">Back to Static Value</Text>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
</Menu>
|
</Menu>
|
||||||
@@ -48,13 +45,7 @@ function DynamicButton({ options, selectedDynamicValue, onSelect, enabled, stati
|
|||||||
className="dynamic-button"
|
className="dynamic-button"
|
||||||
placement="bottomRight"
|
placement="bottomRight"
|
||||||
trigger={["click"]}
|
trigger={["click"]}
|
||||||
icon={
|
icon={<Icon type="thunderbolt" theme={enabled ? "twoTone" : "outlined"} className="dynamic-icon" />}
|
||||||
enabled ? (
|
|
||||||
<ThunderboltTwoToneIcon className="dynamic-icon" />
|
|
||||||
) : (
|
|
||||||
<ThunderboltOutlinedIcon className="dynamic-icon" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
getPopupContainer={() => containerRef.current}
|
getPopupContainer={() => containerRef.current}
|
||||||
data-test="DynamicButton"
|
data-test="DynamicButton"
|
||||||
/>
|
/>
|
||||||
@@ -68,7 +59,6 @@ DynamicButton.propTypes = {
|
|||||||
selectedDynamicValue: PropTypes.oneOfType([DynamicDateType, DynamicDateRangeType]),
|
selectedDynamicValue: PropTypes.oneOfType([DynamicDateType, DynamicDateRangeType]),
|
||||||
onSelect: PropTypes.func,
|
onSelect: PropTypes.func,
|
||||||
enabled: PropTypes.bool,
|
enabled: PropTypes.bool,
|
||||||
staticValueLabel: PropTypes.string,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
DynamicButton.defaultProps = {
|
DynamicButton.defaultProps = {
|
||||||
@@ -76,7 +66,6 @@ DynamicButton.defaultProps = {
|
|||||||
selectedDynamicValue: null,
|
selectedDynamicValue: null,
|
||||||
onSelect: () => {},
|
onSelect: () => {},
|
||||||
enabled: false,
|
enabled: false,
|
||||||
staticValueLabel: "Back to Static Value",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DynamicButton;
|
export default DynamicButton;
|
||||||
|
|||||||
@@ -34,9 +34,3 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dynamic-icon {
|
|
||||||
display: flex !important;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import PropTypes from "prop-types";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import moment from "moment";
|
|
||||||
import { includes } from "lodash";
|
|
||||||
import { isDynamicDate } from "@/services/parameters/DateParameter";
|
|
||||||
import DateInput from "@/components/DateInput";
|
|
||||||
import DateTimeInput from "@/components/DateTimeInput";
|
|
||||||
import DynamicButton from "@/components/dynamic-parameters/DynamicButton";
|
|
||||||
|
|
||||||
import "./DynamicParameters.less";
|
|
||||||
|
|
||||||
class DynamicDatePicker extends React.Component {
|
|
||||||
static propTypes = {
|
|
||||||
type: PropTypes.string,
|
|
||||||
className: PropTypes.string,
|
|
||||||
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
|
||||||
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
|
||||||
onSelect: PropTypes.func,
|
|
||||||
dynamicButtonOptions: PropTypes.shape({
|
|
||||||
staticValueLabel: PropTypes.string,
|
|
||||||
options: PropTypes.arrayOf(
|
|
||||||
PropTypes.shape({
|
|
||||||
name: PropTypes.string,
|
|
||||||
value: PropTypes.object,
|
|
||||||
label: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
dateOptions: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
|
||||||
};
|
|
||||||
|
|
||||||
static defaultProps = {
|
|
||||||
type: "",
|
|
||||||
className: "",
|
|
||||||
value: null,
|
|
||||||
parameter: null,
|
|
||||||
dynamicButtonOptions: {
|
|
||||||
options: [],
|
|
||||||
},
|
|
||||||
onSelect: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.dateComponentRef = React.createRef();
|
|
||||||
}
|
|
||||||
|
|
||||||
onDynamicValueSelect = dynamicValue => {
|
|
||||||
const { onSelect, parameter } = this.props;
|
|
||||||
if (dynamicValue === "static") {
|
|
||||||
const parameterValue = parameter.getExecutionValue();
|
|
||||||
if (parameterValue) {
|
|
||||||
onSelect(moment(parameterValue));
|
|
||||||
} else {
|
|
||||||
onSelect(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onSelect(dynamicValue.value);
|
|
||||||
}
|
|
||||||
// give focus to the DatePicker to get keyboard shortcuts to work
|
|
||||||
this.dateComponentRef.current.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { type, value, className, dateOptions, dynamicButtonOptions, onSelect } = this.props;
|
|
||||||
const hasDynamicValue = isDynamicDate(value);
|
|
||||||
const isDateTime = includes(type, "datetime");
|
|
||||||
|
|
||||||
const additionalAttributes = {};
|
|
||||||
|
|
||||||
let DateComponent = DateInput;
|
|
||||||
if (isDateTime) {
|
|
||||||
DateComponent = DateTimeInput;
|
|
||||||
if (includes(type, "with-seconds")) {
|
|
||||||
additionalAttributes.withSeconds = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (moment.isMoment(value) || value === null) {
|
|
||||||
additionalAttributes.value = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasDynamicValue) {
|
|
||||||
const dynamicDate = value;
|
|
||||||
additionalAttributes.placeholder = dynamicDate && dynamicDate.name;
|
|
||||||
additionalAttributes.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classNames("date-parameter", className)}>
|
|
||||||
<DateComponent
|
|
||||||
{...dateOptions}
|
|
||||||
ref={this.dateComponentRef}
|
|
||||||
className={classNames("redash-datepicker", type, { "dynamic-value": hasDynamicValue })}
|
|
||||||
onSelect={onSelect}
|
|
||||||
suffixIcon={null}
|
|
||||||
{...additionalAttributes}
|
|
||||||
/>
|
|
||||||
<DynamicButton
|
|
||||||
options={dynamicButtonOptions.options}
|
|
||||||
staticValueLabel={dynamicButtonOptions.staticValueLabel}
|
|
||||||
selectedDynamicValue={hasDynamicValue ? value : null}
|
|
||||||
enabled={hasDynamicValue}
|
|
||||||
onSelect={this.onDynamicValueSelect}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DynamicDatePicker;
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import PropTypes from "prop-types";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import moment from "moment";
|
|
||||||
import { includes, isArray, isObject } from "lodash";
|
|
||||||
import { isDynamicDateRange } from "@/services/parameters/DateRangeParameter";
|
|
||||||
import DateRangeInput from "@/components/DateRangeInput";
|
|
||||||
import DateTimeRangeInput from "@/components/DateTimeRangeInput";
|
|
||||||
import DynamicButton from "@/components/dynamic-parameters/DynamicButton";
|
|
||||||
|
|
||||||
import "./DynamicParameters.less";
|
|
||||||
|
|
||||||
function isValidDateRangeValue(value) {
|
|
||||||
return isArray(value) && value.length === 2 && moment.isMoment(value[0]) && moment.isMoment(value[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
class DynamicDateRangePicker extends React.Component {
|
|
||||||
static propTypes = {
|
|
||||||
type: PropTypes.oneOf(["date-range", "datetime-range", "datetime-range-with-seconds"]).isRequired,
|
|
||||||
className: PropTypes.string,
|
|
||||||
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
|
||||||
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
|
||||||
onSelect: PropTypes.func,
|
|
||||||
dynamicButtonOptions: PropTypes.shape({
|
|
||||||
staticValueLabel: PropTypes.string,
|
|
||||||
options: PropTypes.arrayOf(
|
|
||||||
PropTypes.shape({
|
|
||||||
name: PropTypes.string,
|
|
||||||
value: PropTypes.object,
|
|
||||||
label: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
dateRangeOptions: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
|
||||||
};
|
|
||||||
|
|
||||||
static defaultProps = {
|
|
||||||
type: "date-range",
|
|
||||||
className: "",
|
|
||||||
value: null,
|
|
||||||
parameter: null,
|
|
||||||
dynamicButtonOptions: {
|
|
||||||
options: [],
|
|
||||||
},
|
|
||||||
onSelect: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.dateRangeComponentRef = React.createRef();
|
|
||||||
}
|
|
||||||
|
|
||||||
onDynamicValueSelect = dynamicValue => {
|
|
||||||
const { onSelect, parameter } = this.props;
|
|
||||||
if (dynamicValue === "static") {
|
|
||||||
const parameterValue = parameter.getExecutionValue();
|
|
||||||
if (isObject(parameterValue) && parameterValue.start && parameterValue.end) {
|
|
||||||
onSelect([moment(parameterValue.start), moment(parameterValue.end)]);
|
|
||||||
} else {
|
|
||||||
onSelect(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onSelect(dynamicValue.value);
|
|
||||||
}
|
|
||||||
// give focus to the DatePicker to get keyboard shortcuts to work
|
|
||||||
this.dateRangeComponentRef.current.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { type, value, onSelect, className, dynamicButtonOptions, dateRangeOptions, parameter, ...rest } = this.props;
|
|
||||||
const isDateTimeRange = includes(type, "datetime-range");
|
|
||||||
const hasDynamicValue = isDynamicDateRange(value);
|
|
||||||
|
|
||||||
const additionalAttributes = {};
|
|
||||||
|
|
||||||
let DateRangeComponent = DateRangeInput;
|
|
||||||
if (isDateTimeRange) {
|
|
||||||
DateRangeComponent = DateTimeRangeInput;
|
|
||||||
if (includes(type, "with-seconds")) {
|
|
||||||
additionalAttributes.withSeconds = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isValidDateRangeValue(value) || value === null) {
|
|
||||||
additionalAttributes.value = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasDynamicValue) {
|
|
||||||
additionalAttributes.placeholder = [value && value.name];
|
|
||||||
additionalAttributes.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div {...rest} className={classNames("date-range-parameter", className)}>
|
|
||||||
<DateRangeComponent
|
|
||||||
{...dateRangeOptions}
|
|
||||||
ref={this.dateRangeComponentRef}
|
|
||||||
className={classNames("redash-datepicker date-range-input", type, { "dynamic-value": hasDynamicValue })}
|
|
||||||
onSelect={onSelect}
|
|
||||||
suffixIcon={null}
|
|
||||||
{...additionalAttributes}
|
|
||||||
/>
|
|
||||||
<DynamicButton
|
|
||||||
options={dynamicButtonOptions.options}
|
|
||||||
staticValueLabel={dynamicButtonOptions.staticValueLabel}
|
|
||||||
selectedDynamicValue={hasDynamicValue ? value : null}
|
|
||||||
enabled={hasDynamicValue}
|
|
||||||
onSelect={this.onDynamicValueSelect}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DynamicDateRangePicker;
|
|
||||||
@@ -1,28 +1,8 @@
|
|||||||
@import "../../assets/less/inc/variables";
|
@import '../../assets/less/inc/variables';
|
||||||
|
|
||||||
.date-range-parameter,
|
|
||||||
.date-parameter {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.redash-datepicker {
|
.redash-datepicker {
|
||||||
padding-right: 35px !important;
|
.ant-calendar-picker-clear {
|
||||||
|
right: 35px;
|
||||||
&.date-range {
|
|
||||||
width: 294px;
|
|
||||||
}
|
|
||||||
&.datetime-range {
|
|
||||||
width: 352px;
|
|
||||||
}
|
|
||||||
&.datetime-range-with-seconds {
|
|
||||||
width: 382px;
|
|
||||||
}
|
|
||||||
&.dynamic-value {
|
|
||||||
width: 195px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.ant-picker-range .ant-picker-clear {
|
|
||||||
right: 35px !important;
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,22 +12,19 @@
|
|||||||
|
|
||||||
&.dynamic-value {
|
&.dynamic-value {
|
||||||
& ::placeholder {
|
& ::placeholder {
|
||||||
color: @input-color !important;
|
color: @text-color !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.date-range-input {
|
&.date-range-input {
|
||||||
.ant-picker-active-bar {
|
.ant-calendar-range-picker-input {
|
||||||
opacity: 0;
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-picker-separator,
|
.ant-calendar-range-picker-separator,
|
||||||
.ant-picker-range-separator {
|
.ant-calendar-range-picker-input:not(:first-child) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-picker-input:not(:first-child) {
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,21 +8,13 @@ export interface StepItem<K> {
|
|||||||
node: React.ReactNode;
|
node: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmptyStateHelpMessageProps {
|
|
||||||
helpTriggerType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export declare const EmptyStateHelpMessage: React.FunctionComponent<EmptyStateHelpMessageProps>;
|
|
||||||
|
|
||||||
export interface EmptyStateProps<K = unknown> {
|
export interface EmptyStateProps<K = unknown> {
|
||||||
header?: string;
|
header?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
description: string;
|
description: string;
|
||||||
illustration: string;
|
illustration: string;
|
||||||
illustrationPath?: string;
|
illustrationPath?: string;
|
||||||
helpMessage?: React.ReactNode;
|
helpLink: string;
|
||||||
closable?: boolean;
|
|
||||||
onClose?: () => void;
|
|
||||||
|
|
||||||
onboardingMode?: boolean;
|
onboardingMode?: boolean;
|
||||||
showAlertStep?: boolean;
|
showAlertStep?: boolean;
|
||||||
@@ -41,9 +33,8 @@ export interface StepProps {
|
|||||||
show: boolean;
|
show: boolean;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
url?: string;
|
url?: string;
|
||||||
urlTarget?: string;
|
urlText?: string;
|
||||||
urlText?: React.ReactNode;
|
text: string;
|
||||||
text?: React.ReactNode;
|
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,22 +2,20 @@ import { keys, some } from "lodash";
|
|||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
|
|
||||||
import Link from "@/components/Link";
|
import Link from "@/components/Link";
|
||||||
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
|
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
|
||||||
import HelpTrigger from "@/components/HelpTrigger";
|
|
||||||
import { currentUser } from "@/services/auth";
|
import { currentUser } from "@/services/auth";
|
||||||
import organizationStatus from "@/services/organizationStatus";
|
import organizationStatus from "@/services/organizationStatus";
|
||||||
import "./empty-state.less";
|
import "./empty-state.less";
|
||||||
|
|
||||||
export function Step({ show, completed, text, url, urlTarget, urlText, onClick }) {
|
export function Step({ show, completed, text, url, urlText, onClick }) {
|
||||||
if (!show) {
|
if (!show) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={classNames({ done: completed })}>
|
<li className={classNames({ done: completed })}>
|
||||||
<Link href={url} onClick={onClick} target={urlTarget}>
|
<Link href={url} onClick={onClick}>
|
||||||
{urlText}
|
{urlText}
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
{text}
|
{text}
|
||||||
@@ -28,44 +26,24 @@ export function Step({ show, completed, text, url, urlTarget, urlText, onClick }
|
|||||||
Step.propTypes = {
|
Step.propTypes = {
|
||||||
show: PropTypes.bool.isRequired,
|
show: PropTypes.bool.isRequired,
|
||||||
completed: PropTypes.bool.isRequired,
|
completed: PropTypes.bool.isRequired,
|
||||||
text: PropTypes.node,
|
text: PropTypes.string.isRequired,
|
||||||
url: PropTypes.string,
|
url: PropTypes.string,
|
||||||
urlTarget: PropTypes.string,
|
urlText: PropTypes.string,
|
||||||
urlText: PropTypes.node,
|
|
||||||
onClick: PropTypes.func,
|
onClick: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
Step.defaultProps = {
|
Step.defaultProps = {
|
||||||
url: null,
|
url: null,
|
||||||
urlTarget: null,
|
|
||||||
urlText: null,
|
urlText: null,
|
||||||
text: null,
|
|
||||||
onClick: null,
|
onClick: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function EmptyStateHelpMessage({ helpTriggerType }) {
|
|
||||||
return (
|
|
||||||
<p>
|
|
||||||
Need more support?{" "}
|
|
||||||
<HelpTrigger className="f-14" type={helpTriggerType} showTooltip={false}>
|
|
||||||
See our Help
|
|
||||||
</HelpTrigger>
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
EmptyStateHelpMessage.propTypes = {
|
|
||||||
helpTriggerType: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
function EmptyState({
|
function EmptyState({
|
||||||
icon,
|
icon,
|
||||||
header,
|
header,
|
||||||
description,
|
description,
|
||||||
illustration,
|
illustration,
|
||||||
helpMessage,
|
helpLink,
|
||||||
closable,
|
|
||||||
onClose,
|
|
||||||
onboardingMode,
|
onboardingMode,
|
||||||
showAlertStep,
|
showAlertStep,
|
||||||
showDashboardStep,
|
showDashboardStep,
|
||||||
@@ -109,7 +87,8 @@ function EmptyState({
|
|||||||
show={isAvailable.dataSource}
|
show={isAvailable.dataSource}
|
||||||
completed={isCompleted.dataSource}
|
completed={isCompleted.dataSource}
|
||||||
url="data_sources/new"
|
url="data_sources/new"
|
||||||
urlText="Connect a Data Source"
|
urlText="Connect"
|
||||||
|
text="a Data Source"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -137,7 +116,8 @@ function EmptyState({
|
|||||||
show={isAvailable.query}
|
show={isAvailable.query}
|
||||||
completed={isCompleted.query}
|
completed={isCompleted.query}
|
||||||
url="queries/new"
|
url="queries/new"
|
||||||
urlText="Create your first Query"
|
urlText="Create"
|
||||||
|
text="your first Query"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -149,7 +129,8 @@ function EmptyState({
|
|||||||
show={isAvailable.alert}
|
show={isAvailable.alert}
|
||||||
completed={isCompleted.alert}
|
completed={isCompleted.alert}
|
||||||
url="alerts/new"
|
url="alerts/new"
|
||||||
urlText="Create your first Alert"
|
urlText="Create"
|
||||||
|
text="your first Alert"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -161,7 +142,8 @@ function EmptyState({
|
|||||||
show={isAvailable.dashboard}
|
show={isAvailable.dashboard}
|
||||||
completed={isCompleted.dashboard}
|
completed={isCompleted.dashboard}
|
||||||
onClick={showCreateDashboardDialog}
|
onClick={showCreateDashboardDialog}
|
||||||
urlText="Create your first Dashboard"
|
urlText="Create"
|
||||||
|
text="your first Dashboard"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -173,7 +155,8 @@ function EmptyState({
|
|||||||
show={isAvailable.inviteUsers}
|
show={isAvailable.inviteUsers}
|
||||||
completed={isCompleted.inviteUsers}
|
completed={isCompleted.inviteUsers}
|
||||||
url="users/new"
|
url="users/new"
|
||||||
urlText="Invite your team members"
|
urlText="Invite"
|
||||||
|
text="your team members"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -183,27 +166,26 @@ function EmptyState({
|
|||||||
const imageSource = illustrationPath ? illustrationPath : "static/images/illustrations/" + illustration + ".svg";
|
const imageSource = illustrationPath ? illustrationPath : "static/images/illustrations/" + illustration + ".svg";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="empty-state-wrapper">
|
<div className="empty-state bg-white tiled">
|
||||||
<div className="empty-state bg-white tiled">
|
<div className="empty-state__summary">
|
||||||
<div className="empty-state__summary">
|
{header && <h4>{header}</h4>}
|
||||||
{header && <h4>{header}</h4>}
|
<h2>
|
||||||
<h2>
|
<i className={icon} />
|
||||||
<i className={icon} />
|
</h2>
|
||||||
</h2>
|
<p>{description}</p>
|
||||||
<p>{description}</p>
|
<img src={imageSource} alt={illustration + " Illustration"} width="75%" />
|
||||||
<img src={imageSource} alt={illustration + " Illustration"} width="75%" />
|
</div>
|
||||||
</div>
|
<div className="empty-state__steps">
|
||||||
<div className="empty-state__steps">
|
<h4>Let's get started</h4>
|
||||||
<h4>Let's get started</h4>
|
<ol>{stepsItems.map(item => item.node)}</ol>
|
||||||
<ol>{stepsItems.map(item => item.node)}</ol>
|
<p>
|
||||||
{helpMessage}
|
Need more support?{" "}
|
||||||
</div>
|
<Link href={helpLink} target="_blank" rel="noopener noreferrer">
|
||||||
|
See our Help
|
||||||
|
<i className="fa fa-external-link m-l-5" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{closable && (
|
|
||||||
<a className="close-button" onClick={onClose}>
|
|
||||||
<CloseOutlinedIcon />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -214,9 +196,7 @@ EmptyState.propTypes = {
|
|||||||
description: PropTypes.string.isRequired,
|
description: PropTypes.string.isRequired,
|
||||||
illustration: PropTypes.string.isRequired,
|
illustration: PropTypes.string.isRequired,
|
||||||
illustrationPath: PropTypes.string,
|
illustrationPath: PropTypes.string,
|
||||||
helpMessage: PropTypes.node,
|
helpLink: PropTypes.string.isRequired,
|
||||||
closable: PropTypes.bool,
|
|
||||||
onClose: PropTypes.func,
|
|
||||||
|
|
||||||
onboardingMode: PropTypes.bool,
|
onboardingMode: PropTypes.bool,
|
||||||
showAlertStep: PropTypes.bool,
|
showAlertStep: PropTypes.bool,
|
||||||
@@ -230,9 +210,6 @@ EmptyState.propTypes = {
|
|||||||
EmptyState.defaultProps = {
|
EmptyState.defaultProps = {
|
||||||
icon: null,
|
icon: null,
|
||||||
header: null,
|
header: null,
|
||||||
helpMessage: null,
|
|
||||||
closable: false,
|
|
||||||
onClose: () => {},
|
|
||||||
|
|
||||||
onboardingMode: false,
|
onboardingMode: false,
|
||||||
showAlertStep: false,
|
showAlertStep: false,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
@import (reference, less) "~@/assets/less/ant";
|
|
||||||
|
|
||||||
// Empty states
|
// Empty states
|
||||||
.empty-state {
|
.empty-state {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -21,14 +19,11 @@
|
|||||||
padding-left: 0px;
|
padding-left: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.empty-state__summary {
|
.empty-state__summary {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: rgba(102, 136, 153, 0.025);
|
background: rgba(102, 136, 153, 0.025);
|
||||||
|
|
||||||
p {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ol {
|
ol {
|
||||||
@@ -49,6 +44,10 @@
|
|||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
a:hover {
|
a:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -72,22 +71,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// close button
|
|
||||||
.empty-state-wrapper {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.close-button {
|
|
||||||
position: absolute;
|
|
||||||
top: 15px;
|
|
||||||
right: 25px;
|
|
||||||
font-size: 15px;
|
|
||||||
color: @text-color-secondary;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color @animation-duration-slow;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: @text-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -67,10 +67,10 @@ export const Columns = {
|
|||||||
overrides
|
overrides
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
timeAgo(overrides, timeAgoCustomProps = undefined) {
|
timeAgo(overrides) {
|
||||||
return extend(
|
return extend(
|
||||||
{
|
{
|
||||||
render: value => <TimeAgo date={value} {...timeAgoCustomProps} />,
|
render: value => <TimeAgo date={value} />,
|
||||||
},
|
},
|
||||||
overrides
|
overrides
|
||||||
);
|
);
|
||||||
@@ -110,8 +110,6 @@ export default class ItemsTable extends React.Component {
|
|||||||
orderByField: PropTypes.string,
|
orderByField: PropTypes.string,
|
||||||
orderByReverse: PropTypes.bool,
|
orderByReverse: PropTypes.bool,
|
||||||
toggleSorting: PropTypes.func,
|
toggleSorting: PropTypes.func,
|
||||||
"data-test": PropTypes.string,
|
|
||||||
rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
@@ -144,7 +142,7 @@ export default class ItemsTable extends React.Component {
|
|||||||
|
|
||||||
return extend(omit(column, ["field", "orderByField", "render"]), {
|
return extend(omit(column, ["field", "orderByField", "render"]), {
|
||||||
key: "column" + index,
|
key: "column" + index,
|
||||||
dataIndex: ["item", column.field],
|
dataIndex: "item[" + JSON.stringify(column.field) + "]",
|
||||||
defaultSortOrder: column.orderByField === orderByField ? orderByDirection : null,
|
defaultSortOrder: column.orderByField === orderByField ? orderByDirection : null,
|
||||||
onHeaderCell,
|
onHeaderCell,
|
||||||
render,
|
render,
|
||||||
@@ -153,17 +151,6 @@ export default class ItemsTable extends React.Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getRowKey = record => {
|
|
||||||
const { rowKey } = this.props;
|
|
||||||
if (rowKey) {
|
|
||||||
if (isFunction(rowKey)) {
|
|
||||||
return rowKey(record.item);
|
|
||||||
}
|
|
||||||
return record.item[rowKey];
|
|
||||||
}
|
|
||||||
return record.key;
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const tableDataProps = {
|
const tableDataProps = {
|
||||||
columns: this.prepareColumns(),
|
columns: this.prepareColumns(),
|
||||||
@@ -197,10 +184,9 @@ export default class ItemsTable extends React.Component {
|
|||||||
<Table
|
<Table
|
||||||
className={classNames("table-data", { "ant-table-headerless": !showHeader })}
|
className={classNames("table-data", { "ant-table-headerless": !showHeader })}
|
||||||
showHeader={showHeader}
|
showHeader={showHeader}
|
||||||
rowKey={this.getRowKey}
|
rowKey={row => row.key}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
onRow={onTableRow}
|
onRow={onTableRow}
|
||||||
data-test={this.props["data-test"]}
|
|
||||||
{...tableDataProps}
|
{...tableDataProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -132,13 +132,13 @@ ProfileImage.propTypes = {
|
|||||||
Tags
|
Tags
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function Tags({ url, onChange, showUnselectAll }) {
|
export function Tags({ url, onChange }) {
|
||||||
if (url === "") {
|
if (url === "") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="m-b-10">
|
<div className="m-b-10">
|
||||||
<TagsList tagsUrl={url} onUpdate={onChange} showUnselectAll={showUnselectAll} />
|
<TagsList tagsUrl={url} onUpdate={onChange} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -146,6 +146,4 @@ export function Tags({ url, onChange, showUnselectAll }) {
|
|||||||
Tags.propTypes = {
|
Tags.propTypes = {
|
||||||
url: PropTypes.string.isRequired,
|
url: PropTypes.string.isRequired,
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
showUnselectAll: PropTypes.bool,
|
|
||||||
unselectAllButtonTitle: PropTypes.string,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { filter, includes, intersection } from "lodash";
|
|
||||||
import React, { useState, useMemo, useEffect, useCallback } from "react";
|
|
||||||
import Checkbox from "antd/lib/checkbox";
|
|
||||||
import { Columns } from "../components/ItemsTable";
|
|
||||||
|
|
||||||
export default function useItemsListExtraActions(controller, listColumns, ExtraActionsComponent) {
|
|
||||||
const [actionsState, setActionsState] = useState({ isAvailable: false });
|
|
||||||
const [selectedItems, setSelectedItems] = useState([]);
|
|
||||||
|
|
||||||
// clear selection when page changes
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedItems([]);
|
|
||||||
}, [controller.pageItems, actionsState.isAvailable]);
|
|
||||||
|
|
||||||
const areAllItemsSelected = useMemo(() => {
|
|
||||||
const allItems = controller.pageItems;
|
|
||||||
if (allItems.length === 0 || selectedItems.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return intersection(selectedItems, allItems).length === allItems.length;
|
|
||||||
}, [selectedItems, controller.pageItems]);
|
|
||||||
|
|
||||||
const toggleAllItems = useCallback(() => {
|
|
||||||
if (areAllItemsSelected) {
|
|
||||||
setSelectedItems([]);
|
|
||||||
} else {
|
|
||||||
setSelectedItems(controller.pageItems);
|
|
||||||
}
|
|
||||||
}, [areAllItemsSelected, controller.pageItems]);
|
|
||||||
|
|
||||||
const toggleItem = useCallback(
|
|
||||||
item => {
|
|
||||||
if (includes(selectedItems, item)) {
|
|
||||||
setSelectedItems(filter(selectedItems, s => s !== item));
|
|
||||||
} else {
|
|
||||||
setSelectedItems([...selectedItems, item]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[selectedItems]
|
|
||||||
);
|
|
||||||
|
|
||||||
const checkboxColumn = useMemo(
|
|
||||||
() =>
|
|
||||||
Columns.custom(
|
|
||||||
(text, item) => <Checkbox checked={includes(selectedItems, item)} onChange={() => toggleItem(item)} />,
|
|
||||||
{
|
|
||||||
title: () => <Checkbox checked={areAllItemsSelected} onChange={toggleAllItems} />,
|
|
||||||
field: "id",
|
|
||||||
width: "1%",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
[selectedItems, areAllItemsSelected, toggleAllItems, toggleItem]
|
|
||||||
);
|
|
||||||
|
|
||||||
const Component = useCallback(
|
|
||||||
function ItemsListExtraActionsComponentWrapper(props) {
|
|
||||||
// this check mostly needed to avoid eslint exhaustive deps warning
|
|
||||||
if (!ExtraActionsComponent) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <ExtraActionsComponent onStateChange={setActionsState} {...props} />;
|
|
||||||
},
|
|
||||||
[ExtraActionsComponent]
|
|
||||||
);
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
areExtraActionsAvailable: actionsState.isAvailable,
|
|
||||||
listColumns: actionsState.isAvailable ? [checkboxColumn, ...listColumns] : listColumns,
|
|
||||||
Component,
|
|
||||||
selectedItems,
|
|
||||||
setSelectedItems,
|
|
||||||
}),
|
|
||||||
[actionsState, listColumns, checkboxColumn, selectedItems, Component]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -31,6 +31,53 @@ export const RefreshScheduleDefault = {
|
|||||||
until: null,
|
until: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Field = PropTypes.shape({
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
title: PropTypes.string,
|
||||||
|
type: PropTypes.oneOf([
|
||||||
|
"ace",
|
||||||
|
"text",
|
||||||
|
"textarea",
|
||||||
|
"email",
|
||||||
|
"password",
|
||||||
|
"number",
|
||||||
|
"checkbox",
|
||||||
|
"file",
|
||||||
|
"select",
|
||||||
|
"content",
|
||||||
|
]).isRequired,
|
||||||
|
initialValue: PropTypes.oneOfType([
|
||||||
|
PropTypes.string,
|
||||||
|
PropTypes.number,
|
||||||
|
PropTypes.bool,
|
||||||
|
PropTypes.arrayOf(PropTypes.string),
|
||||||
|
PropTypes.arrayOf(PropTypes.number),
|
||||||
|
]),
|
||||||
|
content: PropTypes.node,
|
||||||
|
mode: PropTypes.string,
|
||||||
|
required: PropTypes.bool,
|
||||||
|
extra: PropTypes.bool,
|
||||||
|
readOnly: PropTypes.bool,
|
||||||
|
autoFocus: PropTypes.bool,
|
||||||
|
minLength: PropTypes.number,
|
||||||
|
placeholder: PropTypes.string,
|
||||||
|
contentAfter: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
|
||||||
|
loading: PropTypes.bool,
|
||||||
|
props: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Action = PropTypes.shape({
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
callback: PropTypes.func.isRequired,
|
||||||
|
type: PropTypes.string,
|
||||||
|
pullRight: PropTypes.bool,
|
||||||
|
disabledWhenDirty: PropTypes.bool,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AntdForm = PropTypes.shape({
|
||||||
|
validateFieldsAndScroll: PropTypes.func,
|
||||||
|
});
|
||||||
|
|
||||||
export const UserProfile = PropTypes.shape({
|
export const UserProfile = PropTypes.shape({
|
||||||
id: PropTypes.number.isRequired,
|
id: PropTypes.number.isRequired,
|
||||||
name: PropTypes.string.isRequired,
|
name: PropTypes.string.isRequired,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Modal from "antd/lib/modal";
|
|||||||
import Input from "antd/lib/input";
|
import Input from "antd/lib/input";
|
||||||
import List from "antd/lib/list";
|
import List from "antd/lib/list";
|
||||||
import Link from "@/components/Link";
|
import Link from "@/components/Link";
|
||||||
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
|
import Icon from "antd/lib/icon";
|
||||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||||
import { QueryTagsControl } from "@/components/tags-control/TagsControl";
|
import { QueryTagsControl } from "@/components/tags-control/TagsControl";
|
||||||
import { Dashboard } from "@/services/dashboard";
|
import { Dashboard } from "@/services/dashboard";
|
||||||
@@ -89,7 +89,7 @@ function AddToDashboardDialog({ dialog, visualization }) {
|
|||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={event => setSearchTerm(event.target.value)}
|
onChange={event => setSearchTerm(event.target.value)}
|
||||||
suffix={
|
suffix={
|
||||||
<CloseOutlinedIcon className={searchTerm === "" ? "hidden" : null} onClick={() => setSearchTerm("")} />
|
<Icon type="close" className={searchTerm === "" ? "hidden" : null} onClick={() => setSearchTerm("")} />
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -104,7 +104,7 @@ function AddToDashboardDialog({ dialog, visualization }) {
|
|||||||
renderItem={d => (
|
renderItem={d => (
|
||||||
<List.Item
|
<List.Item
|
||||||
key={`dashboard-${d.id}`}
|
key={`dashboard-${d.id}`}
|
||||||
actions={selectedDashboard ? [<CloseOutlinedIcon onClick={() => setSelectedDashboard(null)} />] : []}
|
actions={selectedDashboard ? [<Icon type="close" onClick={() => setSelectedDashboard(null)} />] : []}
|
||||||
onClick={selectedDashboard ? null : () => setSelectedDashboard(d)}>
|
onClick={selectedDashboard ? null : () => setSelectedDashboard(d)}>
|
||||||
<div className="add-to-dashboard-dialog-item-content">
|
<div className="add-to-dashboard-dialog-item-content">
|
||||||
{d.name}
|
{d.name}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { clientConfig } from "@/services/auth";
|
|||||||
import notification from "@/services/notification";
|
import notification from "@/services/notification";
|
||||||
|
|
||||||
import "./index.less";
|
import "./index.less";
|
||||||
import { policy } from "@/services/policy";
|
|
||||||
|
|
||||||
function ApiKeyDialog({ dialog, ...props }) {
|
function ApiKeyDialog({ dialog, ...props }) {
|
||||||
const [query, setQuery] = useState(props.query);
|
const [query, setQuery] = useState(props.query);
|
||||||
@@ -46,7 +45,7 @@ function ApiKeyDialog({ dialog, ...props }) {
|
|||||||
<div className="m-b-20">
|
<div className="m-b-20">
|
||||||
<Input.Group compact>
|
<Input.Group compact>
|
||||||
<Input readOnly value={query.api_key} />
|
<Input readOnly value={query.api_key} />
|
||||||
{policy.canEdit(query) && (
|
{query.can_edit && (
|
||||||
<Button disabled={updatingApiKey} loading={updatingApiKey} onClick={regenerateQueryApiKey}>
|
<Button disabled={updatingApiKey} loading={updatingApiKey} onClick={regenerateQueryApiKey}>
|
||||||
Regenerate
|
Regenerate
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import React, { useCallback } from "react";
|
|
||||||
import PropTypes from "prop-types";
|
|
||||||
import recordEvent from "@/services/recordEvent";
|
|
||||||
import Checkbox from "antd/lib/checkbox";
|
|
||||||
import Tooltip from "antd/lib/tooltip";
|
|
||||||
|
|
||||||
export default function AutoLimitCheckbox({ available, checked, onChange }) {
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
recordEvent("checkbox_auto_limit", "screen", "query_editor", { state: !checked });
|
|
||||||
onChange(!checked);
|
|
||||||
}, [checked, onChange]);
|
|
||||||
|
|
||||||
let tooltipMessage = null;
|
|
||||||
if (!available) {
|
|
||||||
tooltipMessage = "Auto limiting is not available for this Data Source type.";
|
|
||||||
} else {
|
|
||||||
tooltipMessage = "Auto limit results to first 1000 rows.";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip placement="top" title={tooltipMessage}>
|
|
||||||
<Checkbox
|
|
||||||
className="query-editor-controls-checkbox"
|
|
||||||
disabled={!available}
|
|
||||||
onClick={handleClick}
|
|
||||||
checked={available && checked}>
|
|
||||||
LIMIT 1000
|
|
||||||
</Checkbox>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
AutoLimitCheckbox.propTypes = {
|
|
||||||
available: PropTypes.bool,
|
|
||||||
checked: PropTypes.bool.isRequired,
|
|
||||||
onChange: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
@@ -8,7 +8,6 @@ import KeyboardShortcuts, { humanReadableShortcut } from "@/services/KeyboardSho
|
|||||||
|
|
||||||
import AutocompleteToggle from "./AutocompleteToggle";
|
import AutocompleteToggle from "./AutocompleteToggle";
|
||||||
import "./QueryEditorControls.less";
|
import "./QueryEditorControls.less";
|
||||||
import AutoLimitCheckbox from "@/components/queries/QueryEditor/AutoLimitCheckbox";
|
|
||||||
|
|
||||||
export function ButtonTooltip({ title, shortcut, ...props }) {
|
export function ButtonTooltip({ title, shortcut, ...props }) {
|
||||||
shortcut = humanReadableShortcut(shortcut, 1); // show only primary shortcut
|
shortcut = humanReadableShortcut(shortcut, 1); // show only primary shortcut
|
||||||
@@ -39,7 +38,6 @@ export default function EditorControl({
|
|||||||
saveButtonProps,
|
saveButtonProps,
|
||||||
executeButtonProps,
|
executeButtonProps,
|
||||||
autocompleteToggleProps,
|
autocompleteToggleProps,
|
||||||
autoLimitCheckboxProps,
|
|
||||||
dataSourceSelectorProps,
|
dataSourceSelectorProps,
|
||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -86,7 +84,6 @@ export default function EditorControl({
|
|||||||
onToggle={autocompleteToggleProps.onToggle}
|
onToggle={autocompleteToggleProps.onToggle}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{autoLimitCheckboxProps !== false && <AutoLimitCheckbox {...autoLimitCheckboxProps} />}
|
|
||||||
{dataSourceSelectorProps === false && <span className="query-editor-controls-spacer" />}
|
{dataSourceSelectorProps === false && <span className="query-editor-controls-spacer" />}
|
||||||
{dataSourceSelectorProps !== false && (
|
{dataSourceSelectorProps !== false && (
|
||||||
<Select
|
<Select
|
||||||
@@ -156,10 +153,6 @@ EditorControl.propTypes = {
|
|||||||
onToggle: PropTypes.func,
|
onToggle: PropTypes.func,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
autoLimitCheckboxProps: PropTypes.oneOfType([
|
|
||||||
PropTypes.bool, // `false` to hide
|
|
||||||
PropTypes.shape(AutoLimitCheckbox.propTypes),
|
|
||||||
]),
|
|
||||||
dataSourceSelectorProps: PropTypes.oneOfType([
|
dataSourceSelectorProps: PropTypes.oneOfType([
|
||||||
PropTypes.bool, // `false` to hide
|
PropTypes.bool, // `false` to hide
|
||||||
PropTypes.shape({
|
PropTypes.shape({
|
||||||
@@ -182,6 +175,5 @@ EditorControl.defaultProps = {
|
|||||||
saveButtonProps: false,
|
saveButtonProps: false,
|
||||||
executeButtonProps: false,
|
executeButtonProps: false,
|
||||||
autocompleteToggleProps: false,
|
autocompleteToggleProps: false,
|
||||||
autoLimitCheckboxProps: false,
|
|
||||||
dataSourceSelectorProps: false,
|
dataSourceSelectorProps: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,12 +21,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.query-editor-controls-checkbox {
|
|
||||||
display: inline-block;
|
|
||||||
white-space: nowrap;
|
|
||||||
margin: auto 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.query-editor-controls-spacer {
|
.query-editor-controls-spacer {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
height: 35px; // same as Antd <Select>
|
height: 35px; // same as Antd <Select>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import DatePicker from "antd/lib/date-picker";
|
|||||||
import TimePicker from "antd/lib/time-picker";
|
import TimePicker from "antd/lib/time-picker";
|
||||||
import Select from "antd/lib/select";
|
import Select from "antd/lib/select";
|
||||||
import Radio from "antd/lib/radio";
|
import Radio from "antd/lib/radio";
|
||||||
import { capitalize, clone, isEqual, omitBy, isNil, isEmpty } from "lodash";
|
import { capitalize, clone, isEqual, omitBy, isNil } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { secondsToInterval, durationHumanize, pluralize, IntervalEnum, localizeTime } from "@/lib/utils";
|
import { secondsToInterval, durationHumanize, pluralize, IntervalEnum, localizeTime } from "@/lib/utils";
|
||||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||||
@@ -207,17 +207,15 @@ class ScheduleDialog extends React.Component {
|
|||||||
<Option value={null} key="never">
|
<Option value={null} key="never">
|
||||||
Never
|
Never
|
||||||
</Option>
|
</Option>
|
||||||
{Object.keys(this.intervals)
|
{Object.keys(this.intervals).map(int => (
|
||||||
.filter(int => !isEmpty(this.intervals[int]))
|
<OptGroup label={capitalize(pluralize(int))} key={int}>
|
||||||
.map(int => (
|
{this.intervals[int].map(([cnt, secs]) => (
|
||||||
<OptGroup label={capitalize(pluralize(int))} key={int}>
|
<Option value={secs} key={cnt}>
|
||||||
{this.intervals[int].map(([cnt, secs]) => (
|
{durationHumanize(secs)}
|
||||||
<Option value={secs} key={`${int}-${cnt}`}>
|
</Option>
|
||||||
{durationHumanize(secs)}
|
))}
|
||||||
</Option>
|
</OptGroup>
|
||||||
))}
|
))}
|
||||||
</OptGroup>
|
|
||||||
))}
|
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -120,36 +120,27 @@ describe("ScheduleDialog", () => {
|
|||||||
expect(utc.exists()).toBeFalsy();
|
expect(utc.exists()).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Disabling this test as the TimePicker wasn't setting values from here after Antd v4
|
test("onChange correct result", () => {
|
||||||
// eslint-disable-next-line jest/no-disabled-tests
|
|
||||||
test.skip("onChange correct result", () => {
|
|
||||||
const onChangeCb = jest.fn(time => time.format("HH:mm"));
|
const onChangeCb = jest.fn(time => time.format("HH:mm"));
|
||||||
const editor = mount(<TimeEditor onChange={onChangeCb} />);
|
const editor = mount(<TimeEditor onChange={onChangeCb} />);
|
||||||
|
|
||||||
// click TimePicker
|
// click TimePicker
|
||||||
editor.find(".ant-picker-input input").simulate("mouseDown");
|
editor.find(".ant-time-picker-input").simulate("click");
|
||||||
|
|
||||||
const timePickerPanel = editor.find(".ant-picker-panel");
|
|
||||||
|
|
||||||
// select hour "07"
|
// select hour "07"
|
||||||
const hourSelector = timePickerPanel.find(".ant-picker-time-panel-column").at(0);
|
const hourSelector = editor.find(".ant-time-picker-panel-select").at(0);
|
||||||
hourSelector
|
hourSelector
|
||||||
.find("li")
|
.find("li")
|
||||||
.at(7)
|
.at(7)
|
||||||
.simulate("click");
|
.simulate("click");
|
||||||
|
|
||||||
// select minute "30"
|
// select minute "30"
|
||||||
const minuteSelector = timePickerPanel.find(".ant-picker-time-panel-column").at(1);
|
const minuteSelector = editor.find(".ant-time-picker-panel-select").at(1);
|
||||||
minuteSelector
|
minuteSelector
|
||||||
.find("li")
|
.find("li")
|
||||||
.at(6)
|
.at(6)
|
||||||
.simulate("click");
|
.simulate("click");
|
||||||
|
|
||||||
timePickerPanel
|
|
||||||
.find(".ant-picker-ok")
|
|
||||||
.find("button")
|
|
||||||
.simulate("mouseDown");
|
|
||||||
|
|
||||||
// expect utc to be 2h below initial time
|
// expect utc to be 2h below initial time
|
||||||
const utc = findByTestID(editor, "utc");
|
const utc = findByTestID(editor, "utc");
|
||||||
expect(utc.text()).toBe("(05:30 UTC)");
|
expect(utc.text()).toBe("(05:30 UTC)");
|
||||||
@@ -222,7 +213,7 @@ describe("ScheduleDialog", () => {
|
|||||||
.find("Trigger")
|
.find("Trigger")
|
||||||
.instance()
|
.instance()
|
||||||
.getComponent()
|
.getComponent()
|
||||||
).find(".ant-select-item-option-content");
|
).find("MenuItem");
|
||||||
|
|
||||||
const texts = options.map(node => node.text());
|
const texts = options.map(node => node.text());
|
||||||
const expected = ["Never", "1 minute", "5 minutes", "1 hour", "2 hours"];
|
const expected = ["Never", "1 minute", "5 minutes", "1 hour", "2 hours"];
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export default class SchedulePhrase extends React.Component {
|
|||||||
const content = full ? <Tooltip title={full}>{short}</Tooltip> : short;
|
const content = full ? <Tooltip title={full}>{short}</Tooltip> : short;
|
||||||
|
|
||||||
return this.props.isLink ? (
|
return this.props.isLink ? (
|
||||||
<a className="schedule-phrase" onClick={this.props.onClick} data-test="EditSchedule">
|
<a className="schedule-phrase" onClick={this.props.onClick}>
|
||||||
{content}
|
{content}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -41,24 +41,19 @@ function SchemaItem({ item, expanded, onToggle, onSelect, ...props }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tableDisplayName = item.displayName || item.name;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...props}>
|
<div {...props}>
|
||||||
<div className="table-name" onClick={onToggle}>
|
<div className="table-name" onClick={onToggle}>
|
||||||
<i className="fa fa-table m-r-5" />
|
<i className="fa fa-table m-r-5" />
|
||||||
<strong>
|
<strong>
|
||||||
<span title={item.name}>{tableDisplayName}</span>
|
<span title={item.name}>{item.name}</span>
|
||||||
{!isNil(item.size) && <span> ({item.size})</span>}
|
{!isNil(item.size) && <span> ({item.size})</span>}
|
||||||
</strong>
|
</strong>
|
||||||
|
<i
|
||||||
<Tooltip title="Insert table name into query text" mouseEnterDelay={0} mouseLeaveDelay={0}>
|
className="fa fa-angle-double-right copy-to-editor"
|
||||||
<i
|
aria-hidden="true"
|
||||||
className="fa fa-angle-double-right copy-to-editor"
|
onClick={e => handleSelect(e, item.name)}
|
||||||
aria-hidden="true"
|
/>
|
||||||
onClick={e => handleSelect(e, item.name)}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div>
|
<div>
|
||||||
@@ -71,13 +66,11 @@ function SchemaItem({ item, expanded, onToggle, onSelect, ...props }) {
|
|||||||
return (
|
return (
|
||||||
<div key={columnName} className="table-open">
|
<div key={columnName} className="table-open">
|
||||||
{columnName} {columnType && <span className="column-type">{columnType}</span>}
|
{columnName} {columnType && <span className="column-type">{columnType}</span>}
|
||||||
<Tooltip title="Insert column name into query text" mouseEnterDelay={0} mouseLeaveDelay={0}>
|
<i
|
||||||
<i
|
className="fa fa-angle-double-right copy-to-editor"
|
||||||
className="fa fa-angle-double-right copy-to-editor"
|
aria-hidden="true"
|
||||||
aria-hidden="true"
|
onClick={e => handleSelect(e, columnName)}
|
||||||
onClick={e => handleSelect(e, columnName)}
|
/>
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
|||||||
import React, { useState, useMemo, useEffect, useCallback } from "react";
|
import React, { useState, useMemo, useEffect, useCallback } from "react";
|
||||||
import { filter, includes, get, find } from "lodash";
|
import { slice, without, filter, includes, get, find } from "lodash";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import SyncOutlinedIcon from "@ant-design/icons/SyncOutlined";
|
import Icon from "antd/lib/icon";
|
||||||
import Input from "antd/lib/input";
|
import Input from "antd/lib/input";
|
||||||
import Select from "antd/lib/select";
|
import Select from "antd/lib/select";
|
||||||
import Tooltip from "antd/lib/tooltip";
|
import Tooltip from "antd/lib/tooltip";
|
||||||
@@ -13,6 +13,13 @@ import useDatabricksSchema from "./useDatabricksSchema";
|
|||||||
|
|
||||||
import "./DatabricksSchemaBrowser.less";
|
import "./DatabricksSchemaBrowser.less";
|
||||||
|
|
||||||
|
// Limit number of rendered options to improve performance until Antd v4
|
||||||
|
function getLimitedDatabases(databases, currentDatabaseName, limit = 1000) {
|
||||||
|
const limitedDatabases = slice(without(databases, currentDatabaseName), 0, limit);
|
||||||
|
|
||||||
|
return currentDatabaseName ? [...limitedDatabases, currentDatabaseName].sort() : limitedDatabases;
|
||||||
|
}
|
||||||
|
|
||||||
export default function DatabricksSchemaBrowser({
|
export default function DatabricksSchemaBrowser({
|
||||||
dataSource,
|
dataSource,
|
||||||
options,
|
options,
|
||||||
@@ -56,6 +63,10 @@ export default function DatabricksSchemaBrowser({
|
|||||||
() => filter(databases, database => includes(database.toLowerCase(), databaseFilterString.toLowerCase())),
|
() => filter(databases, database => includes(database.toLowerCase(), databaseFilterString.toLowerCase())),
|
||||||
[databases, databaseFilterString]
|
[databases, databaseFilterString]
|
||||||
);
|
);
|
||||||
|
const limitedDatabases = useMemo(() => getLimitedDatabases(filteredDatabases, currentDatabaseName), [
|
||||||
|
filteredDatabases,
|
||||||
|
currentDatabaseName,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSchemaUpdate = useImmutableCallback(onSchemaUpdate);
|
const handleSchemaUpdate = useImmutableCallback(onSchemaUpdate);
|
||||||
|
|
||||||
@@ -67,6 +78,10 @@ export default function DatabricksSchemaBrowser({
|
|||||||
setExpandedFlags({});
|
setExpandedFlags({});
|
||||||
}, [currentDatabaseName]);
|
}, [currentDatabaseName]);
|
||||||
|
|
||||||
|
if (schema.length === 0 && databases.length === 0 && !(loadingDatabases || loadingSchema)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function toggleTable(tableName) {
|
function toggleTable(tableName) {
|
||||||
const table = find(schema, { name: tableName });
|
const table = find(schema, { name: tableName });
|
||||||
if (!expandedFlags[tableName] && get(table, "loading", false)) {
|
if (!expandedFlags[tableName] && get(table, "loading", false)) {
|
||||||
@@ -101,12 +116,17 @@ export default function DatabricksSchemaBrowser({
|
|||||||
<i className="fa fa-database m-r-5" /> Database
|
<i className="fa fa-database m-r-5" /> Database
|
||||||
</>
|
</>
|
||||||
}>
|
}>
|
||||||
{filteredDatabases.map(database => (
|
{limitedDatabases.map(database => (
|
||||||
<Select.Option key={database}>
|
<Select.Option key={database}>
|
||||||
<i className="fa fa-database m-r-5" />
|
<i className="fa fa-database m-r-5" />
|
||||||
{database}
|
{database}
|
||||||
</Select.Option>
|
</Select.Option>
|
||||||
))}
|
))}
|
||||||
|
{limitedDatabases.length < filteredDatabases.length && (
|
||||||
|
<Select.Option key="hidden_options" value={-1} disabled>
|
||||||
|
Some databases were hidden due to a large set, search to limit results.
|
||||||
|
</Select.Option>
|
||||||
|
)}
|
||||||
</Select>
|
</Select>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -123,7 +143,7 @@ export default function DatabricksSchemaBrowser({
|
|||||||
<div className="load-button">
|
<div className="load-button">
|
||||||
<Tooltip title={!refreshing ? "Refresh Databases and Current Schema" : null}>
|
<Tooltip title={!refreshing ? "Refresh Databases and Current Schema" : null}>
|
||||||
<Button type="link" onClick={refreshAll} disabled={refreshing}>
|
<Button type="link" onClick={refreshAll} disabled={refreshing}>
|
||||||
<SyncOutlinedIcon spin={refreshing} />
|
<Icon type="sync" spin={refreshing} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
.database-select-open .ant-input-group-addon {
|
.database-select-open .ant-input-group-addon {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
|
|
||||||
.ant-select-selection-item {
|
.ant-select-selection-selected-value {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,11 +21,7 @@
|
|||||||
.ant-select {
|
.ant-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
.ant-select-selection-item {
|
&.ant-select-focused .ant-select-selection {
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.ant-select-focused .ant-select-selector {
|
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,5 +58,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.databricks-schema-browser-db-dropdown {
|
.databricks-schema-browser-db-dropdown {
|
||||||
width: 50vw !important;
|
width: auto !important;
|
||||||
|
max-width: 50vw;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { includes, has, get, map, first, isFunction, isEmpty, startsWith } from "lodash";
|
import { has, get, map, first, isFunction, isEmpty } from "lodash";
|
||||||
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
|
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
|
||||||
import notification from "@/services/notification";
|
import notification from "@/services/notification";
|
||||||
import DatabricksDataSource from "@/services/databricks-data-source";
|
import DatabricksDataSource from "@/services/databricks-data-source";
|
||||||
@@ -9,7 +9,7 @@ function getDatabases(dataSource, refresh = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return DatabricksDataSource.getDatabases(dataSource, refresh).catch(() => {
|
return DatabricksDataSource.getDatabases(dataSource, refresh).catch(() => {
|
||||||
notification.error("Failed to load Database list.", "Please try again later.");
|
notification.error("Failed to load Database list", "Please try again later.");
|
||||||
return Promise.reject();
|
return Promise.reject();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -20,26 +20,11 @@ function getSchema(dataSource, databaseName, refresh = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return DatabricksDataSource.getDatabaseTables(dataSource, databaseName, refresh).catch(() => {
|
return DatabricksDataSource.getDatabaseTables(dataSource, databaseName, refresh).catch(() => {
|
||||||
notification.error(`Failed to load tables for ${databaseName}.`, "Please try again later.");
|
notification.error("Failed to load Schema", "Please try again later.");
|
||||||
return Promise.reject();
|
return Promise.reject();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function addDisplayNameWithoutDatabaseName(schema, databaseName) {
|
|
||||||
if (!databaseName) {
|
|
||||||
return schema;
|
|
||||||
}
|
|
||||||
// add display name without {databaseName} + "."
|
|
||||||
return map(schema, table => {
|
|
||||||
const databaseNamePrefix = databaseName + ".";
|
|
||||||
let displayName = table.name;
|
|
||||||
if (startsWith(table.name, databaseNamePrefix)) {
|
|
||||||
displayName = table.name.slice(databaseNamePrefix.length);
|
|
||||||
}
|
|
||||||
return { ...table, displayName };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function useDatabricksSchema(dataSource, options = null, onOptionsUpdate = null) {
|
export default function useDatabricksSchema(dataSource, options = null, onOptionsUpdate = null) {
|
||||||
const [databases, setDatabases] = useState([]);
|
const [databases, setDatabases] = useState([]);
|
||||||
const [loadingDatabases, setLoadingDatabases] = useState(true);
|
const [loadingDatabases, setLoadingDatabases] = useState(true);
|
||||||
@@ -87,10 +72,7 @@ export default function useDatabricksSchema(dataSource, options = null, onOption
|
|||||||
[dataSource, currentDatabaseName]
|
[dataSource, currentDatabaseName]
|
||||||
);
|
);
|
||||||
|
|
||||||
const schema = useMemo(() => {
|
const schema = useMemo(() => get(schemas, currentDatabaseName, []), [schemas, currentDatabaseName]);
|
||||||
const currentSchema = get(schemas, currentDatabaseName, []);
|
|
||||||
return addDisplayNameWithoutDatabaseName(currentSchema, currentDatabaseName);
|
|
||||||
}, [schemas, currentDatabaseName]);
|
|
||||||
|
|
||||||
const refreshAll = useCallback(() => {
|
const refreshAll = useCallback(() => {
|
||||||
if (!refreshing) {
|
if (!refreshing) {
|
||||||
@@ -150,20 +132,12 @@ export default function useDatabricksSchema(dataSource, options = null, onOption
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
if (!isCancelled) {
|
if (!isCancelled) {
|
||||||
setDatabases(data);
|
setDatabases(data);
|
||||||
|
setCurrentDatabaseName(
|
||||||
// We set the database using this order:
|
defaultDatabaseNameRef.current ||
|
||||||
// 1. Currently selected value.
|
localStorage.getItem(`lastSelectedDatabricksDatabase_${dataSource.id}`) ||
|
||||||
// 2. Last used stored in localStorage.
|
first(data) ||
|
||||||
// 3. default database.
|
null
|
||||||
// 4. first database in the list.
|
);
|
||||||
let lastUsedDatabase =
|
|
||||||
defaultDatabaseNameRef.current || localStorage.getItem(`lastSelectedDatabricksDatabase_${dataSource.id}`);
|
|
||||||
|
|
||||||
if (!lastUsedDatabase) {
|
|
||||||
lastUsedDatabase = includes(data, "default") ? "default" : first(data) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentDatabaseName(lastUsedDatabase);
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { isEqual, map, find, fromPairs } from "lodash";
|
import { map, find } from "lodash";
|
||||||
import React, { useState, useMemo, useEffect, useRef } from "react";
|
import React, { useState, useMemo, useEffect, useRef } from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import useQueryResultData from "@/lib/useQueryResultData";
|
import useQueryResultData from "@/lib/useQueryResultData";
|
||||||
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
|
|
||||||
import Filters, { FiltersType, filterData } from "@/components/Filters";
|
import Filters, { FiltersType, filterData } from "@/components/Filters";
|
||||||
import { VisualizationType } from "@redash/viz/lib";
|
import { VisualizationType } from "@redash/viz/lib";
|
||||||
import { Renderer } from "@/components/visualizations/visualizationComponents";
|
import { Renderer } from "@/components/visualizations/visualizationComponents";
|
||||||
@@ -25,41 +24,23 @@ function combineFilters(localFilters, globalFilters) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function areFiltersEqual(a, b) {
|
|
||||||
if (a.length !== b.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
a = fromPairs(map(a, item => [item.name, item]));
|
|
||||||
b = fromPairs(map(b, item => [item.name, item]));
|
|
||||||
|
|
||||||
return isEqual(a, b);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VisualizationRenderer(props) {
|
export default function VisualizationRenderer(props) {
|
||||||
const data = useQueryResultData(props.queryResult);
|
const data = useQueryResultData(props.queryResult);
|
||||||
const [filters, setFilters] = useState(() => combineFilters(data.filters, props.filters)); // lazy initialization
|
const [filters, setFilters] = useState(data.filters);
|
||||||
const filtersRef = useRef();
|
const filtersRef = useRef();
|
||||||
filtersRef.current = filters;
|
filtersRef.current = filters;
|
||||||
|
|
||||||
const handleFiltersChange = useImmutableCallback(newFilters => {
|
|
||||||
if (!areFiltersEqual(newFilters, filters)) {
|
|
||||||
setFilters(newFilters);
|
|
||||||
props.onFiltersChange(newFilters);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset local filters when query results updated
|
// Reset local filters when query results updated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleFiltersChange(combineFilters(data.filters, props.filters));
|
setFilters(combineFilters(data.filters, props.filters));
|
||||||
}, [data.filters, props.filters, handleFiltersChange]);
|
}, [data.filters, props.filters]);
|
||||||
|
|
||||||
// Update local filters when global filters changed.
|
// Update local filters when global filters changed.
|
||||||
// For correct behavior need to watch only `props.filters` here,
|
// For correct behavior need to watch only `props.filters` here,
|
||||||
// therefore using ref to access current local filters
|
// therefore using ref to access current local filters
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleFiltersChange(combineFilters(filtersRef.current, props.filters));
|
setFilters(combineFilters(filtersRef.current, props.filters));
|
||||||
}, [props.filters, handleFiltersChange]);
|
}, [props.filters]);
|
||||||
|
|
||||||
const filteredData = useMemo(
|
const filteredData = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -85,7 +66,7 @@ export default function VisualizationRenderer(props) {
|
|||||||
options={options}
|
options={options}
|
||||||
data={filteredData}
|
data={filteredData}
|
||||||
visualizationName={visualization.name}
|
visualizationName={visualization.name}
|
||||||
addonBefore={showFilters && <Filters filters={filters} onChange={handleFiltersChange} />}
|
addonBefore={showFilters && <Filters filters={filters} onChange={setFilters} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -93,14 +74,12 @@ export default function VisualizationRenderer(props) {
|
|||||||
VisualizationRenderer.propTypes = {
|
VisualizationRenderer.propTypes = {
|
||||||
visualization: VisualizationType.isRequired,
|
visualization: VisualizationType.isRequired,
|
||||||
queryResult: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
queryResult: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||||
showFilters: PropTypes.bool,
|
|
||||||
filters: FiltersType,
|
filters: FiltersType,
|
||||||
onFiltersChange: PropTypes.func,
|
showFilters: PropTypes.bool,
|
||||||
context: PropTypes.oneOf(["query", "widget"]).isRequired,
|
context: PropTypes.oneOf(["query", "widget"]).isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
VisualizationRenderer.defaultProps = {
|
VisualizationRenderer.defaultProps = {
|
||||||
showFilters: true,
|
|
||||||
filters: [],
|
filters: [],
|
||||||
onFiltersChange: () => {},
|
showFilters: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Renderer as VisRenderer, Editor as VisEditor, updateVisualizationsSetti
|
|||||||
import { clientConfig } from "@/services/auth";
|
import { clientConfig } from "@/services/auth";
|
||||||
|
|
||||||
import countriesDataUrl from "@redash/viz/lib/visualizations/choropleth/maps/countries.geo.json";
|
import countriesDataUrl from "@redash/viz/lib/visualizations/choropleth/maps/countries.geo.json";
|
||||||
import usaDataUrl from "@redash/viz/lib/visualizations/choropleth/maps/usa-albers.geo.json";
|
|
||||||
import subdivJapanDataUrl from "@redash/viz/lib/visualizations/choropleth/maps/japan.prefectures.geo.json";
|
import subdivJapanDataUrl from "@redash/viz/lib/visualizations/choropleth/maps/japan.prefectures.geo.json";
|
||||||
|
|
||||||
function wrapComponentWithSettings(WrappedComponent) {
|
function wrapComponentWithSettings(WrappedComponent) {
|
||||||
@@ -18,40 +17,10 @@ function wrapComponentWithSettings(WrappedComponent) {
|
|||||||
countries: {
|
countries: {
|
||||||
name: "Countries",
|
name: "Countries",
|
||||||
url: countriesDataUrl,
|
url: countriesDataUrl,
|
||||||
fieldNames: {
|
|
||||||
name: "Short name",
|
|
||||||
name_long: "Full name",
|
|
||||||
abbrev: "Abbreviated name",
|
|
||||||
iso_a2: "ISO code (2 letters)",
|
|
||||||
iso_a3: "ISO code (3 letters)",
|
|
||||||
iso_n3: "ISO code (3 digits)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
usa: {
|
|
||||||
name: "USA",
|
|
||||||
url: usaDataUrl,
|
|
||||||
fieldNames: {
|
|
||||||
name: "Name",
|
|
||||||
ns_code: "National Standard ANSI Code (8-character)",
|
|
||||||
geoid: "Geographic ID",
|
|
||||||
usps_abbrev: "USPS Abbreviation",
|
|
||||||
fips_code: "FIPS Code (2-character)",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
subdiv_japan: {
|
subdiv_japan: {
|
||||||
name: "Japan/Prefectures",
|
name: "Japan/Prefectures",
|
||||||
url: subdivJapanDataUrl,
|
url: subdivJapanDataUrl,
|
||||||
fieldNames: {
|
|
||||||
name: "Name",
|
|
||||||
name_alt: "Name (alternative)",
|
|
||||||
name_local: "Name (local)",
|
|
||||||
iso_3166_2: "ISO-3166-2",
|
|
||||||
postal: "Postal Code",
|
|
||||||
type: "Type",
|
|
||||||
type_en: "Type (EN)",
|
|
||||||
region: "Region",
|
|
||||||
region_code: "Region Code",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...pick(clientConfig, [
|
...pick(clientConfig, [
|
||||||
|
|||||||
@@ -3,28 +3,13 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<base href="<%= htmlWebpackPlugin.options.baseHref %>" />
|
<base href="/" />
|
||||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
<title>Redash</title>
|
||||||
<script src="<%= htmlWebpackPlugin.options.staticPath %>unsupportedRedirect.js" async></script>
|
<script src="/static/unsupportedRedirect.js" async></script>
|
||||||
|
|
||||||
<link
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png" />
|
||||||
rel="icon"
|
<link rel="icon" type="image/png" sizes="96x96" href="/static/images/favicon-96x96.png" />
|
||||||
type="image/png"
|
<link rel="icon" type="image/png" sizes="16x16" href="/static/images/favicon-16x16.png" />
|
||||||
sizes="32x32"
|
|
||||||
href="<%= htmlWebpackPlugin.options.staticPath %>images/favicon-32x32.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="icon"
|
|
||||||
type="image/png"
|
|
||||||
sizes="96x96"
|
|
||||||
href="<%= htmlWebpackPlugin.options.staticPath %>images/favicon-96x96.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="icon"
|
|
||||||
type="image/png"
|
|
||||||
sizes="16x16"
|
|
||||||
href="<%= htmlWebpackPlugin.options.staticPath %>images/favicon-16x16.png"
|
|
||||||
/>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.style.display = "none";
|
|
||||||
document.body.appendChild(canvas);
|
|
||||||
|
|
||||||
export function calculateTextWidth(text: string, container = document.body) {
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
if (ctx) {
|
|
||||||
const containerStyle = window.getComputedStyle(container);
|
|
||||||
ctx.font = `${containerStyle.fontSize} ${containerStyle.fontFamily}`;
|
|
||||||
const textMetrics = ctx.measureText(text);
|
|
||||||
let actualWidth = textMetrics.width;
|
|
||||||
if ("actualBoundingBoxLeft" in textMetrics) {
|
|
||||||
// only available on evergreen browsers
|
|
||||||
actualWidth = Math.abs(textMetrics.actualBoundingBoxLeft) + Math.abs(textMetrics.actualBoundingBoxRight);
|
|
||||||
}
|
|
||||||
return actualWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { Query } from "@/services/query";
|
|
||||||
import * as queryFormat from "./queryFormat";
|
|
||||||
|
|
||||||
describe("QueryFormat.formatQuery", () => {
|
|
||||||
test("returns same query text when syntax is not supported", () => {
|
|
||||||
const unsupportedSyntax = "unsupported-syntax";
|
|
||||||
const queryText = "select * from example";
|
|
||||||
const isFormatQueryAvailable = queryFormat.isFormatQueryAvailable(unsupportedSyntax);
|
|
||||||
const formattedQuery = queryFormat.formatQuery(queryText, unsupportedSyntax);
|
|
||||||
|
|
||||||
expect(isFormatQueryAvailable).toBeFalsy();
|
|
||||||
expect(formattedQuery).toBe(queryText);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sql", () => {
|
|
||||||
const syntax = "sql";
|
|
||||||
|
|
||||||
test("returns the formatted query text", () => {
|
|
||||||
const queryText = "select column1, column2 from example where column1 = 2";
|
|
||||||
const expectedFormattedQueryText = [
|
|
||||||
"select",
|
|
||||||
" column1,",
|
|
||||||
" column2",
|
|
||||||
"from",
|
|
||||||
" example",
|
|
||||||
"where",
|
|
||||||
" column1 = 2",
|
|
||||||
].join("\n");
|
|
||||||
const isFormatQueryAvailable = queryFormat.isFormatQueryAvailable(syntax);
|
|
||||||
const formattedQueryText = queryFormat.formatQuery(queryText, syntax);
|
|
||||||
expect(isFormatQueryAvailable).toBeTruthy();
|
|
||||||
expect(formattedQueryText).toBe(expectedFormattedQueryText);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("still recognizes parameters after formatting", () => {
|
|
||||||
const queryText = "select {{param1}}, {{ param2 }}, {{ date-range.start }} from example";
|
|
||||||
const formattedQueryText = queryFormat.formatQuery(queryText, syntax);
|
|
||||||
const queryParameters = new Query({ query: queryText }).getParameters().parseQuery();
|
|
||||||
const formattedQueryParameters = new Query({ query: formattedQueryText }).getParameters().parseQuery();
|
|
||||||
expect(formattedQueryParameters.sort()).toEqual(queryParameters.sort());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("json", () => {
|
|
||||||
const syntax = "json";
|
|
||||||
|
|
||||||
test("returns the formatted query text", () => {
|
|
||||||
const queryText = '{"collection": "example","limit": 10}';
|
|
||||||
const expectedFormattedQueryText = '{\n "collection": "example",\n "limit": 10\n}';
|
|
||||||
const isFormatQueryAvailable = queryFormat.isFormatQueryAvailable(syntax);
|
|
||||||
const formattedQueryText = queryFormat.formatQuery(queryText, syntax);
|
|
||||||
expect(isFormatQueryAvailable).toBeTruthy();
|
|
||||||
expect(formattedQueryText).toBe(expectedFormattedQueryText);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { trim } from "lodash";
|
|
||||||
import sqlFormatter from "sql-formatter";
|
|
||||||
|
|
||||||
interface QueryFormatterMap {
|
|
||||||
[syntax: string]: (queryText: string) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QueryFormatters: QueryFormatterMap = {
|
|
||||||
sql: queryText => sqlFormatter.format(trim(queryText)),
|
|
||||||
json: queryText => JSON.stringify(JSON.parse(queryText), null, 4),
|
|
||||||
};
|
|
||||||
|
|
||||||
export function isFormatQueryAvailable(syntax: string) {
|
|
||||||
return syntax in QueryFormatters;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatQuery(queryText: string, syntax: string) {
|
|
||||||
if (!isFormatQueryAvailable(syntax)) {
|
|
||||||
return queryText;
|
|
||||||
}
|
|
||||||
const formatter = QueryFormatters[syntax];
|
|
||||||
return formatter(queryText);
|
|
||||||
}
|
|
||||||
@@ -11,16 +11,7 @@ export const IntervalEnum = {
|
|||||||
MILLISECONDS: "millisecond",
|
MILLISECONDS: "millisecond",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AbbreviatedTimeUnits = {
|
export function formatDateTime(value) {
|
||||||
SECONDS: "s",
|
|
||||||
MINUTES: "m",
|
|
||||||
HOURS: "h",
|
|
||||||
DAYS: "d",
|
|
||||||
WEEKS: "w",
|
|
||||||
MILLISECONDS: "ms",
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatDateTimeValue(value, format) {
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -30,19 +21,20 @@ function formatDateTimeValue(value, format) {
|
|||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
return parsed.format(format);
|
return parsed.format(clientConfig.dateTimeFormat);
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDateTime(value) {
|
|
||||||
return formatDateTimeValue(value, clientConfig.dateTimeFormat);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDateTimePrecise(value, withMilliseconds = false) {
|
|
||||||
return formatDateTimeValue(value, clientConfig.dateFormat + (withMilliseconds ? " HH:mm:ss.SSS" : " HH:mm:ss"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(value) {
|
export function formatDate(value) {
|
||||||
return formatDateTimeValue(value, clientConfig.dateFormat);
|
if (!value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = moment(value);
|
||||||
|
if (!parsed.isValid()) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.format(clientConfig.dateFormat);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function localizeTime(time) {
|
export function localizeTime(time) {
|
||||||
@@ -127,59 +119,21 @@ export function remove(items, item) {
|
|||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const units = ["bytes", "KB", "MB", "GB", "TB", "PB"];
|
||||||
* Formats number to string
|
|
||||||
* @param value {number}
|
|
||||||
* @param [fractionDigits] {number}
|
|
||||||
* @return {string}
|
|
||||||
*/
|
|
||||||
export function formatNumber(value, fractionDigits = 3) {
|
|
||||||
return Math.round(value) !== value ? value.toFixed(fractionDigits) : value.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
export function prettySize(bytes) {
|
||||||
* Formats any number using predefined units
|
if (isNaN(parseFloat(bytes)) || !isFinite(bytes)) {
|
||||||
* @param value {string|number}
|
return "?";
|
||||||
* @param divisor {number}
|
|
||||||
* @param [units] {Array<string>}
|
|
||||||
* @param [fractionDigits] {number}
|
|
||||||
* @return {{unit: string, value: string, divisor: number}}
|
|
||||||
*/
|
|
||||||
export function prettyNumberWithUnit(value, divisor, units = [], fractionDigits) {
|
|
||||||
if (isNaN(parseFloat(value)) || !isFinite(value)) {
|
|
||||||
return {
|
|
||||||
value: "",
|
|
||||||
unit: "",
|
|
||||||
divisor: 1,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let unit = 0;
|
let unit = 0;
|
||||||
let greatestDivisor = 1;
|
|
||||||
|
|
||||||
while (value >= divisor && unit < units.length - 1) {
|
while (bytes >= 1024) {
|
||||||
value /= divisor;
|
bytes /= 1024;
|
||||||
greatestDivisor *= divisor;
|
|
||||||
unit += 1;
|
unit += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return bytes.toFixed(3) + " " + units[unit];
|
||||||
value: formatNumber(value, fractionDigits),
|
|
||||||
unit: units[unit],
|
|
||||||
divisor: greatestDivisor,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function prettySizeWithUnit(bytes, fractionDigits) {
|
|
||||||
return prettyNumberWithUnit(bytes, 1024, ["bytes", "KB", "MB", "GB", "TB", "PB"], fractionDigits);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function prettySize(bytes) {
|
|
||||||
const { value, unit } = prettySizeWithUnit(bytes);
|
|
||||||
if (!value) {
|
|
||||||
return "?";
|
|
||||||
}
|
|
||||||
return value + " " + unit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function join(arr) {
|
export function join(arr) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user