mirror of
https://github.com/getredash/redash.git
synced 2025-12-19 17:37:19 -05:00
Compare commits
91 Commits
test-event
...
query-base
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19343a0520 | ||
|
|
c1ed8848f0 | ||
|
|
b40070d7f5 | ||
|
|
fa2b57a209 | ||
|
|
132fed64b3 | ||
|
|
bd9ce68f68 | ||
|
|
0c0b62ae1a | ||
|
|
08bcdf77d0 | ||
|
|
aa2064b1ab | ||
|
|
d0a787cab1 | ||
|
|
a741341938 | ||
|
|
53385fa24b | ||
|
|
fa7ecca485 | ||
|
|
8f484706b1 | ||
|
|
e2e8714155 | ||
|
|
c6bf8a1c55 | ||
|
|
12f71925c2 | ||
|
|
cae088f35b | ||
|
|
a3c79f26b9 | ||
|
|
c7c92a3192 | ||
|
|
55cf17aa47 | ||
|
|
8dd76a00c5 | ||
|
|
e242ac2b10 | ||
|
|
66463aedd4 | ||
|
|
8a6524c1ba | ||
|
|
9097feb100 | ||
|
|
db4e97fa6f | ||
|
|
0d4615a482 | ||
|
|
ff008a076b | ||
|
|
8d548ecbac | ||
|
|
2992c382d1 | ||
|
|
f4dcb2918a | ||
|
|
c821cab4cb | ||
|
|
4fb77867b0 | ||
|
|
a473611cb0 | ||
|
|
210008c714 | ||
|
|
aa5d4f5f4e | ||
|
|
6b811c5245 | ||
|
|
83726da48a | ||
|
|
72dc157bbe | ||
|
|
1b8ff8e810 | ||
|
|
31ddd0fb79 | ||
|
|
5cabf7a724 | ||
|
|
59b135ace7 | ||
|
|
32b41e4112 | ||
|
|
2e31b91054 | ||
|
|
205915e6db | ||
|
|
b7c245f925 | ||
|
|
681b2f1abd | ||
|
|
a31196aef8 | ||
|
|
596e5bee3a | ||
|
|
84d516bfd1 | ||
|
|
2cc3bd3d54 | ||
|
|
ac652c20bf | ||
|
|
1bc6cd8f41 | ||
|
|
4c70b5ce8e | ||
|
|
de052ff02b | ||
|
|
a596d6558c | ||
|
|
fc71acdc09 | ||
|
|
b326d36ae8 | ||
|
|
378cc57d42 | ||
|
|
83c6a6bcd2 | ||
|
|
5afd0554d0 | ||
|
|
eb603f63f0 | ||
|
|
6c00f7c4e3 | ||
|
|
f56f4c4899 | ||
|
|
d3b639a68a | ||
|
|
3332b656ac | ||
|
|
24c95379ca | ||
|
|
93b4be672f | ||
|
|
f396c96457 | ||
|
|
8bfcbf21e3 | ||
|
|
8a1640c4e7 | ||
|
|
a37e7f93dc | ||
|
|
cc34e781d3 | ||
|
|
6aa0ea715e | ||
|
|
6c27619671 | ||
|
|
6eeb3b3eb2 | ||
|
|
d40edb81c2 | ||
|
|
f128b4b85f | ||
|
|
264fb5798d | ||
|
|
90023ac435 | ||
|
|
df755fbc17 | ||
|
|
e555642844 | ||
|
|
bdd7b146ae | ||
|
|
b7478defec | ||
|
|
bb0d7830c9 | ||
|
|
137aa22dd4 | ||
|
|
9cf396599a | ||
|
|
b70f0fa921 | ||
|
|
5e3613d6cb |
@@ -1,12 +1,12 @@
|
||||
FROM cypress/browsers:chrome67
|
||||
FROM cypress/browsers:node14.0.0-chrome84
|
||||
|
||||
ENV APP /usr/src/app
|
||||
WORKDIR $APP
|
||||
|
||||
COPY package.json $APP/package.json
|
||||
RUN npm run cypress:install > /dev/null
|
||||
COPY package.json package-lock.json $APP/
|
||||
COPY viz-lib $APP/viz-lib
|
||||
RUN npm ci > /dev/null
|
||||
|
||||
COPY client/cypress $APP/client/cypress
|
||||
COPY cypress.json $APP/cypress.json
|
||||
COPY . $APP
|
||||
|
||||
RUN ./node_modules/.bin/cypress verify
|
||||
|
||||
@@ -57,6 +57,9 @@ jobs:
|
||||
- store_artifacts:
|
||||
path: coverage.xml
|
||||
frontend-lint:
|
||||
environment:
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
|
||||
docker:
|
||||
- image: circleci/node:12
|
||||
steps:
|
||||
@@ -67,6 +70,9 @@ jobs:
|
||||
- store_test_results:
|
||||
path: /tmp/test-results
|
||||
frontend-unit-tests:
|
||||
environment:
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
|
||||
docker:
|
||||
- image: circleci/node:12
|
||||
steps:
|
||||
@@ -90,11 +96,20 @@ jobs:
|
||||
PERCY_TOKEN_ENCODED: ZGRiY2ZmZDQ0OTdjMzM5ZWE0ZGQzNTZiOWNkMDRjOTk4Zjg0ZjMxMWRmMDZiM2RjOTYxNDZhOGExMjI4ZDE3MA==
|
||||
CYPRESS_PROJECT_ID_ENCODED: OTI0Y2th
|
||||
CYPRESS_RECORD_KEY_ENCODED: YzA1OTIxMTUtYTA1Yy00NzQ2LWEyMDMtZmZjMDgwZGI2ODgx
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
|
||||
docker:
|
||||
- image: circleci/node:12
|
||||
steps:
|
||||
- setup_remote_docker
|
||||
- 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:
|
||||
name: Install npm dependencies
|
||||
command: |
|
||||
@@ -113,6 +128,13 @@ jobs:
|
||||
command: |
|
||||
docker-compose logs
|
||||
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-preview-docker-image: *build-docker-image-job
|
||||
workflows:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version: '3'
|
||||
version: '2.2'
|
||||
services:
|
||||
redash:
|
||||
build: ../
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
version: '3'
|
||||
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:
|
||||
server:
|
||||
build: ../
|
||||
<<: *redash-service
|
||||
command: server
|
||||
depends_on:
|
||||
- postgres
|
||||
@@ -9,29 +22,25 @@ services:
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
<<: *redash-environment
|
||||
PYTHONUNBUFFERED: 0
|
||||
REDASH_LOG_LEVEL: "INFO"
|
||||
REDASH_REDIS_URL: "redis://redis:6379/0"
|
||||
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
||||
REDASH_RATELIMIT_ENABLED: "false"
|
||||
scheduler:
|
||||
build: ../
|
||||
<<: *redash-service
|
||||
command: scheduler
|
||||
depends_on:
|
||||
- server
|
||||
environment:
|
||||
REDASH_REDIS_URL: "redis://redis:6379/0"
|
||||
<<: *redash-environment
|
||||
worker:
|
||||
build: ../
|
||||
<<: *redash-service
|
||||
command: worker
|
||||
depends_on:
|
||||
- server
|
||||
environment:
|
||||
<<: *redash-environment
|
||||
PYTHONUNBUFFERED: 0
|
||||
REDASH_LOG_LEVEL: "INFO"
|
||||
REDASH_REDIS_URL: "redis://redis:6379/0"
|
||||
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
||||
cypress:
|
||||
ipc: host
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: .circleci/Dockerfile.cypress
|
||||
@@ -41,6 +50,7 @@ services:
|
||||
- scheduler
|
||||
environment:
|
||||
CYPRESS_baseUrl: "http://server:5000"
|
||||
CYPRESS_coverage: ${CODE_COVERAGE}
|
||||
PERCY_TOKEN: ${PERCY_TOKEN}
|
||||
PERCY_BRANCH: ${CIRCLE_BRANCH}
|
||||
PERCY_COMMIT: ${CIRCLE_SHA1}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,6 +5,8 @@ venv/
|
||||
.coveralls.yml
|
||||
.idea
|
||||
*.pyc
|
||||
.nyc_output
|
||||
coverage
|
||||
.coverage
|
||||
coverage.xml
|
||||
client/dist
|
||||
|
||||
19
Dockerfile
19
Dockerfile
@@ -3,13 +3,24 @@ FROM node:12 as frontend-builder
|
||||
# Controls whether to build the frontend assets
|
||||
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
|
||||
COPY package.json package-lock.json /frontend/
|
||||
COPY viz-lib /frontend/viz-lib
|
||||
COPY --chown=redash package.json package-lock.json /frontend/
|
||||
COPY --chown=redash 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
|
||||
|
||||
COPY client /frontend/client
|
||||
COPY webpack.config.js /frontend/
|
||||
COPY --chown=redash client /frontend/client
|
||||
COPY --chown=redash 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
|
||||
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
|
||||
|
||||
frontend-unit-tests: bundle
|
||||
npm ci
|
||||
CYPRESS_INSTALL_BINARY=0 PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 npm ci
|
||||
npm run bundle
|
||||
npm test
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ worker() {
|
||||
export WORKERS_COUNT=${WORKERS_COUNT:-2}
|
||||
export QUEUES=${QUEUES:-}
|
||||
|
||||
supervisord -c worker.conf
|
||||
exec supervisord -c worker.conf
|
||||
}
|
||||
|
||||
dev_worker() {
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
{
|
||||
"presets": [
|
||||
["@babel/preset-env", {
|
||||
"exclude": [
|
||||
"@babel/plugin-transform-async-to-generator",
|
||||
"@babel/plugin-transform-arrow-functions"
|
||||
],
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"exclude": ["@babel/plugin-transform-async-to-generator", "@babel/plugin-transform-arrow-functions"],
|
||||
"corejs": "2",
|
||||
"useBuiltIns": "usage"
|
||||
}],
|
||||
}
|
||||
],
|
||||
"@babel/preset-react",
|
||||
"@babel/preset-typescript"
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
"@babel/plugin-transform-object-assign",
|
||||
["babel-plugin-transform-builtin-extend", {
|
||||
[
|
||||
"babel-plugin-transform-builtin-extend",
|
||||
{
|
||||
"globals": ["Error"]
|
||||
}]
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"env": {
|
||||
"test": {
|
||||
"plugins": ["istanbul"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,21 @@ module.exports = {
|
||||
// allow debugger during development
|
||||
"no-debugger": process.env.NODE_ENV === "production" ? 2 : 0,
|
||||
"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: [
|
||||
{
|
||||
@@ -34,6 +49,8 @@ module.exports = {
|
||||
// Do not complain about useless contructors in declaration files
|
||||
"no-useless-constructor": "off",
|
||||
"@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.5 KiB After Width: | Height: | Size: 2.8 KiB |
@@ -16,7 +16,6 @@
|
||||
@import "~antd/lib/pagination/style/index";
|
||||
@import "~antd/lib/table/style/index";
|
||||
@import "~antd/lib/popover/style/index";
|
||||
@import "~antd/lib/icon/style/index";
|
||||
@import "~antd/lib/tag/style/index";
|
||||
@import "~antd/lib/grid/style/index";
|
||||
@import "~antd/lib/switch/style/index";
|
||||
@@ -31,6 +30,7 @@
|
||||
@import "~antd/lib/badge/style/index";
|
||||
@import "~antd/lib/card/style/index";
|
||||
@import "~antd/lib/spin/style/index";
|
||||
@import "~antd/lib/skeleton/style/index";
|
||||
@import "~antd/lib/tabs/style/index";
|
||||
@import "~antd/lib/notification/style/index";
|
||||
@import "~antd/lib/collapse/style/index";
|
||||
@@ -401,3 +401,14 @@
|
||||
.@{checkbox-prefix-cls} + span {
|
||||
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,6 +23,10 @@
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.ant-form-item-explain {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.alert-last-triggered {
|
||||
color: @headings-color;
|
||||
}
|
||||
|
||||
@@ -78,8 +78,6 @@ strong {
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed width layout for specific pages
|
||||
|
||||
.settings-screen,
|
||||
.home-page,
|
||||
.page-dashboard-list,
|
||||
@@ -89,7 +87,7 @@ strong {
|
||||
.admin-page-layout {
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
|
||||
&:not(.table-striped) > thead > tr > th {
|
||||
background-color: #FAFAFA;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
[class*="bg-"] {
|
||||
@@ -33,9 +33,8 @@
|
||||
& > thead > tr,
|
||||
& > tbody > tr,
|
||||
& > tfoot > tr {
|
||||
|
||||
& > th, & > td {
|
||||
|
||||
& > th,
|
||||
& > td {
|
||||
&:first-child {
|
||||
padding-left: 30px;
|
||||
}
|
||||
@@ -43,7 +42,6 @@
|
||||
&:last-child {
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +54,8 @@
|
||||
border: 0;
|
||||
|
||||
& > tbody > tr {
|
||||
& > td, & > th {
|
||||
& > td,
|
||||
& > th {
|
||||
border-bottom: 0;
|
||||
border-left: 0;
|
||||
|
||||
@@ -86,10 +85,8 @@
|
||||
}
|
||||
|
||||
.tile .table {
|
||||
|
||||
& > thead:not([class*="bg-"]) > tr > th {
|
||||
border-top: 1px solid @table-border-color;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,11 +95,16 @@
|
||||
}
|
||||
|
||||
.table-data {
|
||||
thead > tr > th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tbody > tr > td {
|
||||
padding-top: 5px !important;
|
||||
}
|
||||
|
||||
.btn-favourite, .btn-archive {
|
||||
.btn-favourite,
|
||||
.btn-archive {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
@@ -114,9 +116,10 @@
|
||||
|
||||
.btn-favourite {
|
||||
color: #d4d4d4;
|
||||
transition: all .25s ease-in-out;
|
||||
transition: all 0.25s ease-in-out;
|
||||
|
||||
&:hover, &:focus {
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: @yellow-darker;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -128,9 +131,10 @@
|
||||
|
||||
.btn-archive {
|
||||
color: #d4d4d4;
|
||||
transition: all .25s ease-in-out;
|
||||
transition: all 0.25s ease-in-out;
|
||||
|
||||
&:hover, &:focus {
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: @gray-light;
|
||||
}
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@ a.label-tag {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.query-fullscreen {
|
||||
|
||||
@@ -2,13 +2,22 @@ import { first } from "lodash";
|
||||
import React, { useState } from "react";
|
||||
import Button from "antd/lib/button";
|
||||
import Menu from "antd/lib/menu";
|
||||
import Icon from "antd/lib/icon";
|
||||
import Link from "@/components/Link";
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
|
||||
import { Auth, currentUser } from "@/services/auth";
|
||||
import settingsMenu from "@/services/settingsMenu";
|
||||
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 "./DesktopNavbar.less";
|
||||
|
||||
@@ -37,34 +46,36 @@ export default function DesktopNavbar() {
|
||||
return (
|
||||
<div className="desktop-navbar">
|
||||
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-logo">
|
||||
<a href="./">
|
||||
<div>
|
||||
<Link href="./">
|
||||
<img src={logoUrl} alt="Redash" />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</NavbarSection>
|
||||
|
||||
<NavbarSection inlineCollapsed={collapsed}>
|
||||
{currentUser.hasPermission("list_dashboards") && (
|
||||
<Menu.Item key="dashboards">
|
||||
<a href="dashboards">
|
||||
<Icon type="desktop" />
|
||||
<Link href="dashboards">
|
||||
<DesktopOutlinedIcon />
|
||||
<span>Dashboards</span>
|
||||
</a>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("view_query") && (
|
||||
<Menu.Item key="queries">
|
||||
<a href="queries">
|
||||
<Icon type="code" />
|
||||
<Link href="queries">
|
||||
<CodeOutlinedIcon />
|
||||
<span>Queries</span>
|
||||
</a>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("list_alerts") && (
|
||||
<Menu.Item key="alerts">
|
||||
<a href="alerts">
|
||||
<Icon type="alert" />
|
||||
<Link href="alerts">
|
||||
<AlertOutlinedIcon />
|
||||
<span>Alerts</span>
|
||||
</a>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</NavbarSection>
|
||||
@@ -78,16 +89,16 @@ export default function DesktopNavbar() {
|
||||
title={
|
||||
<React.Fragment>
|
||||
<span data-test="CreateButton">
|
||||
<Icon type="plus" />
|
||||
<PlusOutlinedIcon />
|
||||
<span>Create</span>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
}>
|
||||
{canCreateQuery && (
|
||||
<Menu.Item key="new-query">
|
||||
<a href="queries/new" data-test="CreateQueryMenuItem">
|
||||
<Link href="queries/new" data-test="CreateQueryMenuItem">
|
||||
New Query
|
||||
</a>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{canCreateDashboard && (
|
||||
@@ -99,9 +110,9 @@ export default function DesktopNavbar() {
|
||||
)}
|
||||
{canCreateAlert && (
|
||||
<Menu.Item key="new-alert">
|
||||
<a data-test="CreateAlertMenuItem" href="alerts/new">
|
||||
<Link data-test="CreateAlertMenuItem" href="alerts/new">
|
||||
New Alert
|
||||
</a>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.SubMenu>
|
||||
@@ -111,16 +122,16 @@ export default function DesktopNavbar() {
|
||||
<NavbarSection inlineCollapsed={collapsed}>
|
||||
<Menu.Item key="help">
|
||||
<HelpTrigger showTooltip={false} type="HOME">
|
||||
<Icon type="question-circle" />
|
||||
<QuestionCircleOutlinedIcon />
|
||||
<span>Help</span>
|
||||
</HelpTrigger>
|
||||
</Menu.Item>
|
||||
{firstSettingsTab && (
|
||||
<Menu.Item key="settings">
|
||||
<a href={firstSettingsTab.path} data-test="SettingsLink">
|
||||
<Icon type="setting" />
|
||||
<Link href={firstSettingsTab.path} data-test="SettingsLink">
|
||||
<SettingOutlinedIcon />
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
@@ -137,11 +148,11 @@ export default function DesktopNavbar() {
|
||||
</span>
|
||||
}>
|
||||
<Menu.Item key="profile">
|
||||
<a href="users/me">Profile</a>
|
||||
<Link href="users/me">Profile</Link>
|
||||
</Menu.Item>
|
||||
{currentUser.hasPermission("super_admin") && (
|
||||
<Menu.Item key="status">
|
||||
<a href="admin/status">System Status</a>
|
||||
<Link href="admin/status">System Status</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
@@ -158,7 +169,7 @@ export default function DesktopNavbar() {
|
||||
</NavbarSection>
|
||||
|
||||
<Button onClick={() => setCollapsed(!collapsed)} className="desktop-navbar-collapse-button">
|
||||
<Icon type={collapsed ? "menu-unfold" : "menu-fold"} />
|
||||
{collapsed ? <MenuUnfoldOutlinedIcon /> : <MenuFoldOutlinedIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,10 @@ import { first } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Button from "antd/lib/button";
|
||||
import Icon from "antd/lib/icon";
|
||||
import MenuOutlinedIcon from "@ant-design/icons/MenuOutlined";
|
||||
import Dropdown from "antd/lib/dropdown";
|
||||
import Menu from "antd/lib/menu";
|
||||
import Link from "@/components/Link";
|
||||
import { Auth, currentUser } from "@/services/auth";
|
||||
import settingsMenu from "@/services/settingsMenu";
|
||||
import logoUrl from "@/assets/images/redash_icon_small.png";
|
||||
@@ -17,9 +18,9 @@ export default function MobileNavbar({ getPopupContainer }) {
|
||||
return (
|
||||
<div className="mobile-navbar">
|
||||
<div className="mobile-navbar-logo">
|
||||
<a href="./">
|
||||
<Link href="./">
|
||||
<img src={logoUrl} alt="Redash" />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<Dropdown
|
||||
@@ -30,39 +31,39 @@ export default function MobileNavbar({ getPopupContainer }) {
|
||||
<Menu mode="vertical" theme="dark" selectable={false} className="mobile-navbar-menu">
|
||||
{currentUser.hasPermission("list_dashboards") && (
|
||||
<Menu.Item key="dashboards">
|
||||
<a href="dashboards">Dashboards</a>
|
||||
<Link href="dashboards">Dashboards</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("view_query") && (
|
||||
<Menu.Item key="queries">
|
||||
<a href="queries">Queries</a>
|
||||
<Link href="queries">Queries</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("list_alerts") && (
|
||||
<Menu.Item key="alerts">
|
||||
<a href="alerts">Alerts</a>
|
||||
<Link href="alerts">Alerts</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item key="profile">
|
||||
<a href="users/me">Edit Profile</a>
|
||||
<Link href="users/me">Edit Profile</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
{firstSettingsTab && (
|
||||
<Menu.Item key="settings">
|
||||
<a href={firstSettingsTab.path}>Settings</a>
|
||||
<Link href={firstSettingsTab.path}>Settings</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("super_admin") && (
|
||||
<Menu.Item key="status">
|
||||
<a href="admin/status">System Status</a>
|
||||
<Link href="admin/status">System Status</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("super_admin") && <Menu.Divider />}
|
||||
<Menu.Item key="help">
|
||||
{/* eslint-disable-next-line react/jsx-no-target-blank */}
|
||||
<a href="https://redash.io/help" target="_blank" rel="noopener">
|
||||
<Link href="https://redash.io/help" target="_blank" rel="noopener">
|
||||
Help
|
||||
</a>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="logout" onClick={() => Auth.logout()}>
|
||||
Log out
|
||||
@@ -70,7 +71,7 @@ export default function MobileNavbar({ getPopupContainer }) {
|
||||
</Menu>
|
||||
}>
|
||||
<Button className="mobile-navbar-toggle-button" ghost>
|
||||
<Icon type="menu" />
|
||||
<MenuOutlinedIcon />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import Link from "@/components/Link";
|
||||
import { clientConfig, currentUser } from "@/services/auth";
|
||||
import frontendVersion from "@/version.json";
|
||||
|
||||
@@ -12,10 +13,10 @@ export default function VersionInfo() {
|
||||
{clientConfig.newVersionAvailable && currentUser.hasPermission("super_admin") && (
|
||||
<div className="m-t-10">
|
||||
{/* eslint-disable react/jsx-no-target-blank */}
|
||||
<a href="https://version.redash.io/" className="update-available" target="_blank" rel="noopener">
|
||||
<Link href="https://version.redash.io/" className="update-available" target="_blank" rel="noopener">
|
||||
Update Available
|
||||
<i className="fa fa-external-link m-l-5" />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
|
||||
@@ -13,6 +13,7 @@ export default function ApplicationLayout({ children }) {
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DynamicComponent name="ApplicationWrapper">
|
||||
<div className="application-layout-side-menu">
|
||||
<DynamicComponent name="ApplicationDesktopNavbar">
|
||||
<DesktopNavbar />
|
||||
@@ -26,6 +27,7 @@ export default function ApplicationLayout({ children }) {
|
||||
</nav>
|
||||
{children}
|
||||
</div>
|
||||
</DynamicComponent>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { isObject, get } from "lodash";
|
||||
import { get, isObject } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import "./ErrorMessage.less";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
import { ErrorMessageDetails } from "@/components/ApplicationArea/ErrorMessageDetails";
|
||||
|
||||
function getErrorMessageByStatus(status, defaultMessage) {
|
||||
switch (status) {
|
||||
@@ -31,21 +33,30 @@ function getErrorMessage(error) {
|
||||
return message;
|
||||
}
|
||||
|
||||
export default function ErrorMessage({ error }) {
|
||||
export default function ErrorMessage({ error, message }) {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
|
||||
const errorDetailsProps = {
|
||||
error,
|
||||
message: message || getErrorMessage(error),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="error-message-container" data-test="ErrorMessage">
|
||||
<div className="error-message-container" data-test="ErrorMessage" role="alert">
|
||||
<div className="error-state bg-white tiled">
|
||||
<div className="error-state__icon">
|
||||
<i className="zmdi zmdi-alert-circle-o" />
|
||||
</div>
|
||||
<div className="error-state__details">
|
||||
<h4>{getErrorMessage(error)}</h4>
|
||||
<DynamicComponent
|
||||
name="ErrorMessageDetails"
|
||||
fallback={<ErrorMessageDetails {...errorDetailsProps} />}
|
||||
{...errorDetailsProps}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,4 +65,5 @@ export default function ErrorMessage({ error }) {
|
||||
|
||||
ErrorMessage.propTypes = {
|
||||
error: PropTypes.object.isRequired,
|
||||
message: PropTypes.string,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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,
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isFunction, startsWith, trimStart, trimEnd } from "lodash";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import React, { useState, useEffect, useRef, useContext } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import UniversalRouter from "universal-router";
|
||||
import ErrorBoundary from "@redash/viz/lib/components/ErrorBoundary";
|
||||
@@ -14,6 +14,12 @@ function generateRouteKey() {
|
||||
.substr(2);
|
||||
}
|
||||
|
||||
export const CurrentRouteContext = React.createContext(null);
|
||||
|
||||
export function useCurrentRoute() {
|
||||
return useContext(CurrentRouteContext);
|
||||
}
|
||||
|
||||
export function stripBase(href) {
|
||||
// Resolve provided link and '' (root) relative to document's base.
|
||||
// If provided href is not related to current document (does not
|
||||
@@ -53,7 +59,7 @@ export default function Router({ routes, onRouteChange }) {
|
||||
errorHandlerRef.current.reset();
|
||||
}
|
||||
|
||||
const pathname = stripBase(location.path);
|
||||
const pathname = stripBase(location.path) || "/";
|
||||
|
||||
// This is a optimization for route resolver: if current route was already resolved
|
||||
// from this path - do nothing. It also prevents router from using outdated route in a case
|
||||
@@ -109,9 +115,11 @@ export default function Router({ routes, onRouteChange }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<CurrentRouteContext.Provider value={currentRoute}>
|
||||
<ErrorBoundary ref={errorHandlerRef} renderError={error => <ErrorMessage error={error} />}>
|
||||
{currentRoute.render(currentRoute)}
|
||||
</ErrorBoundary>
|
||||
</CurrentRouteContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function handleNavigationIntent(event) {
|
||||
}
|
||||
element = element.parentNode;
|
||||
}
|
||||
if (!element || !element.hasAttribute("href") || element.hasAttribute("download")) {
|
||||
if (!element || !element.hasAttribute("href") || element.hasAttribute("download") || element.dataset.skipRouter) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState, useContext } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
|
||||
import { Auth } from "@/services/auth";
|
||||
import { Auth, clientConfig } from "@/services/auth";
|
||||
|
||||
// This wrapper modifies `route.render` function and instead of passing `currentRoute` passes an object
|
||||
// that contains:
|
||||
@@ -33,7 +33,7 @@ function ApiKeySessionWrapper({ apiKey, currentRoute, renderChildren }) {
|
||||
};
|
||||
}, [apiKey]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
if (!isAuthenticated || clientConfig.disablePublicUrls) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import ErrorBoundary, { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
|
||||
import { Auth } from "@/services/auth";
|
||||
import organizationStatus from "@/services/organizationStatus";
|
||||
import ApplicationLayout from "./ApplicationLayout";
|
||||
import ErrorMessage from "./ErrorMessage";
|
||||
|
||||
// This wrapper modifies `route.render` function and instead of passing `currentRoute` passes an object
|
||||
// that contains:
|
||||
// - `currentRoute.routeParams`
|
||||
// - `pageTitle` field which is equal to `currentRoute.title`
|
||||
// - `onError` field which is a `handleError` method of nearest error boundary
|
||||
|
||||
function UserSessionWrapper({ bodyClass, currentRoute, renderChildren }) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(!!Auth.isAuthenticated());
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
Promise.all([Auth.requireSession(), organizationStatus.refresh()])
|
||||
.then(() => {
|
||||
if (!isCancelled) {
|
||||
setIsAuthenticated(!!Auth.isAuthenticated());
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCancelled) {
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (bodyClass) {
|
||||
document.body.classList.toggle(bodyClass, true);
|
||||
return () => {
|
||||
document.body.classList.toggle(bodyClass, false);
|
||||
};
|
||||
}
|
||||
}, [bodyClass]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ApplicationLayout>
|
||||
<React.Fragment key={currentRoute.key}>
|
||||
<ErrorBoundary renderError={error => <ErrorMessage error={error} />}>
|
||||
<ErrorBoundaryContext.Consumer>
|
||||
{({ handleError }) =>
|
||||
renderChildren({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError })
|
||||
}
|
||||
</ErrorBoundaryContext.Consumer>
|
||||
</ErrorBoundary>
|
||||
</React.Fragment>
|
||||
</ApplicationLayout>
|
||||
);
|
||||
}
|
||||
|
||||
UserSessionWrapper.propTypes = {
|
||||
bodyClass: PropTypes.string,
|
||||
renderChildren: PropTypes.func,
|
||||
};
|
||||
|
||||
UserSessionWrapper.defaultProps = {
|
||||
bodyClass: null,
|
||||
renderChildren: () => null,
|
||||
};
|
||||
|
||||
export default function routeWithUserSession({ render, bodyClass, ...rest }) {
|
||||
return {
|
||||
...rest,
|
||||
render: currentRoute => (
|
||||
<UserSessionWrapper bodyClass={bodyClass} currentRoute={currentRoute} renderChildren={render} />
|
||||
),
|
||||
};
|
||||
}
|
||||
108
client/app/components/ApplicationArea/routeWithUserSession.tsx
Normal file
108
client/app/components/ApplicationArea/routeWithUserSession.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
// @ts-expect-error (Must be removed after adding @redash/viz typing)
|
||||
import ErrorBoundary, { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
|
||||
import { Auth } from "@/services/auth";
|
||||
import { policy } from "@/services/policy";
|
||||
import { CurrentRoute } from "@/services/routes";
|
||||
import organizationStatus from "@/services/organizationStatus";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
import ApplicationLayout from "./ApplicationLayout";
|
||||
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
|
||||
// that contains:
|
||||
// - `currentRoute.routeParams`
|
||||
// - `pageTitle` field which is equal to `currentRoute.title`
|
||||
// - `onError` field which is a `handleError` method of nearest error boundary
|
||||
|
||||
export function UserSessionWrapper<P>({ bodyClass, currentRoute, render }: UserSessionWrapperProps<P>) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(!!Auth.isAuthenticated());
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
Promise.all([Auth.requireSession(), organizationStatus.refresh(), policy.refresh()])
|
||||
.then(() => {
|
||||
if (!isCancelled) {
|
||||
setIsAuthenticated(!!Auth.isAuthenticated());
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCancelled) {
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (bodyClass) {
|
||||
document.body.classList.toggle(bodyClass, true);
|
||||
return () => {
|
||||
document.body.classList.toggle(bodyClass, false);
|
||||
};
|
||||
}
|
||||
}, [bodyClass]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ApplicationLayout>
|
||||
<React.Fragment key={currentRoute.key}>
|
||||
<ErrorBoundary renderError={(error: Error) => <ErrorMessage error={error} />}>
|
||||
<ErrorBoundaryContext.Consumer>
|
||||
{({ handleError }: { handleError: UserSessionWrapperRenderChildrenProps<P>["onError"] }) =>
|
||||
render({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError })
|
||||
}
|
||||
</ErrorBoundaryContext.Consumer>
|
||||
</ErrorBoundary>
|
||||
</React.Fragment>
|
||||
</ApplicationLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export type RouteWithUserSessionOptions<P> = {
|
||||
render: (props: UserSessionWrapperRenderChildrenProps<P>) => React.ReactNode;
|
||||
bodyClass?: string;
|
||||
title: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export const UserSessionWrapperDynamicComponentName = "UserSessionWrapper";
|
||||
|
||||
export default function routeWithUserSession<P extends {} = {}>({
|
||||
render: originalRender,
|
||||
bodyClass,
|
||||
...rest
|
||||
}: RouteWithUserSessionOptions<P>) {
|
||||
return {
|
||||
...rest,
|
||||
render: (currentRoute: CurrentRoute<P>) => {
|
||||
const props = {
|
||||
render: originalRender,
|
||||
bodyClass,
|
||||
currentRoute,
|
||||
};
|
||||
return (
|
||||
<DynamicComponent
|
||||
{...props}
|
||||
name={UserSessionWrapperDynamicComponentName}
|
||||
fallback={<UserSessionWrapper {...props} />}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import Card from "antd/lib/card";
|
||||
import Button from "antd/lib/button";
|
||||
import Typography from "antd/lib/typography";
|
||||
import { clientConfig } from "@/services/auth";
|
||||
import Link from "@/components/Link";
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
import OrgSettings from "@/services/organizationSettings";
|
||||
@@ -65,8 +66,8 @@ function BeaconConsent() {
|
||||
</div>
|
||||
<div className="m-t-15">
|
||||
<Text type="secondary">
|
||||
You can change this setting anytime from the <a href="settings/organization">Organization Settings</a>{" "}
|
||||
page.
|
||||
You can change this setting anytime from the{" "}
|
||||
<Link href="settings/organization">Organization Settings</Link> page.
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Button from "antd/lib/button";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined";
|
||||
import "./CodeBlock.less";
|
||||
|
||||
export default class CodeBlock extends React.Component {
|
||||
@@ -59,7 +60,7 @@ export default class CodeBlock extends React.Component {
|
||||
|
||||
const copyButton = (
|
||||
<Tooltip title={this.state.copied || "Copy"}>
|
||||
<Button icon="copy" type="dashed" size="small" onClick={this.copy} />
|
||||
<Button icon={<CopyOutlinedIcon />} type="dashed" size="small" onClick={this.copy} />
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import Modal from "antd/lib/modal";
|
||||
import Input from "antd/lib/input";
|
||||
import Steps from "antd/lib/steps";
|
||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||
import Link from "@/components/Link";
|
||||
import { PreviewCard } from "@/components/PreviewCard";
|
||||
import EmptyState from "@/components/items-list/components/EmptyState";
|
||||
import DynamicForm from "@/components/dynamic-form/DynamicForm";
|
||||
@@ -118,9 +119,9 @@ class CreateSourceDialog extends React.Component {
|
||||
{selectedType.type === "databricks" && (
|
||||
<small>
|
||||
By using the Databricks Data Source you agree to the Databricks JDBC/ODBC{" "}
|
||||
<a href="https://databricks.com/spark/odbc-driver-download" target="_blank" rel="noopener noreferrer">
|
||||
<Link href="https://databricks.com/spark/odbc-driver-download" target="_blank" rel="noopener noreferrer">
|
||||
Driver Download Terms and Conditions
|
||||
</a>
|
||||
</Link>
|
||||
.
|
||||
</small>
|
||||
)}
|
||||
@@ -154,7 +155,7 @@ class CreateSourceDialog extends React.Component {
|
||||
footer={
|
||||
currentStep === StepEnum.SELECT_TYPE
|
||||
? [
|
||||
<Button key="cancel" onClick={() => dialog.dismiss()}>
|
||||
<Button key="cancel" onClick={() => dialog.dismiss()} data-test="CreateSourceCancelButton">
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button key="submit" type="primary" disabled>
|
||||
@@ -171,7 +172,7 @@ class CreateSourceDialog extends React.Component {
|
||||
form="sourceForm"
|
||||
type="primary"
|
||||
loading={savingSource}
|
||||
data-test="CreateSourceButton">
|
||||
data-test="CreateSourceSaveButton">
|
||||
Create
|
||||
</Button>,
|
||||
]
|
||||
|
||||
30
client/app/components/DialogWrapper.d.ts
vendored
Normal file
30
client/app/components/DialogWrapper.d.ts
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ModalProps } from "antd/lib/modal/Modal";
|
||||
|
||||
export interface DialogProps<ROk, RCancel> {
|
||||
props: ModalProps;
|
||||
close: (result: ROk) => void;
|
||||
dismiss: (result: RCancel) => void;
|
||||
}
|
||||
|
||||
export type DialogWrapperChildProps<ROk, RCancel> = {
|
||||
dialog: DialogProps<ROk, RCancel>;
|
||||
};
|
||||
|
||||
export type DialogComponentType<ROk = void, P = {}, RCancel = void> = React.ComponentType<
|
||||
DialogWrapperChildProps<ROk, RCancel> & P
|
||||
>;
|
||||
|
||||
export function wrap<ROk = void, P = {}, RCancel = void>(
|
||||
DialogComponent: DialogComponentType<ROk, P, RCancel>
|
||||
): {
|
||||
Component: DialogComponentType<ROk, P, RCancel>;
|
||||
showModal: (
|
||||
props?: P
|
||||
) => {
|
||||
update: (props: P) => void;
|
||||
onClose: (handler: (result: ROk) => Promise<void> | void) => void;
|
||||
onDismiss: (handler: (result: RCancel) => Promise<void> | void) => void;
|
||||
close: (result: ROk) => void;
|
||||
dismiss: (result: RCancel) => void;
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isFunction, isString } from "lodash";
|
||||
import { isFunction, isString, isUndefined } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
@@ -24,6 +24,7 @@ export function unregisterComponent(name) {
|
||||
export default class DynamicComponent extends React.Component {
|
||||
static propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
fallback: PropTypes.node,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
@@ -40,10 +41,11 @@ export default class DynamicComponent extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { name, children, ...props } = this.props;
|
||||
const { name, children, fallback, ...props } = this.props;
|
||||
const RealComponent = componentsRegistry.get(name);
|
||||
if (!RealComponent) {
|
||||
return children;
|
||||
// return fallback if any, otherwise return children
|
||||
return isUndefined(fallback) ? children : fallback;
|
||||
}
|
||||
return <RealComponent {...props}>{children}</RealComponent>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { includes, words, capitalize, clone, isNull } from "lodash";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { includes, words, capitalize, clone, isNull, map, get, find } from "lodash";
|
||||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Checkbox from "antd/lib/checkbox";
|
||||
import Modal from "antd/lib/modal";
|
||||
@@ -11,6 +11,8 @@ import Divider from "antd/lib/divider";
|
||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||
import QuerySelector from "@/components/QuerySelector";
|
||||
import { Query } from "@/services/query";
|
||||
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
|
||||
import QueryBasedParameterMappingTable from "./query-based-parameter/QueryBasedParameterMappingTable";
|
||||
|
||||
const { Option } = Select;
|
||||
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
|
||||
@@ -69,17 +71,27 @@ NameInput.propTypes = {
|
||||
function EditParameterSettingsDialog(props) {
|
||||
const [param, setParam] = useState(clone(props.parameter));
|
||||
const [isNameValid, setIsNameValid] = useState(true);
|
||||
const [initialQuery, setInitialQuery] = useState();
|
||||
const [paramQuery, setParamQuery] = 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;
|
||||
|
||||
// fetch query by id
|
||||
const initialQueryId = useRef(props.parameter.queryId);
|
||||
useEffect(() => {
|
||||
const queryId = props.parameter.queryId;
|
||||
if (queryId) {
|
||||
Query.get({ id: queryId }).then(setInitialQuery);
|
||||
if (initialQueryId.current) {
|
||||
Query.get({ id: initialQueryId.current }).then(setParamQuery);
|
||||
}
|
||||
}, [props.parameter.queryId]);
|
||||
}, []);
|
||||
|
||||
function isFulfilled() {
|
||||
// name
|
||||
@@ -93,14 +105,20 @@ function EditParameterSettingsDialog(props) {
|
||||
}
|
||||
|
||||
// query
|
||||
if (param.type === "query" && !param.queryId) {
|
||||
if (param.type === "query") {
|
||||
if (!param.queryId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (find(mappingParameters, { existingMapping: { mappingType: QueryBasedParameterMappingType.UNDEFINED } })) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function onConfirm(e) {
|
||||
function onConfirm() {
|
||||
// update title to default
|
||||
if (!param.title) {
|
||||
// forced to do this cause param won't update in time for save
|
||||
@@ -109,8 +127,6 @@ function EditParameterSettingsDialog(props) {
|
||||
}
|
||||
|
||||
props.dialog.close(param);
|
||||
|
||||
e.preventDefault(); // stops form redirect
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -132,7 +148,7 @@ function EditParameterSettingsDialog(props) {
|
||||
{isNew ? "Add Parameter" : "OK"}
|
||||
</Button>,
|
||||
]}>
|
||||
<Form layout="horizontal" onSubmit={onConfirm} id="paramForm">
|
||||
<Form layout="horizontal" onFinish={onConfirm} id="paramForm">
|
||||
{isNew && (
|
||||
<NameInput
|
||||
name={param.name}
|
||||
@@ -142,7 +158,7 @@ function EditParameterSettingsDialog(props) {
|
||||
type={param.type}
|
||||
/>
|
||||
)}
|
||||
<Form.Item label="Title" {...formItemProps}>
|
||||
<Form.Item required label="Title" {...formItemProps}>
|
||||
<Input
|
||||
value={isNull(param.title) ? getDefaultTitle(param.name) : param.title}
|
||||
onChange={e => setParam({ ...param, title: e.target.value })}
|
||||
@@ -189,14 +205,28 @@ function EditParameterSettingsDialog(props) {
|
||||
</Form.Item>
|
||||
)}
|
||||
{param.type === "query" && (
|
||||
<Form.Item label="Query" help="Select query to load dropdown values from" {...formItemProps}>
|
||||
<Form.Item label="Query" help="Select query to load dropdown values from" required {...formItemProps}>
|
||||
<QuerySelector
|
||||
selectedQuery={initialQuery}
|
||||
onChange={q => setParam({ ...param, queryId: q && q.id })}
|
||||
selectedQuery={paramQuery}
|
||||
onChange={q => {
|
||||
if (q) {
|
||||
setParamQuery(q);
|
||||
setParam({ ...param, queryId: q.id, parameterMapping: {} });
|
||||
}
|
||||
}}
|
||||
type="select"
|
||||
/>
|
||||
</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") && (
|
||||
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
|
||||
<Checkbox
|
||||
@@ -0,0 +1,134 @@
|
||||
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: () => {},
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
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,7 +3,13 @@ import PropTypes from "prop-types";
|
||||
import Dropdown from "antd/lib/dropdown";
|
||||
import Menu from "antd/lib/menu";
|
||||
import Button from "antd/lib/button";
|
||||
import Icon from "antd/lib/icon";
|
||||
import { clientConfig } from "@/services/auth";
|
||||
|
||||
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";
|
||||
|
||||
@@ -13,14 +19,14 @@ export default function QueryControlDropdown(props) {
|
||||
{!props.query.isNew() && (!props.query.is_draft || !props.query.is_archived) && (
|
||||
<Menu.Item>
|
||||
<a target="_self" onClick={() => props.openAddToDashboardForm(props.selectedTab)}>
|
||||
<Icon type="plus-circle" theme="filled" /> Add to Dashboard
|
||||
<PlusCircleFilledIcon /> Add to Dashboard
|
||||
</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!props.query.isNew() && (
|
||||
{!clientConfig.disablePublicUrls && !props.query.isNew() && (
|
||||
<Menu.Item>
|
||||
<a onClick={() => props.showEmbedDialog(props.query, props.selectedTab)} data-test="ShowEmbedDialogButton">
|
||||
<Icon type="share-alt" /> Embed Elsewhere
|
||||
<ShareAltOutlinedIcon /> Embed Elsewhere
|
||||
</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
@@ -32,7 +38,7 @@ export default function QueryControlDropdown(props) {
|
||||
queryResult={props.queryResult}
|
||||
embed={props.embed}
|
||||
apiKey={props.apiKey}>
|
||||
<Icon type="file" /> Download as CSV File
|
||||
<FileOutlinedIcon /> Download as CSV File
|
||||
</QueryResultsLink>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
@@ -43,7 +49,7 @@ export default function QueryControlDropdown(props) {
|
||||
queryResult={props.queryResult}
|
||||
embed={props.embed}
|
||||
apiKey={props.apiKey}>
|
||||
<Icon type="file" /> Download as TSV File
|
||||
<FileOutlinedIcon /> Download as TSV File
|
||||
</QueryResultsLink>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
@@ -54,7 +60,7 @@ export default function QueryControlDropdown(props) {
|
||||
queryResult={props.queryResult}
|
||||
embed={props.embed}
|
||||
apiKey={props.apiKey}>
|
||||
<Icon type="file-excel" /> Download as Excel File
|
||||
<FileExcelOutlinedIcon /> Download as Excel File
|
||||
</QueryResultsLink>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
@@ -63,7 +69,7 @@ export default function QueryControlDropdown(props) {
|
||||
return (
|
||||
<Dropdown trigger={["click"]} overlay={menu} overlayClassName="query-control-dropdown-overlay">
|
||||
<Button data-test="QueryControlDropdownButton">
|
||||
<Icon type="ellipsis" rotate={90} />
|
||||
<EllipsisOutlinedIcon rotate={90} />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Link from "@/components/Link";
|
||||
|
||||
export default function QueryResultsLink(props) {
|
||||
let href = "";
|
||||
@@ -17,9 +18,9 @@ export default function QueryResultsLink(props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<a target="_blank" rel="noopener noreferrer" disabled={props.disabled} href={href} download>
|
||||
<Link target="_blank" rel="noopener noreferrer" disabled={props.disabled} href={href} download>
|
||||
{props.children}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Button from "antd/lib/button";
|
||||
import Icon from "antd/lib/icon";
|
||||
import FormOutlinedIcon from "@ant-design/icons/FormOutlined";
|
||||
|
||||
export default function EditVisualizationButton(props) {
|
||||
return (
|
||||
@@ -9,7 +9,7 @@ export default function EditVisualizationButton(props) {
|
||||
data-test="EditVisualization"
|
||||
className="edit-visualization"
|
||||
onClick={() => props.openVisualizationEditor(props.selectedTab)}>
|
||||
<Icon type="form" />
|
||||
<FormOutlinedIcon />
|
||||
<span className="hidden-xs hidden-s hidden-m">Edit Visualization</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { startsWith, get } from "lodash";
|
||||
import { startsWith, get, some, mapValues } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import cx from "classnames";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
import Drawer from "antd/lib/drawer";
|
||||
import Icon from "antd/lib/icon";
|
||||
import Link from "@/components/Link";
|
||||
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
|
||||
import BigMessage from "@/components/BigMessage";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
import DynamicComponent, { registerComponent } from "@/components/DynamicComponent";
|
||||
|
||||
import "./HelpTrigger.less";
|
||||
|
||||
@@ -15,7 +16,8 @@ const HELP_PATH = "/help";
|
||||
const IFRAME_TIMEOUT = 20000;
|
||||
const IFRAME_URL_UPDATE_MESSAGE = "iframe_url";
|
||||
|
||||
export const TYPES = {
|
||||
export const TYPES = mapValues(
|
||||
{
|
||||
HOME: ["", "Help"],
|
||||
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"],
|
||||
@@ -25,7 +27,10 @@ export const TYPES = {
|
||||
DS_BIGQUERY: ["/data-sources/bigquery-setup", "Guide: Help Setting up BigQuery"],
|
||||
DS_URL: ["/data-sources/querying-urls", "Guide: Help Setting up URL"],
|
||||
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: [
|
||||
"/data-sources/querying-a-google-spreadsheet",
|
||||
"Guide: Help Setting up Google Spreadsheets",
|
||||
],
|
||||
DS_GOOGLE_ANALYTICS: ["/data-sources/google-analytics-setup", "Guide: Help Setting up Google Analytics"],
|
||||
DS_AXIBASETSD: ["/data-sources/axibase-time-series-database", "Guide: Help Setting up Axibase Time Series"],
|
||||
DS_RESULTS: ["/user-guide/querying/query-results-data-source", "Guide: Help Setting up Query Results"],
|
||||
@@ -38,27 +43,43 @@ export const TYPES = {
|
||||
"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]
|
||||
);
|
||||
|
||||
export default class HelpTrigger extends React.Component {
|
||||
static propTypes = {
|
||||
type: PropTypes.oneOf(Object.keys(TYPES)),
|
||||
const HelpTriggerPropTypes = {
|
||||
type: PropTypes.string,
|
||||
href: PropTypes.string,
|
||||
title: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
showTooltip: PropTypes.bool,
|
||||
renderAsLink: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
const HelpTriggerDefaultProps = {
|
||||
type: null,
|
||||
href: null,
|
||||
title: null,
|
||||
className: null,
|
||||
showTooltip: true,
|
||||
renderAsLink: false,
|
||||
children: <i className="fa fa-question-circle" />,
|
||||
};
|
||||
|
||||
export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName = null) {
|
||||
return class HelpTrigger extends React.Component {
|
||||
static propTypes = {
|
||||
...HelpTriggerPropTypes,
|
||||
type: PropTypes.oneOf(Object.keys(types)),
|
||||
};
|
||||
|
||||
static defaultProps = HelpTriggerDefaultProps;
|
||||
|
||||
iframeRef = React.createRef();
|
||||
|
||||
iframeLoadingTimeout = null;
|
||||
@@ -95,7 +116,7 @@ export default class HelpTrigger extends React.Component {
|
||||
};
|
||||
|
||||
onPostMessageReceived = event => {
|
||||
if (!startsWith(event.origin, DOMAIN)) {
|
||||
if (!some(allowedDomains, domain => startsWith(event.origin, domain))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -108,14 +129,18 @@ export default class HelpTrigger extends React.Component {
|
||||
};
|
||||
|
||||
getUrl = () => {
|
||||
const helpTriggerType = get(TYPES, this.props.type);
|
||||
return helpTriggerType ? DOMAIN + HELP_PATH + helpTriggerType[0] : this.props.href;
|
||||
const helpTriggerType = get(types, this.props.type);
|
||||
return helpTriggerType ? helpTriggerType[0] : this.props.href;
|
||||
};
|
||||
|
||||
openDrawer = () => {
|
||||
openDrawer = e => {
|
||||
// keep "open in new tab" behavior
|
||||
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
this.setState({ visible: true });
|
||||
// wait for drawer animation to complete so there's no animation jank
|
||||
setTimeout(() => this.loadIframe(this.getUrl()), 300);
|
||||
}
|
||||
};
|
||||
|
||||
closeDrawer = event => {
|
||||
@@ -127,11 +152,16 @@ export default class HelpTrigger extends React.Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const tooltip = get(TYPES, `${this.props.type}[1]`, this.props.title);
|
||||
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 = startsWith(url || this.getUrl(), DOMAIN);
|
||||
const isAllowedDomain = some(allowedDomains, domain => startsWith(url || targetUrl, domain));
|
||||
const shouldRenderAsLink = this.props.renderAsLink || !isAllowedDomain;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
@@ -140,26 +170,25 @@ export default class HelpTrigger extends React.Component {
|
||||
this.props.showTooltip ? (
|
||||
<>
|
||||
{tooltip}
|
||||
{!isAllowedDomain && <i className="fa fa-external-link" style={{ marginLeft: 5 }} />}
|
||||
{shouldRenderAsLink && <i className="fa fa-external-link" style={{ marginLeft: 5 }} />}
|
||||
</>
|
||||
) : null
|
||||
}>
|
||||
{isAllowedDomain ? (
|
||||
<a onClick={this.openDrawer} className={className}>
|
||||
<Link
|
||||
href={url || this.getUrl()}
|
||||
className={className}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
onClick={shouldRenderAsLink ? () => {} : this.openDrawer}>
|
||||
{this.props.children}
|
||||
</a>
|
||||
) : (
|
||||
<a href={url || this.getUrl()} className={className} rel="noopener noreferrer" target="_blank">
|
||||
{this.props.children}
|
||||
</a>
|
||||
)}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
<Drawer
|
||||
placement="right"
|
||||
closable={false}
|
||||
onClose={this.closeDrawer}
|
||||
visible={this.state.visible}
|
||||
className="help-drawer"
|
||||
className={cx("help-drawer", drawerClassName)}
|
||||
destroyOnClose
|
||||
width={400}>
|
||||
<div className="drawer-wrapper">
|
||||
@@ -167,14 +196,14 @@ export default class HelpTrigger extends React.Component {
|
||||
{url && (
|
||||
<Tooltip title="Open page in a new window" placement="left">
|
||||
{/* eslint-disable-next-line react/jsx-no-target-blank */}
|
||||
<a href={url} target="_blank">
|
||||
<Link href={url} target="_blank">
|
||||
<i className="fa fa-external-link" />
|
||||
</a>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Close" placement="bottom">
|
||||
<a onClick={this.closeDrawer}>
|
||||
<Icon type="close" />
|
||||
<CloseOutlinedIcon />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -183,7 +212,7 @@ export default class HelpTrigger extends React.Component {
|
||||
{!this.state.error && (
|
||||
<iframe
|
||||
ref={this.iframeRef}
|
||||
title="Redash Help"
|
||||
title="Usage Help"
|
||||
src="about:blank"
|
||||
className={cx({ ready: !this.state.loading })}
|
||||
onLoad={this.onIframeLoaded}
|
||||
@@ -201,9 +230,9 @@ export default class HelpTrigger extends React.Component {
|
||||
Something went wrong.
|
||||
<br />
|
||||
{/* eslint-disable-next-line react/jsx-no-target-blank */}
|
||||
<a href={this.state.error} target="_blank" rel="noopener">
|
||||
<Link href={this.state.error} target="_blank" rel="noopener">
|
||||
Click here
|
||||
</a>{" "}
|
||||
</Link>{" "}
|
||||
to open the page in a new window.
|
||||
</BigMessage>
|
||||
)}
|
||||
@@ -215,4 +244,14 @@ export default class HelpTrigger extends React.Component {
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
registerComponent("HelpTrigger", helpTriggerWithTypes(TYPES, [DOMAIN]));
|
||||
|
||||
export default function HelpTrigger(props) {
|
||||
return <DynamicComponent {...props} name="HelpTrigger" />;
|
||||
}
|
||||
|
||||
HelpTrigger.propTypes = HelpTriggerPropTypes;
|
||||
HelpTrigger.defaultProps = HelpTriggerDefaultProps;
|
||||
|
||||
57
client/app/components/InputPopover/index.jsx
Normal file
57
client/app/components/InputPopover/index.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
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: () => {},
|
||||
};
|
||||
37
client/app/components/InputPopover/index.less
Normal file
37
client/app/components/InputPopover/index.less
Normal file
@@ -0,0 +1,37 @@
|
||||
@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 Input from "antd/lib/input";
|
||||
import Icon from "antd/lib/icon";
|
||||
import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
|
||||
export default class InputWithCopy extends React.Component {
|
||||
@@ -42,7 +42,7 @@ export default class InputWithCopy extends React.Component {
|
||||
render() {
|
||||
const copyButton = (
|
||||
<Tooltip title={this.state.copied || "Copy"}>
|
||||
<Icon type="copy" style={{ cursor: "pointer" }} onClick={this.copy} />
|
||||
<CopyOutlinedIcon style={{ cursor: "pointer" }} onClick={this.copy} />
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
|
||||
26
client/app/components/Link.jsx
Normal file
26
client/app/components/Link.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
import Button from "antd/lib/button";
|
||||
|
||||
function DefaultLinkComponent(props) {
|
||||
return <a {...props} />; // eslint-disable-line jsx-a11y/anchor-has-content
|
||||
}
|
||||
|
||||
function Link(props) {
|
||||
return <Link.Component {...props} />;
|
||||
}
|
||||
|
||||
Link.Component = DefaultLinkComponent;
|
||||
|
||||
function DefaultButtonLinkComponent(props) {
|
||||
return <Button role="button" {...props} />;
|
||||
}
|
||||
|
||||
function ButtonLink(props) {
|
||||
return <ButtonLink.Component {...props} />;
|
||||
}
|
||||
|
||||
ButtonLink.Component = DefaultButtonLinkComponent;
|
||||
|
||||
Link.Button = ButtonLink;
|
||||
|
||||
export default Link;
|
||||
@@ -8,7 +8,6 @@ import Select from "antd/lib/select";
|
||||
import Table from "antd/lib/table";
|
||||
import Popover from "antd/lib/popover";
|
||||
import Button from "antd/lib/button";
|
||||
import Icon from "antd/lib/icon";
|
||||
import Tag from "antd/lib/tag";
|
||||
import Input from "antd/lib/input";
|
||||
import Radio from "antd/lib/radio";
|
||||
@@ -18,11 +17,15 @@ import ParameterValueInput from "@/components/ParameterValueInput";
|
||||
import { ParameterMappingType } from "@/services/widget";
|
||||
import { Parameter, cloneParameter } from "@/services/parameters";
|
||||
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";
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
export const MappingType = {
|
||||
DashboardAddNew: "dashboard-add-new",
|
||||
DashboardMapToExisting: "dashboard-map-to-existing",
|
||||
@@ -181,7 +184,7 @@ export class ParameterMappingInput extends React.Component {
|
||||
Existing dashboard parameter{" "}
|
||||
{noExisting ? (
|
||||
<Tooltip title="There are no dashboard parameters corresponding to this data type">
|
||||
<Icon type="question-circle" theme="filled" />
|
||||
<QuestionCircleFilledIcon />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Radio>
|
||||
@@ -204,19 +207,9 @@ export class ParameterMappingInput extends React.Component {
|
||||
|
||||
renderDashboardMapToExisting() {
|
||||
const { mapping, existingParamNames } = this.props;
|
||||
const options = map(existingParamNames, paramName => ({ label: paramName, value: paramName }));
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={mapping.mapTo}
|
||||
onChange={mapTo => this.updateParamMapping({ mapTo })}
|
||||
dropdownMatchSelectWidth={false}>
|
||||
{map(existingParamNames, name => (
|
||||
<Option value={name} key={name}>
|
||||
{name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
return <Select value={mapping.mapTo} onChange={mapTo => this.updateParamMapping({ mapTo })} options={options} />;
|
||||
}
|
||||
|
||||
renderStaticValue() {
|
||||
@@ -321,43 +314,34 @@ class MappingEditor extends React.Component {
|
||||
this.setState({ visible: false });
|
||||
};
|
||||
|
||||
renderContent() {
|
||||
const { mapping, inputError } = this.state;
|
||||
|
||||
render() {
|
||||
const { visible, mapping, inputError } = this.state;
|
||||
return (
|
||||
<div className="parameter-mapping-editor" data-test="EditParamMappingPopover">
|
||||
<header>
|
||||
<InputPopover
|
||||
placement="left"
|
||||
trigger="click"
|
||||
header={
|
||||
<>
|
||||
Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" />
|
||||
</header>
|
||||
</>
|
||||
}
|
||||
content={
|
||||
<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"
|
||||
trigger="click"
|
||||
content={this.renderContent()}
|
||||
onOk={this.save}
|
||||
onCancel={this.hide}
|
||||
okButtonProps={{ disabled: !!inputError }}
|
||||
visible={visible}
|
||||
onVisibleChange={this.onVisibleChange}>
|
||||
<Button size="small" type="dashed" data-test={`EditParamMappingButon-${mapping.param.name}`}>
|
||||
<Icon type="edit" />
|
||||
<Button size="small" type="dashed" data-test={`EditParamMappingButton-${mapping.param.name}`}>
|
||||
<EditOutlinedIcon />
|
||||
</Button>
|
||||
</Popover>
|
||||
</InputPopover>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -434,10 +418,10 @@ class TitleEditor extends React.Component {
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="small" type="dashed" onClick={this.hide}>
|
||||
<Icon type="close" />
|
||||
<CloseOutlinedIcon />
|
||||
</Button>
|
||||
<Button size="small" type="dashed" onClick={this.save}>
|
||||
<Icon type="check" />
|
||||
<CheckOutlinedIcon />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -460,7 +444,7 @@ class TitleEditor extends React.Component {
|
||||
visible={this.state.showPopup}
|
||||
onVisibleChange={this.onPopupVisibleChange}>
|
||||
<Button size="small" type="dashed">
|
||||
<Icon type="edit" />
|
||||
<EditOutlinedIcon />
|
||||
</Button>
|
||||
</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 {
|
||||
.keyword {
|
||||
@@ -22,48 +22,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
.text {
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
&.disabled, .fa {
|
||||
&.disabled,
|
||||
.fa {
|
||||
color: #a4a4a4;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { isEqual, isEmpty } from "lodash";
|
||||
import { isEqual, isEmpty, map } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Select from "antd/lib/select";
|
||||
import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll";
|
||||
import Input from "antd/lib/input";
|
||||
import InputNumber from "antd/lib/input-number";
|
||||
import DateParameter from "@/components/dynamic-parameters/DateParameter";
|
||||
@@ -10,8 +10,6 @@ import QueryBasedParameterInput from "./QueryBasedParameterInput";
|
||||
|
||||
import "./ParameterValueInput.less";
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
const multipleValuesProps = {
|
||||
maxTagCount: 3,
|
||||
maxTagTextLength: 10,
|
||||
@@ -98,25 +96,20 @@ class ParameterValueInput extends React.Component {
|
||||
const enumOptionsArray = enumOptions.split("\n").filter(v => v !== "");
|
||||
// Antd Select doesn't handle null in multiple mode
|
||||
const normalize = val => (parameter.multiValuesOptions && val === null ? [] : val);
|
||||
|
||||
return (
|
||||
<Select
|
||||
<SelectWithVirtualScroll
|
||||
className={this.props.className}
|
||||
mode={parameter.multiValuesOptions ? "multiple" : "default"}
|
||||
optionFilterProp="children"
|
||||
value={normalize(value)}
|
||||
onChange={this.onSelect}
|
||||
dropdownMatchSelectWidth={false}
|
||||
options={map(enumOptionsArray, opt => ({ label: String(opt), value: opt }))}
|
||||
showSearch
|
||||
showArrow
|
||||
style={{ minWidth: 60 }}
|
||||
notFoundContent={isEmpty(enumOptionsArray) ? "No options available" : null}
|
||||
{...multipleValuesProps}>
|
||||
{enumOptionsArray.map(option => (
|
||||
<Option key={option} value={option}>
|
||||
{option}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
{...multipleValuesProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,10 +17,11 @@
|
||||
}
|
||||
|
||||
&[data-dirty] {
|
||||
.@{ant-prefix}-input, // covers also ant date component
|
||||
.@{ant-prefix}-input,
|
||||
.@{ant-prefix}-input-number,
|
||||
.@{ant-prefix}-select-selection {
|
||||
background-color: @input-dirty;
|
||||
.@{ant-prefix}-select-selector,
|
||||
.@{ant-prefix}-picker {
|
||||
background-color: @input-dirty !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Parameter, createParameter } from "@/services/parameters";
|
||||
import ParameterApplyButton from "@/components/ParameterApplyButton";
|
||||
import ParameterValueInput from "@/components/ParameterValueInput";
|
||||
import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
|
||||
import { toHuman } from "@/lib/utils";
|
||||
|
||||
import "./Parameters.less";
|
||||
|
||||
@@ -121,7 +120,7 @@ export default class Parameters extends React.Component {
|
||||
return (
|
||||
<div key={param.name} className="di-block" data-test={`ParameterName-${param.name}`}>
|
||||
<div className="parameter-heading">
|
||||
<label>{param.title || toHuman(param.name)}</label>
|
||||
<label>{param.getTitle()}</label>
|
||||
{editable && (
|
||||
<button
|
||||
className="btn btn-default btn-xs m-l-5"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import classNames from "classnames";
|
||||
import Link from "@/components/Link";
|
||||
|
||||
// PreviewCard
|
||||
|
||||
@@ -42,7 +43,7 @@ PreviewCard.defaultProps = {
|
||||
// UserPreviewCard
|
||||
|
||||
export function UserPreviewCard({ user, withLink, children, ...props }) {
|
||||
const title = withLink ? <a href={"users/" + user.id}>{user.name}</a> : user.name;
|
||||
const title = withLink ? <Link href={"users/" + user.id}>{user.name}</Link> : user.name;
|
||||
return (
|
||||
<PreviewCard {...props} imageUrl={user.profile_image_url} title={title} body={user.email}>
|
||||
{children}
|
||||
@@ -68,8 +69,8 @@ UserPreviewCard.defaultProps = {
|
||||
// DataSourcePreviewCard
|
||||
|
||||
export function DataSourcePreviewCard({ dataSource, withLink, children, ...props }) {
|
||||
const imageUrl = `/static/images/db-logos/${dataSource.type}.png`;
|
||||
const title = withLink ? <a href={"data_sources/" + dataSource.id}>{dataSource.name}</a> : dataSource.name;
|
||||
const imageUrl = `static/images/db-logos/${dataSource.type}.png`;
|
||||
const title = withLink ? <Link href={"data_sources/" + dataSource.id}>{dataSource.name}</Link> : dataSource.name;
|
||||
return (
|
||||
<PreviewCard {...props} imageUrl={imageUrl} title={title}>
|
||||
{children}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { find, isArray, get, first, map, intersection, isEqual, isEmpty } from "lodash";
|
||||
import { find, isArray, get, first, map, intersection, isEqual, isEmpty, trim, debounce, isNil } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Select from "antd/lib/select";
|
||||
import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll";
|
||||
|
||||
const { Option } = Select;
|
||||
const SEARCH_DEBOUNCE_TIME = 300;
|
||||
|
||||
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 {
|
||||
static propTypes = {
|
||||
@@ -30,6 +39,7 @@ export default class QueryBasedParameterInput extends React.Component {
|
||||
options: [],
|
||||
value: null,
|
||||
loading: false,
|
||||
currentSearchTerm: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,9 +48,10 @@ export default class QueryBasedParameterInput extends React.Component {
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.queryId !== prevProps.queryId) {
|
||||
if (this.props.queryId !== prevProps.queryId || this.props.parameter !== prevProps.parameter) {
|
||||
this._loadOptions(this.props.queryId);
|
||||
}
|
||||
|
||||
if (this.props.value !== prevProps.value) {
|
||||
this.setValue(this.props.value);
|
||||
}
|
||||
@@ -48,26 +59,26 @@ export default class QueryBasedParameterInput extends React.Component {
|
||||
|
||||
setValue(value) {
|
||||
const { options } = this.state;
|
||||
if (this.props.mode === "multiple") {
|
||||
value = isArray(value) ? value : [value];
|
||||
const optionValues = map(options, option => option.value);
|
||||
const validValues = intersection(value, optionValues);
|
||||
this.setState({ value: validValues });
|
||||
return validValues;
|
||||
const { mode, parameter } = this.props;
|
||||
|
||||
if (mode === "multiple") {
|
||||
if (isNil(value)) {
|
||||
value = [];
|
||||
}
|
||||
const found = find(options, option => option.value === this.props.value) !== undefined;
|
||||
value = found ? value : get(first(options), "value");
|
||||
|
||||
value = isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
// parameters with search don't have options available, so we trust what we get
|
||||
if (!parameter.searchFunction) {
|
||||
value = filterValuesThatAreNotInOptions(value, options);
|
||||
}
|
||||
|
||||
this.setState({ value });
|
||||
return value;
|
||||
}
|
||||
|
||||
async _loadOptions(queryId) {
|
||||
if (queryId && queryId !== this.state.queryId) {
|
||||
this.setState({ loading: true });
|
||||
const options = await this.props.parameter.loadDropdownValues();
|
||||
|
||||
// stale queryId check
|
||||
if (this.props.queryId === queryId) {
|
||||
updateOptions(options) {
|
||||
this.setState({ options, loading: false }, () => {
|
||||
const updatedValue = this.setValue(this.props.value);
|
||||
if (!isEqual(updatedValue, this.props.value)) {
|
||||
@@ -75,33 +86,59 @@ export default class QueryBasedParameterInput extends React.Component {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async _loadOptions(queryId) {
|
||||
if (queryId && queryId !== this.state.queryId) {
|
||||
this.setState({ loading: true });
|
||||
const options = await this.props.parameter.loadDropdownValues(this.state.currentSearchTerm);
|
||||
|
||||
// stale queryId check
|
||||
if (this.props.queryId === queryId) {
|
||||
this.updateOptions(options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
const { className, value, mode, onSelect, ...otherProps } = this.props;
|
||||
const { parameter, className, mode, onSelect, queryId, value, ...otherProps } = this.props;
|
||||
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 (
|
||||
<span>
|
||||
<Select
|
||||
<SelectWithVirtualScroll
|
||||
className={className}
|
||||
disabled={loading}
|
||||
disabled={!parameter.searchFunction && loading}
|
||||
loading={loading}
|
||||
mode={mode}
|
||||
value={this.state.value}
|
||||
value={this.state.value || undefined}
|
||||
onChange={onSelect}
|
||||
dropdownMatchSelectWidth={false}
|
||||
options={options}
|
||||
optionFilterProp="children"
|
||||
showSearch
|
||||
showArrow
|
||||
notFoundContent={isEmpty(options) ? "No options available" : null}
|
||||
{...otherProps}>
|
||||
{options.map(option => (
|
||||
<Option value={option.value} key={option.value}>
|
||||
{option.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
{...selectProps}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { VisualizationType } from "@redash/viz/lib";
|
||||
import Link from "@/components/Link";
|
||||
import VisualizationName from "@/components/visualizations/VisualizationName";
|
||||
|
||||
import "./QueryLink.less";
|
||||
@@ -21,9 +22,9 @@ function QueryLink({ query, visualization, readOnly }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<a href={readOnly ? null : getUrl()} className="query-link">
|
||||
<Link href={readOnly ? null : getUrl()} className="query-link">
|
||||
<VisualizationName visualization={visualization} /> <span>{query.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
38
client/app/components/SelectWithVirtualScroll.tsx
Normal file
38
client/app/components/SelectWithVirtualScroll.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
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;
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import Menu from "antd/lib/menu";
|
||||
import PageHeader from "@/components/PageHeader";
|
||||
import Link from "@/components/Link";
|
||||
import location from "@/services/location";
|
||||
import settingsMenu from "@/services/settingsMenu";
|
||||
|
||||
@@ -17,9 +18,9 @@ function wrapSettingsTab(id, options, WrappedComponent) {
|
||||
<Menu selectedKeys={[activeItem && activeItem.title]} selectable={false} mode="horizontal">
|
||||
{settingsMenu.getAvailableItems().map(item => (
|
||||
<Menu.Item key={item.title}>
|
||||
<a href={item.path} data-test="SettingsScreenItem">
|
||||
<Link href={item.path} data-test="SettingsScreenItem">
|
||||
{item.title}
|
||||
</a>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
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,11 +1,42 @@
|
||||
@import '~@/assets/less/ant';
|
||||
@import "~@/assets/less/ant";
|
||||
|
||||
.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 {
|
||||
background-color: fade(@redash-gray, 10%);
|
||||
color: fade(@redash-gray, 75%);
|
||||
}
|
||||
|
||||
.ant-menu.ant-menu-inline {
|
||||
border: none;
|
||||
|
||||
.ant-menu-item {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-menu-item-selected {
|
||||
.ant-badge-count {
|
||||
background-color: @primary-color;
|
||||
@@ -13,3 +44,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
107
client/app/components/TagsList.tsx
Normal file
107
client/app/components/TagsList.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export default function TimeAgo({ date, placeholder, autoUpdate }) {
|
||||
export default function TimeAgo({ date, placeholder, autoUpdate, variation }) {
|
||||
const startDate = toMoment(date);
|
||||
const [value, setValue] = useState(null);
|
||||
const title = useMemo(() => (startDate ? startDate.format(clientConfig.dateTimeFormat) : null), [startDate]);
|
||||
@@ -28,6 +28,13 @@ export default function TimeAgo({ date, placeholder, autoUpdate }) {
|
||||
}
|
||||
}, [autoUpdate, startDate, placeholder]);
|
||||
|
||||
if (variation === "timeAgoInTooltip") {
|
||||
return (
|
||||
<Tooltip title={value}>
|
||||
<span data-test="TimeAgo">{title}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip title={title}>
|
||||
<span data-test="TimeAgo">{value}</span>
|
||||
@@ -39,6 +46,7 @@ TimeAgo.propTypes = {
|
||||
date: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.instanceOf(Date), Moment]),
|
||||
placeholder: PropTypes.string,
|
||||
autoUpdate: PropTypes.bool,
|
||||
variation: PropTypes.oneOf(["timeAgoInTooltip"]),
|
||||
};
|
||||
|
||||
TimeAgo.defaultProps = {
|
||||
|
||||
32
client/app/components/UserGroups.jsx
Normal file
32
client/app/components/UserGroups.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { map } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Tag from "antd/lib/tag";
|
||||
import Link from "@/components/Link";
|
||||
|
||||
import "./UserGroups.less";
|
||||
|
||||
export default function UserGroups({ groups, linkGroups, ...props }) {
|
||||
return (
|
||||
<div className="user-groups" {...props}>
|
||||
{map(groups, group => (
|
||||
<Tag key={group.id}>{linkGroups ? <Link href={`groups/${group.id}`}>{group.name}</Link> : group.name}</Tag>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
UserGroups.propTypes = {
|
||||
groups: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
|
||||
name: PropTypes.string,
|
||||
})
|
||||
),
|
||||
linkGroups: PropTypes.bool,
|
||||
};
|
||||
|
||||
UserGroups.defaultProps = {
|
||||
groups: [],
|
||||
linkGroups: true,
|
||||
};
|
||||
7
client/app/components/UserGroups.less
Normal file
7
client/app/components/UserGroups.less
Normal file
@@ -0,0 +1,7 @@
|
||||
.user-groups {
|
||||
margin: -5px 0 0 -5px;
|
||||
|
||||
.ant-tag {
|
||||
margin: 5px 0 0 5px;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Tabs from "antd/lib/tabs";
|
||||
import Menu from "antd/lib/menu";
|
||||
import PageHeader from "@/components/PageHeader";
|
||||
import Link from "@/components/Link";
|
||||
|
||||
import "./layout.less";
|
||||
|
||||
@@ -10,19 +11,19 @@ export default function Layout({ activeTab, children }) {
|
||||
<div className="admin-page-layout">
|
||||
<div className="container">
|
||||
<PageHeader title="Admin" />
|
||||
|
||||
<div className="bg-white tiled">
|
||||
<Tabs className="admin-page-layout-tabs" defaultActiveKey={activeTab} animated={false} tabBarGutter={0}>
|
||||
<Tabs.TabPane key="system_status" tab={<a href="admin/status">System Status</a>}>
|
||||
{activeTab === "system_status" ? children : null}
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="jobs" tab={<a href="admin/queries/jobs">RQ Status</a>}>
|
||||
{activeTab === "jobs" ? children : null}
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="outdated_queries" tab={<a href="admin/queries/outdated">Outdated Queries</a>}>
|
||||
{activeTab === "outdated_queries" ? children : null}
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
<Menu selectedKeys={[activeTab]} selectable={false} mode="horizontal">
|
||||
<Menu.Item key="system_status">
|
||||
<Link href="admin/status">System Status</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="jobs">
|
||||
<Link href="admin/queries/jobs">RQ Status</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="outdated_queries">
|
||||
<Link href="admin/queries/outdated">Outdated Queries</Link>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
.admin-page-layout {
|
||||
&-tabs.ant-tabs {
|
||||
> .ant-tabs-bar {
|
||||
margin: 0;
|
||||
|
||||
.ant-tabs-tab {
|
||||
padding: 0;
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
padding: 12px 16px;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ant-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import Input from "antd/lib/input";
|
||||
import { includes, isEmpty } from "lodash";
|
||||
import PropTypes from "prop-types";
|
||||
import React from "react";
|
||||
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 (
|
||||
<a key={`card${item.id}`} className="visual-card" onClick={item.onClick} href={item.href}>
|
||||
<img alt={item.title} src={item.imgSrc} />
|
||||
<h3>{item.title}</h3>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
80
client/app/components/cards-list/CardsList.tsx
Normal file
80
client/app/components/cards-list/CardsList.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
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,6 +238,7 @@ class DashboardGrid extends React.Component {
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveGridLayout
|
||||
draggableCancel="input"
|
||||
className={cx("layout", { "disable-animations": this.state.disableAnimations })}
|
||||
cols={{ [MULTI]: cfg.columns, [SINGLE]: 1 }}
|
||||
rowHeight={cfg.rowHeight - cfg.margins}
|
||||
|
||||
@@ -3,10 +3,11 @@ import PropTypes from "prop-types";
|
||||
import Button from "antd/lib/button";
|
||||
import Modal from "antd/lib/modal";
|
||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||
import { FiltersType } from "@/components/Filters";
|
||||
import VisualizationRenderer from "@/components/visualizations/VisualizationRenderer";
|
||||
import VisualizationName from "@/components/visualizations/VisualizationName";
|
||||
|
||||
function ExpandedWidgetDialog({ dialog, widget }) {
|
||||
function ExpandedWidgetDialog({ dialog, widget, filters }) {
|
||||
return (
|
||||
<Modal
|
||||
{...dialog.props}
|
||||
@@ -20,6 +21,7 @@ function ExpandedWidgetDialog({ dialog, widget }) {
|
||||
<VisualizationRenderer
|
||||
visualization={widget.visualization}
|
||||
queryResult={widget.getQueryResult()}
|
||||
filters={filters}
|
||||
context="widget"
|
||||
/>
|
||||
</Modal>
|
||||
@@ -29,6 +31,11 @@ function ExpandedWidgetDialog({ dialog, widget }) {
|
||||
ExpandedWidgetDialog.propTypes = {
|
||||
dialog: DialogPropType.isRequired,
|
||||
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
filters: FiltersType,
|
||||
};
|
||||
|
||||
ExpandedWidgetDialog.defaultProps = {
|
||||
filters: [],
|
||||
};
|
||||
|
||||
export default wrapDialog(ExpandedWidgetDialog);
|
||||
|
||||
@@ -7,6 +7,7 @@ import Modal from "antd/lib/modal";
|
||||
import Input from "antd/lib/input";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
import Divider from "antd/lib/divider";
|
||||
import Link from "@/components/Link";
|
||||
import HtmlContent from "@redash/viz/lib/components/HtmlContent";
|
||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||
import notification from "@/services/notification";
|
||||
@@ -78,9 +79,12 @@ function TextboxDialog({ dialog, isNew, ...props }) {
|
||||
/>
|
||||
<small>
|
||||
Supports basic{" "}
|
||||
<a target="_blank" rel="noopener noreferrer" href="https://www.markdownguide.org/cheat-sheet/#basic-syntax">
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://www.markdownguide.org/cheat-sheet/#basic-syntax">
|
||||
<Tooltip title="Markdown guide opens in new window">Markdown</Tooltip>
|
||||
</a>
|
||||
</Link>
|
||||
.
|
||||
</small>
|
||||
{text && (
|
||||
|
||||
@@ -48,10 +48,10 @@
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 85px;
|
||||
right: 15px;
|
||||
right: 0;
|
||||
background: linear-gradient(to bottom, transparent, transparent 2px, #f6f8f9 2px, #f6f8f9 5px),
|
||||
linear-gradient(to left, #b3babf, #b3babf 1px, transparent 1px, transparent);
|
||||
background-size: calc((100vw - 15px) / 6) 5px;
|
||||
background-size: calc((100% + 15px) / 6) 5px;
|
||||
background-position: -7px 1px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import HtmlContent from "@redash/viz/lib/components/HtmlContent";
|
||||
import { currentUser } from "@/services/auth";
|
||||
import recordEvent from "@/services/recordEvent";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import Link from "@/components/Link";
|
||||
import Parameters from "@/components/Parameters";
|
||||
import TimeAgo from "@/components/TimeAgo";
|
||||
import Timer from "@/components/Timer";
|
||||
@@ -30,27 +31,27 @@ function visualizationWidgetMenuOptions({ widget, canEditDashboard, onParameters
|
||||
return compact([
|
||||
<Menu.Item key="download_csv" disabled={isQueryResultEmpty}>
|
||||
{!isQueryResultEmpty ? (
|
||||
<a href={downloadLink("csv")} download={downloadName("csv")} target="_self">
|
||||
<Link href={downloadLink("csv")} download={downloadName("csv")} target="_self">
|
||||
Download as CSV File
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
"Download as CSV File"
|
||||
)}
|
||||
</Menu.Item>,
|
||||
<Menu.Item key="download_tsv" disabled={isQueryResultEmpty}>
|
||||
{!isQueryResultEmpty ? (
|
||||
<a href={downloadLink("tsv")} download={downloadName("tsv")} target="_self">
|
||||
<Link href={downloadLink("tsv")} download={downloadName("tsv")} target="_self">
|
||||
Download as TSV File
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
"Download as TSV File"
|
||||
)}
|
||||
</Menu.Item>,
|
||||
<Menu.Item key="download_excel" disabled={isQueryResultEmpty}>
|
||||
{!isQueryResultEmpty ? (
|
||||
<a href={downloadLink("xlsx")} download={downloadName("xlsx")} target="_self">
|
||||
<Link href={downloadLink("xlsx")} download={downloadName("xlsx")} target="_self">
|
||||
Download as Excel File
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
"Download as Excel File"
|
||||
)}
|
||||
@@ -58,7 +59,7 @@ function visualizationWidgetMenuOptions({ widget, canEditDashboard, onParameters
|
||||
(canViewQuery || canEditParameters) && <Menu.Divider key="divider" />,
|
||||
canViewQuery && (
|
||||
<Menu.Item key="view_query">
|
||||
<a href={widget.getQuery().getUrl(true, widget.visualization.id)}>View Query</a>
|
||||
<Link href={widget.getQuery().getUrl(true, widget.visualization.id)}>View Query</Link>
|
||||
</Menu.Item>
|
||||
),
|
||||
canEditParameters && (
|
||||
@@ -208,7 +209,10 @@ class VisualizationWidget extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { localParameters: props.widget.getLocalParameters() };
|
||||
this.state = {
|
||||
localParameters: props.widget.getLocalParameters(),
|
||||
localFilters: props.filters,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -218,8 +222,12 @@ class VisualizationWidget extends React.Component {
|
||||
onLoad();
|
||||
}
|
||||
|
||||
onLocalFiltersChange = localFilters => {
|
||||
this.setState({ localFilters });
|
||||
};
|
||||
|
||||
expandWidget = () => {
|
||||
ExpandedWidgetDialog.showModal({ widget: this.props.widget });
|
||||
ExpandedWidgetDialog.showModal({ widget: this.props.widget, filters: this.state.localFilters });
|
||||
};
|
||||
|
||||
editParameterMappings = () => {
|
||||
@@ -259,6 +267,7 @@ class VisualizationWidget extends React.Component {
|
||||
visualization={widget.visualization}
|
||||
queryResult={widgetQueryResult}
|
||||
filters={filters}
|
||||
onFiltersChange={this.onLocalFiltersChange}
|
||||
context="widget"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
import React from "react";
|
||||
import React, { useState, useReducer, useCallback } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import cx from "classnames";
|
||||
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 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 { includes, isFunction, filter, find, difference, isEmpty, mapValues } from "lodash";
|
||||
import notification from "@/services/notification";
|
||||
import Collapse from "@/components/Collapse";
|
||||
import AceEditorInput from "@/components/AceEditorInput";
|
||||
import { toHuman } from "@/lib/utils";
|
||||
import { Field, Action, AntdForm } from "../proptypes";
|
||||
import DynamicFormField, { FieldType } from "./DynamicFormField";
|
||||
import getFieldLabel from "./getFieldLabel";
|
||||
import helper from "./dynamicFormHelper";
|
||||
|
||||
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 requiredRule = required;
|
||||
const minLengthRule = minLength && includes(["text", "email", "password"], type);
|
||||
@@ -31,20 +36,200 @@ const fieldRules = ({ type, required, minLength }) => {
|
||||
].filter(rule => rule);
|
||||
};
|
||||
|
||||
class DynamicForm extends React.Component {
|
||||
static propTypes = {
|
||||
function normalizeEmptyValuesToNull(fields, values) {
|
||||
return mapValues(values, (value, key) => {
|
||||
const { initialValue } = find(fields, { name: key }) || {};
|
||||
if ((initialValue === null || initialValue === undefined || initialValue === "") && value === "") {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
function DynamicFormFields({ fields, feedbackIcons, form }) {
|
||||
return fields.map(field => {
|
||||
const { name, type, initialValue, contentAfter } = field;
|
||||
const fieldLabel = getFieldLabel(field);
|
||||
|
||||
const formItemProps = {
|
||||
name,
|
||||
className: "m-b-10",
|
||||
hasFeedback: type !== "checkbox" && type !== "file" && feedbackIcons,
|
||||
label: type === "checkbox" ? "" : fieldLabel,
|
||||
rules: fieldRules(field),
|
||||
valuePropName: type === "checkbox" ? "checked" : "value",
|
||||
initialValue,
|
||||
};
|
||||
|
||||
if (type === "file") {
|
||||
formItemProps.valuePropName = "data-value";
|
||||
formItemProps.getValueFromEvent = e => {
|
||||
if (e && e.fileList[0]) {
|
||||
helper.getBase64(e.file).then(value => {
|
||||
form.setFieldsValue({ [name]: value });
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key={name}>
|
||||
<Form.Item {...formItemProps}>
|
||||
<DynamicFormField field={field} form={form} />
|
||||
</Form.Item>
|
||||
{isFunction(contentAfter) ? contentAfter(form.getFieldValue(name)) : contentAfter}
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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 = {
|
||||
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(Field),
|
||||
actions: PropTypes.arrayOf(Action),
|
||||
fields: PropTypes.arrayOf(FieldType),
|
||||
actions: PropTypes.arrayOf(ActionType),
|
||||
feedbackIcons: PropTypes.bool,
|
||||
hideSubmitButton: PropTypes.bool,
|
||||
defaultShowExtraFields: PropTypes.bool,
|
||||
saveText: PropTypes.string,
|
||||
onSubmit: PropTypes.func,
|
||||
form: AntdForm.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
DynamicForm.defaultProps = {
|
||||
id: null,
|
||||
fields: [],
|
||||
actions: [],
|
||||
@@ -54,259 +239,3 @@ class DynamicForm extends React.Component {
|
||||
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 });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
renderUpload(field, props) {
|
||||
const { getFieldDecorator, getFieldValue } = this.props.form;
|
||||
const { name, initialValue } = field;
|
||||
|
||||
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,
|
||||
decoratorOptions
|
||||
)(
|
||||
<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),
|
||||
valuePropName: type === "checkbox" ? "checked" : "value",
|
||||
initialValue,
|
||||
};
|
||||
|
||||
if (type === "checkbox") {
|
||||
return getFieldDecorator(name, options)(<Checkbox {...props}>{fieldLabel}</Checkbox>);
|
||||
} else if (type === "file") {
|
||||
return this.renderUpload(field, props);
|
||||
} else if (type === "select") {
|
||||
return this.renderSelect(field, props);
|
||||
} else if (type === "content") {
|
||||
return field.content;
|
||||
} 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 (
|
||||
<Form id={id} className="dynamic-form" layout="vertical" onSubmit={this.handleSubmit}>
|
||||
{this.renderFields(regularFields)}
|
||||
{!isEmpty(extraFields) && (
|
||||
<div className="extra-options">
|
||||
<Button
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Form.create()(DynamicForm);
|
||||
|
||||
82
client/app/components/dynamic-form/DynamicFormField.jsx
Normal file
82
client/app/components/dynamic-form/DynamicFormField.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
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 };
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from "react";
|
||||
import AceEditorInput from "@/components/AceEditorInput";
|
||||
|
||||
export default function AceEditorField({ form, field, ...otherProps }) {
|
||||
return <AceEditorInput {...otherProps} />;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function ContentField({ field }) {
|
||||
return field.content;
|
||||
}
|
||||
18
client/app/components/dynamic-form/fields/FileField.jsx
Normal file
18
client/app/components/dynamic-form/fields/FileField.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
6
client/app/components/dynamic-form/fields/InputField.jsx
Normal file
6
client/app/components/dynamic-form/fields/InputField.jsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import React from "react";
|
||||
import Input from "antd/lib/input";
|
||||
|
||||
export default function InputField({ form, field, ...otherProps }) {
|
||||
return <Input {...otherProps} />;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from "react";
|
||||
import InputNumber from "antd/lib/input-number";
|
||||
|
||||
export default function NumberField({ form, field, ...otherProps }) {
|
||||
return <InputNumber {...otherProps} />;
|
||||
}
|
||||
21
client/app/components/dynamic-form/fields/SelectField.jsx
Normal file
21
client/app/components/dynamic-form/fields/SelectField.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from "react";
|
||||
import Input from "antd/lib/input";
|
||||
|
||||
export default function TextAreaField({ form, field, ...otherProps }) {
|
||||
return <Input.TextArea {...otherProps} />;
|
||||
}
|
||||
8
client/app/components/dynamic-form/fields/index.js
Normal file
8
client/app/components/dynamic-form/fields/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
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";
|
||||
6
client/app/components/dynamic-form/getFieldLabel.js
Normal file
6
client/app/components/dynamic-form/getFieldLabel.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { toHuman } from "@/lib/utils";
|
||||
|
||||
export default function getFieldLabel(field) {
|
||||
const { title, name } = field;
|
||||
return title || toHuman(name);
|
||||
}
|
||||
@@ -1,14 +1,7 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import classNames from "classnames";
|
||||
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";
|
||||
import { getDynamicDateFromString } from "@/services/parameters/DateParameter";
|
||||
import DynamicDatePicker from "@/components/dynamic-parameters/DynamicDatePicker";
|
||||
|
||||
const DYNAMIC_DATE_OPTIONS = [
|
||||
{
|
||||
@@ -29,8 +22,11 @@ const DYNAMIC_DATE_OPTIONS = [
|
||||
},
|
||||
];
|
||||
|
||||
class DateParameter extends React.Component {
|
||||
static propTypes = {
|
||||
function DateParameter(props) {
|
||||
return <DynamicDatePicker dynamicButtonOptions={{ options: DYNAMIC_DATE_OPTIONS }} {...props} />;
|
||||
}
|
||||
|
||||
DateParameter.propTypes = {
|
||||
type: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||
@@ -38,7 +34,7 @@ class DateParameter extends React.Component {
|
||||
onSelect: PropTypes.func,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
DateParameter.defaultProps = {
|
||||
type: "",
|
||||
className: "",
|
||||
value: null,
|
||||
@@ -46,69 +42,4 @@ class DateParameter extends React.Component {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DateParameter;
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
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, 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";
|
||||
import { includes } from "lodash";
|
||||
import { getDynamicDateRangeFromString } from "@/services/parameters/DateRangeParameter";
|
||||
import DynamicDateRangePicker from "@/components/dynamic-parameters/DynamicDateRangePicker";
|
||||
|
||||
const DYNAMIC_DATE_OPTIONS = [
|
||||
{
|
||||
@@ -134,18 +128,12 @@ const DYNAMIC_DATETIME_OPTIONS = [
|
||||
...DYNAMIC_DATE_OPTIONS,
|
||||
];
|
||||
|
||||
const widthByType = {
|
||||
"date-range": 294,
|
||||
"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]);
|
||||
function DateRangeParameter(props) {
|
||||
const options = includes(props.type, "datetime-range") ? DYNAMIC_DATETIME_OPTIONS : DYNAMIC_DATE_OPTIONS;
|
||||
return <DynamicDateRangePicker {...props} dynamicButtonOptions={{ options }} />;
|
||||
}
|
||||
|
||||
class DateRangeParameter extends React.Component {
|
||||
static propTypes = {
|
||||
DateRangeParameter.propTypes = {
|
||||
type: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||
@@ -153,7 +141,7 @@ class DateRangeParameter extends React.Component {
|
||||
onSelect: PropTypes.func,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
DateRangeParameter.defaultProps = {
|
||||
type: "",
|
||||
className: "",
|
||||
value: null,
|
||||
@@ -161,70 +149,4 @@ class DateRangeParameter extends React.Component {
|
||||
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;
|
||||
|
||||
@@ -2,17 +2,20 @@ import React, { useRef } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { isFunction, get, findIndex } from "lodash";
|
||||
import Dropdown from "antd/lib/dropdown";
|
||||
import Icon from "antd/lib/icon";
|
||||
import Menu from "antd/lib/menu";
|
||||
import Typography from "antd/lib/typography";
|
||||
import { DynamicDateType } from "@/services/parameters/DateParameter";
|
||||
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";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function DynamicButton({ options, selectedDynamicValue, onSelect, enabled }) {
|
||||
function DynamicButton({ options, selectedDynamicValue, onSelect, enabled, staticValueLabel }) {
|
||||
const menu = (
|
||||
<Menu
|
||||
className="dynamic-menu"
|
||||
@@ -28,8 +31,8 @@ function DynamicButton({ options, selectedDynamicValue, onSelect, enabled }) {
|
||||
{enabled && <Menu.Divider />}
|
||||
{enabled && (
|
||||
<Menu.Item>
|
||||
<Icon type="arrow-left" />
|
||||
<Text type="secondary">Back to Static Value</Text>
|
||||
<ArrowLeftOutlinedIcon />
|
||||
<Text type="secondary">{staticValueLabel}</Text>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu>
|
||||
@@ -45,7 +48,13 @@ function DynamicButton({ options, selectedDynamicValue, onSelect, enabled }) {
|
||||
className="dynamic-button"
|
||||
placement="bottomRight"
|
||||
trigger={["click"]}
|
||||
icon={<Icon type="thunderbolt" theme={enabled ? "twoTone" : "outlined"} className="dynamic-icon" />}
|
||||
icon={
|
||||
enabled ? (
|
||||
<ThunderboltTwoToneIcon className="dynamic-icon" />
|
||||
) : (
|
||||
<ThunderboltOutlinedIcon className="dynamic-icon" />
|
||||
)
|
||||
}
|
||||
getPopupContainer={() => containerRef.current}
|
||||
data-test="DynamicButton"
|
||||
/>
|
||||
@@ -59,6 +68,7 @@ DynamicButton.propTypes = {
|
||||
selectedDynamicValue: PropTypes.oneOfType([DynamicDateType, DynamicDateRangeType]),
|
||||
onSelect: PropTypes.func,
|
||||
enabled: PropTypes.bool,
|
||||
staticValueLabel: PropTypes.string,
|
||||
};
|
||||
|
||||
DynamicButton.defaultProps = {
|
||||
@@ -66,6 +76,7 @@ DynamicButton.defaultProps = {
|
||||
selectedDynamicValue: null,
|
||||
onSelect: () => {},
|
||||
enabled: false,
|
||||
staticValueLabel: "Back to Static Value",
|
||||
};
|
||||
|
||||
export default DynamicButton;
|
||||
|
||||
@@ -34,3 +34,9 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.dynamic-icon {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
112
client/app/components/dynamic-parameters/DynamicDatePicker.jsx
Normal file
112
client/app/components/dynamic-parameters/DynamicDatePicker.jsx
Normal file
@@ -0,0 +1,112 @@
|
||||
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;
|
||||
@@ -0,0 +1,115 @@
|
||||
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,8 +1,28 @@
|
||||
@import '../../assets/less/inc/variables';
|
||||
@import "../../assets/less/inc/variables";
|
||||
|
||||
.date-range-parameter,
|
||||
.date-parameter {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.redash-datepicker {
|
||||
.ant-calendar-picker-clear {
|
||||
right: 35px;
|
||||
padding-right: 35px !important;
|
||||
|
||||
&.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;
|
||||
}
|
||||
|
||||
@@ -12,19 +32,22 @@
|
||||
|
||||
&.dynamic-value {
|
||||
& ::placeholder {
|
||||
color: @text-color !important;
|
||||
color: @input-color !important;
|
||||
}
|
||||
|
||||
&.date-range-input {
|
||||
.ant-calendar-range-picker-input {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
.ant-picker-active-bar {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ant-calendar-range-picker-separator,
|
||||
.ant-calendar-range-picker-input:not(:first-child) {
|
||||
.ant-picker-separator,
|
||||
.ant-picker-range-separator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-picker-input:not(:first-child) {
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,50 @@
|
||||
import React from "react";
|
||||
|
||||
export interface EmptyStateProps {
|
||||
type DefaultStepKey = "dataSources" | "queries" | "alerts" | "dashboards" | "users";
|
||||
export type StepKey<K> = DefaultStepKey | K;
|
||||
|
||||
export interface StepItem<K> {
|
||||
key: StepKey<K>;
|
||||
node: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface EmptyStateHelpMessageProps {
|
||||
helpTriggerType: string;
|
||||
}
|
||||
|
||||
export declare const EmptyStateHelpMessage: React.FunctionComponent<EmptyStateHelpMessageProps>;
|
||||
|
||||
export interface EmptyStateProps<K = unknown> {
|
||||
header?: string;
|
||||
icon?: string;
|
||||
description: string;
|
||||
illustration: string;
|
||||
helpLink: string;
|
||||
illustrationPath?: string;
|
||||
helpMessage?: React.ReactNode;
|
||||
closable?: boolean;
|
||||
onClose?: () => void;
|
||||
|
||||
onboardingMode?: boolean;
|
||||
showAlertStep?: boolean;
|
||||
showDashboardStep?: boolean;
|
||||
showDataSourceStep?: boolean;
|
||||
showInviteStep?: boolean;
|
||||
|
||||
getStepsItems?: (items: Array<StepItem<K>>) => Array<StepItem<K>>;
|
||||
}
|
||||
|
||||
declare const EmptyState: React.FunctionComponent<EmptyStateProps>;
|
||||
declare class EmptyState<R> extends React.Component<EmptyStateProps<R>> {}
|
||||
|
||||
export default EmptyState;
|
||||
|
||||
export interface StepProps {
|
||||
show: boolean;
|
||||
completed: boolean;
|
||||
url?: string;
|
||||
urlTarget?: string;
|
||||
urlText?: React.ReactNode;
|
||||
text?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export declare const Step: React.FunctionComponent<StepProps>;
|
||||
|
||||
@@ -2,21 +2,24 @@ import { keys, some } from "lodash";
|
||||
import React, { useCallback } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import classNames from "classnames";
|
||||
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
|
||||
import Link from "@/components/Link";
|
||||
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
import { currentUser } from "@/services/auth";
|
||||
import organizationStatus from "@/services/organizationStatus";
|
||||
import "./empty-state.less";
|
||||
|
||||
function Step({ show, completed, text, url, urlText, onClick }) {
|
||||
export function Step({ show, completed, text, url, urlTarget, urlText, onClick }) {
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={classNames({ done: completed })}>
|
||||
<a href={url} onClick={onClick}>
|
||||
<Link href={url} onClick={onClick} target={urlTarget}>
|
||||
{urlText}
|
||||
</a>{" "}
|
||||
</Link>{" "}
|
||||
{text}
|
||||
</li>
|
||||
);
|
||||
@@ -25,31 +28,54 @@ function Step({ show, completed, text, url, urlText, onClick }) {
|
||||
Step.propTypes = {
|
||||
show: PropTypes.bool.isRequired,
|
||||
completed: PropTypes.bool.isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
text: PropTypes.node,
|
||||
url: PropTypes.string,
|
||||
urlText: PropTypes.string,
|
||||
urlTarget: PropTypes.string,
|
||||
urlText: PropTypes.node,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
Step.defaultProps = {
|
||||
url: null,
|
||||
urlTarget: null,
|
||||
urlText: null,
|
||||
text: 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({
|
||||
icon,
|
||||
header,
|
||||
description,
|
||||
illustration,
|
||||
helpLink,
|
||||
helpMessage,
|
||||
closable,
|
||||
onClose,
|
||||
onboardingMode,
|
||||
showAlertStep,
|
||||
showDashboardStep,
|
||||
showDataSourceStep,
|
||||
showInviteStep,
|
||||
getStepsItems,
|
||||
illustrationPath,
|
||||
}) {
|
||||
const isAvailable = {
|
||||
dataSource: true,
|
||||
dataSource: showDataSourceStep,
|
||||
query: true,
|
||||
alert: showAlertStep,
|
||||
dashboard: showDashboardStep,
|
||||
@@ -75,7 +101,89 @@ function EmptyState({
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderDataSourcesStep = () => {
|
||||
if (currentUser.isAdmin) {
|
||||
return (
|
||||
<Step
|
||||
key="dataSources"
|
||||
show={isAvailable.dataSource}
|
||||
completed={isCompleted.dataSource}
|
||||
url="data_sources/new"
|
||||
urlText="Connect a Data Source"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Step
|
||||
key="dataSources"
|
||||
show={isAvailable.dataSource}
|
||||
completed={isCompleted.dataSource}
|
||||
text="Ask an account admin to connect a data source"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const defaultStepsItems = [
|
||||
{
|
||||
key: "dataSources",
|
||||
node: renderDataSourcesStep(),
|
||||
},
|
||||
{
|
||||
key: "queries",
|
||||
node: (
|
||||
<Step
|
||||
key="queries"
|
||||
show={isAvailable.query}
|
||||
completed={isCompleted.query}
|
||||
url="queries/new"
|
||||
urlText="Create your first Query"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "alerts",
|
||||
node: (
|
||||
<Step
|
||||
key="alerts"
|
||||
show={isAvailable.alert}
|
||||
completed={isCompleted.alert}
|
||||
url="alerts/new"
|
||||
urlText="Create your first Alert"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "dashboards",
|
||||
node: (
|
||||
<Step
|
||||
key="dashboards"
|
||||
show={isAvailable.dashboard}
|
||||
completed={isCompleted.dashboard}
|
||||
onClick={showCreateDashboardDialog}
|
||||
urlText="Create your first Dashboard"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "users",
|
||||
node: (
|
||||
<Step
|
||||
key="users"
|
||||
show={isAvailable.inviteUsers}
|
||||
completed={isCompleted.inviteUsers}
|
||||
url="users/new"
|
||||
urlText="Invite your team members"
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const stepsItems = getStepsItems ? getStepsItems(defaultStepsItems) : defaultStepsItems;
|
||||
const imageSource = illustrationPath ? illustrationPath : "static/images/illustrations/" + illustration + ".svg";
|
||||
|
||||
return (
|
||||
<div className="empty-state-wrapper">
|
||||
<div className="empty-state bg-white tiled">
|
||||
<div className="empty-state__summary">
|
||||
{header && <h4>{header}</h4>}
|
||||
@@ -83,69 +191,20 @@ function EmptyState({
|
||||
<i className={icon} />
|
||||
</h2>
|
||||
<p>{description}</p>
|
||||
<img
|
||||
src={"/static/images/illustrations/" + illustration + ".svg"}
|
||||
alt={illustration + " Illustration"}
|
||||
width="75%"
|
||||
/>
|
||||
<img src={imageSource} alt={illustration + " Illustration"} width="75%" />
|
||||
</div>
|
||||
<div className="empty-state__steps">
|
||||
<h4>Let's get started</h4>
|
||||
<ol>
|
||||
{currentUser.isAdmin && (
|
||||
<Step
|
||||
show={isAvailable.dataSource}
|
||||
completed={isCompleted.dataSource}
|
||||
url="data_sources/new"
|
||||
urlText="Connect"
|
||||
text="a Data Source"
|
||||
/>
|
||||
)}
|
||||
{!currentUser.isAdmin && (
|
||||
<Step
|
||||
show={isAvailable.dataSource}
|
||||
completed={isCompleted.dataSource}
|
||||
text="Ask an account admin to connect a data source"
|
||||
/>
|
||||
)}
|
||||
<Step
|
||||
show={isAvailable.query}
|
||||
completed={isCompleted.query}
|
||||
url="queries/new"
|
||||
urlText="Create"
|
||||
text="your first Query"
|
||||
/>
|
||||
<Step
|
||||
show={isAvailable.alert}
|
||||
completed={isCompleted.alert}
|
||||
url="alerts/new"
|
||||
urlText="Create"
|
||||
text="your first Alert"
|
||||
/>
|
||||
<Step
|
||||
show={isAvailable.dashboard}
|
||||
completed={isCompleted.dashboard}
|
||||
onClick={showCreateDashboardDialog}
|
||||
urlText="Create"
|
||||
text="your first Dashboard"
|
||||
/>
|
||||
<Step
|
||||
show={isAvailable.inviteUsers}
|
||||
completed={isCompleted.inviteUsers}
|
||||
url="users/new"
|
||||
urlText="Invite"
|
||||
text="your team members"
|
||||
/>
|
||||
</ol>
|
||||
<p>
|
||||
Need more support?{" "}
|
||||
<a href={helpLink} target="_blank" rel="noopener noreferrer">
|
||||
See our Help
|
||||
<i className="fa fa-external-link m-l-5" aria-hidden="true" />
|
||||
</a>
|
||||
</p>
|
||||
<ol>{stepsItems.map(item => item.node)}</ol>
|
||||
{helpMessage}
|
||||
</div>
|
||||
</div>
|
||||
{closable && (
|
||||
<a className="close-button" onClick={onClose}>
|
||||
<CloseOutlinedIcon />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,21 +213,31 @@ EmptyState.propTypes = {
|
||||
header: PropTypes.string,
|
||||
description: PropTypes.string.isRequired,
|
||||
illustration: PropTypes.string.isRequired,
|
||||
helpLink: PropTypes.string.isRequired,
|
||||
illustrationPath: PropTypes.string,
|
||||
helpMessage: PropTypes.node,
|
||||
closable: PropTypes.bool,
|
||||
onClose: PropTypes.func,
|
||||
|
||||
onboardingMode: PropTypes.bool,
|
||||
showAlertStep: PropTypes.bool,
|
||||
showDashboardStep: PropTypes.bool,
|
||||
showDataSourceStep: PropTypes.bool,
|
||||
showInviteStep: PropTypes.bool,
|
||||
|
||||
getStepItems: PropTypes.func,
|
||||
};
|
||||
|
||||
EmptyState.defaultProps = {
|
||||
icon: null,
|
||||
header: null,
|
||||
helpMessage: null,
|
||||
closable: false,
|
||||
onClose: () => {},
|
||||
|
||||
onboardingMode: false,
|
||||
showAlertStep: false,
|
||||
showDashboardStep: false,
|
||||
showDataSourceStep: true,
|
||||
showInviteStep: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import (reference, less) "~@/assets/less/ant";
|
||||
|
||||
// Empty states
|
||||
.empty-state {
|
||||
width: 100%;
|
||||
@@ -19,11 +21,14 @@
|
||||
padding-left: 0px;
|
||||
}
|
||||
|
||||
|
||||
.empty-state__summary {
|
||||
align-self: flex-start;
|
||||
text-align: center;
|
||||
background: rgba(102, 136, 153, 0.025);
|
||||
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
ol {
|
||||
@@ -44,10 +49,6 @@
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -71,3 +72,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,42 @@ import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import hoistNonReactStatics from "hoist-non-react-statics";
|
||||
import { clientConfig } from "@/services/auth";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
export interface PaginationOptions {
|
||||
page?: number;
|
||||
itemsPerPage?: number;
|
||||
}
|
||||
|
||||
export interface Controller<I, P = any> {
|
||||
params: P; // TODO: Find out what params is (except merging with props)
|
||||
|
||||
isLoaded: boolean;
|
||||
isEmpty: boolean;
|
||||
|
||||
// search
|
||||
searchTerm?: string;
|
||||
updateSearch: (searchTerm: string) => void;
|
||||
|
||||
// tags
|
||||
selectedTags: string[];
|
||||
updateSelectedTags: (selectedTags: string[]) => void;
|
||||
|
||||
// sorting
|
||||
orderByField?: string;
|
||||
orderByReverse: boolean;
|
||||
toggleSorting: (orderByField: string) => void;
|
||||
|
||||
// pagination
|
||||
page: number;
|
||||
itemsPerPage: number;
|
||||
totalItemsCount: number;
|
||||
pageSizeOptions: number[];
|
||||
pageItems: I[];
|
||||
updatePagination: (options: PaginationOptions) => void; // ({ page: number, itemsPerPage: number }) => void
|
||||
|
||||
handleError: (error: any) => void; // TODO: Find out if error is string or object or Exception.
|
||||
}
|
||||
|
||||
export const ControllerType = PropTypes.shape({
|
||||
// values of props declared by wrapped component and some additional props from items list
|
||||
@@ -35,15 +71,40 @@ export const ControllerType = PropTypes.shape({
|
||||
handleError: PropTypes.func.isRequired, // (error) => void
|
||||
});
|
||||
|
||||
export function wrap(WrappedComponent, createItemsSource, createStateStorage) {
|
||||
class ItemsListWrapper extends React.Component {
|
||||
export type GenericItemSourceError = AxiosError | Error;
|
||||
|
||||
export interface ItemsListWrapperProps {
|
||||
onError?: (error: AxiosError | Error) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface ItemsListWrapperState<I, P = any> extends Controller<I, P> {
|
||||
totalCount?: number;
|
||||
update: () => void;
|
||||
}
|
||||
|
||||
type ItemsSource = any; // TODO: Type ItemsSource
|
||||
type StateStorage = any; // TODO: Type StateStore
|
||||
|
||||
export interface ItemsListWrappedComponentProps<I, P = any> {
|
||||
controller: Controller<I, P>;
|
||||
}
|
||||
|
||||
export function wrap<I, P = any>(
|
||||
WrappedComponent: React.ComponentType<ItemsListWrappedComponentProps<I>>,
|
||||
createItemsSource: () => ItemsSource,
|
||||
createStateStorage: () => StateStorage
|
||||
) {
|
||||
class ItemsListWrapper extends React.Component<ItemsListWrapperProps, ItemsListWrapperState<I, P>> {
|
||||
private _itemsSource: ItemsSource;
|
||||
|
||||
static propTypes = {
|
||||
onError: PropTypes.func,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
onError: error => {
|
||||
onError: (error: GenericItemSourceError) => {
|
||||
// Allow calling chain to roll up, and then throw the error in global context
|
||||
setTimeout(() => {
|
||||
throw error;
|
||||
@@ -52,7 +113,7 @@ export function wrap(WrappedComponent, createItemsSource, createStateStorage) {
|
||||
children: null,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
constructor(props: ItemsListWrapperProps) {
|
||||
super(props);
|
||||
|
||||
const stateStorage = createStateStorage();
|
||||
@@ -73,7 +134,9 @@ export function wrap(WrappedComponent, createItemsSource, createStateStorage) {
|
||||
this.setState(this.getState({ ...state, isLoaded: true }));
|
||||
};
|
||||
|
||||
itemsSource.onError = error => this.props.onError(error);
|
||||
itemsSource.onError = (error: GenericItemSourceError) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
this.props.onError!(error);
|
||||
|
||||
const initialState = this.getState({ ...itemsSource.getState(), isLoaded: false });
|
||||
const { updatePagination, toggleSorting, updateSearch, updateSelectedTags, update, handleError } = itemsSource;
|
||||
@@ -93,13 +156,22 @@ export function wrap(WrappedComponent, createItemsSource, createStateStorage) {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
this._itemsSource.onBeforeUpdate = () => {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
this._itemsSource.onAfterUpdate = () => {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
this._itemsSource.onError = () => {};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
getState({ isLoaded, totalCount, pageItems, params, ...rest }) {
|
||||
getState({
|
||||
isLoaded,
|
||||
totalCount,
|
||||
pageItems,
|
||||
params,
|
||||
...rest
|
||||
}: ItemsListWrapperState<I, P>): ItemsListWrapperState<I, P> {
|
||||
return {
|
||||
...rest,
|
||||
|
||||
@@ -110,9 +182,9 @@ export function wrap(WrappedComponent, createItemsSource, createStateStorage) {
|
||||
|
||||
isLoaded,
|
||||
isEmpty: !isLoaded || totalCount === 0,
|
||||
totalItemsCount: isLoaded ? totalCount : 0,
|
||||
pageSizeOptions: clientConfig.pageSizeOptions,
|
||||
pageItems: isLoaded ? pageItems : [],
|
||||
totalItemsCount: totalCount || 0,
|
||||
pageSizeOptions: (clientConfig as any).pageSizeOptions, // TODO: Type auth.js
|
||||
pageItems: pageItems || [],
|
||||
};
|
||||
}
|
||||
|
||||
51
client/app/components/items-list/classes/ItemsSource.d.ts
vendored
Normal file
51
client/app/components/items-list/classes/ItemsSource.d.ts
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface ItemsSourceOptions<I = any> extends Partial<ItemsSourceState> {
|
||||
getRequest?: (params: any, context: any) => any; // TODO: Add stricter types
|
||||
doRequest?: () => any; // TODO: Add stricter type
|
||||
processResults?: () => any; // TODO: Add stricter type
|
||||
isPlainList?: boolean;
|
||||
sortByIteratees?: { [fieldName: string]: (a: I) => number };
|
||||
}
|
||||
|
||||
export interface GetResourceContext extends ItemsSourceState {
|
||||
params: {
|
||||
currentPage: number;
|
||||
// TODO: Add more context parameters
|
||||
};
|
||||
}
|
||||
|
||||
export type GetResourceRequest = any; // TODO: Add stricter type
|
||||
|
||||
export interface ItemsPage<INPUT = any> {
|
||||
count: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
results: INPUT[];
|
||||
}
|
||||
|
||||
export interface ResourceItemsSourceOptions<INPUT = any, ITEM = any> extends ItemsSourceOptions {
|
||||
getResource: (context: GetResourceContext) => (request: GetResourceRequest) => Promise<INPUT[]>;
|
||||
getItemProcessor?: () => (input: INPUT) => ITEM;
|
||||
}
|
||||
|
||||
export type ItemsSourceState<ITEM = any> = {
|
||||
page: number;
|
||||
itemsPerPage: number;
|
||||
orderByField: string;
|
||||
orderByReverse: boolean;
|
||||
searchTerm: string;
|
||||
selectedTags: string[];
|
||||
totalCount: number;
|
||||
pageItems: ITEM[];
|
||||
allItems: ITEM[] | undefined;
|
||||
params: {
|
||||
pageTitle?: string;
|
||||
} & { [key: string]: string | number };
|
||||
};
|
||||
|
||||
declare class ItemsSource {
|
||||
constructor(options: ItemsSourceOptions);
|
||||
}
|
||||
|
||||
declare class ResourceItemsSource<I> {
|
||||
constructor(options: ResourceItemsSourceOptions<I>);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export class ItemsSource {
|
||||
|
||||
onError = null;
|
||||
|
||||
sortByIteratees = undefined;
|
||||
|
||||
getCallbackContext = () => null;
|
||||
|
||||
_beforeUpdate() {
|
||||
@@ -41,21 +43,34 @@ export class ItemsSource {
|
||||
extend(customParams, params);
|
||||
},
|
||||
};
|
||||
return this._beforeUpdate().then(() =>
|
||||
this._fetcher
|
||||
return this._beforeUpdate().then(() => {
|
||||
const fetchToken = Math.random()
|
||||
.toString(36)
|
||||
.substr(2);
|
||||
this._currentFetchToken = fetchToken;
|
||||
return this._fetcher
|
||||
.fetch(changes, state, context)
|
||||
.then(({ results, count, allResults }) => {
|
||||
if (this._currentFetchToken === fetchToken) {
|
||||
this._pageItems = results;
|
||||
this._allItems = allResults || null;
|
||||
this._paginator.setTotalCount(count);
|
||||
this._params = { ...this._params, ...customParams };
|
||||
return this._afterUpdate();
|
||||
}
|
||||
})
|
||||
.catch(error => this.handleError(error))
|
||||
);
|
||||
.catch(error => this.handleError(error));
|
||||
});
|
||||
}
|
||||
|
||||
constructor({ getRequest, doRequest, processResults, isPlainList = false, ...defaultState }) {
|
||||
constructor({
|
||||
getRequest,
|
||||
doRequest,
|
||||
processResults,
|
||||
isPlainList = false,
|
||||
sortByIteratees = undefined,
|
||||
...defaultState
|
||||
}) {
|
||||
if (!isFunction(getRequest)) {
|
||||
getRequest = identity;
|
||||
}
|
||||
@@ -64,6 +79,8 @@ export class ItemsSource {
|
||||
? new PlainListFetcher({ getRequest, doRequest, processResults })
|
||||
: new PaginatedListFetcher({ getRequest, doRequest, processResults });
|
||||
|
||||
this.sortByIteratees = sortByIteratees;
|
||||
|
||||
this.setState(defaultState);
|
||||
this._pageItems = [];
|
||||
|
||||
@@ -87,7 +104,7 @@ export class ItemsSource {
|
||||
|
||||
setState(state) {
|
||||
this._paginator = new Paginator(state);
|
||||
this._sorter = new Sorter(state);
|
||||
this._sorter = new Sorter(state, this.sortByIteratees);
|
||||
|
||||
this._searchTerm = state.searchTerm || "";
|
||||
this._selectedTags = state.selectedTags || [];
|
||||
|
||||
@@ -24,6 +24,8 @@ export default class Sorter {
|
||||
|
||||
reverse = false;
|
||||
|
||||
sortByIteratees = null;
|
||||
|
||||
get compiled() {
|
||||
return compile(this.field, this.reverse);
|
||||
}
|
||||
@@ -42,9 +44,10 @@ export default class Sorter {
|
||||
this.reverse = !!value; // cast to boolean
|
||||
}
|
||||
|
||||
constructor({ orderByField, orderByReverse } = {}) {
|
||||
constructor({ orderByField, orderByReverse } = {}, sortByIteratees = undefined) {
|
||||
this.setField(orderByField);
|
||||
this.setReverse(orderByReverse);
|
||||
this.sortByIteratees = sortByIteratees;
|
||||
}
|
||||
|
||||
toggleField(field) {
|
||||
@@ -61,7 +64,8 @@ export default class Sorter {
|
||||
|
||||
sort(items) {
|
||||
if (this.field) {
|
||||
items = sortBy(items, this.field);
|
||||
const customIteratee = this.sortByIteratees && this.sortByIteratees[this.field];
|
||||
items = sortBy(items, customIteratee ? [customIteratee] : this.field);
|
||||
if (this.reverse) {
|
||||
items.reverse();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { isFunction, map, filter, extend, omit, identity } from "lodash";
|
||||
import { isFunction, map, filter, extend, omit, identity, range, isEmpty } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import classNames from "classnames";
|
||||
import Table from "antd/lib/table";
|
||||
import Skeleton from "antd/lib/skeleton";
|
||||
import FavoritesControl from "@/components/FavoritesControl";
|
||||
import TimeAgo from "@/components/TimeAgo";
|
||||
import { durationHumanize, formatDate, formatDateTime } from "@/lib/utils";
|
||||
@@ -66,10 +67,10 @@ export const Columns = {
|
||||
overrides
|
||||
);
|
||||
},
|
||||
timeAgo(overrides) {
|
||||
timeAgo(overrides, timeAgoCustomProps = undefined) {
|
||||
return extend(
|
||||
{
|
||||
render: value => <TimeAgo date={value} />,
|
||||
render: value => <TimeAgo date={value} {...timeAgoCustomProps} />,
|
||||
},
|
||||
overrides
|
||||
);
|
||||
@@ -109,6 +110,8 @@ export default class ItemsTable extends React.Component {
|
||||
orderByField: PropTypes.string,
|
||||
orderByReverse: PropTypes.bool,
|
||||
toggleSorting: PropTypes.func,
|
||||
"data-test": PropTypes.string,
|
||||
rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@@ -141,7 +144,7 @@ export default class ItemsTable extends React.Component {
|
||||
|
||||
return extend(omit(column, ["field", "orderByField", "render"]), {
|
||||
key: "column" + index,
|
||||
dataIndex: "item[" + JSON.stringify(column.field) + "]",
|
||||
dataIndex: ["item", column.field],
|
||||
defaultSortOrder: column.orderByField === orderByField ? orderByDirection : null,
|
||||
onHeaderCell,
|
||||
render,
|
||||
@@ -150,9 +153,22 @@ 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() {
|
||||
const columns = this.prepareColumns();
|
||||
const rows = map(this.props.items, (item, index) => ({ key: "row" + index, item }));
|
||||
const tableDataProps = {
|
||||
columns: this.prepareColumns(),
|
||||
dataSource: map(this.props.items, (item, index) => ({ key: "row" + index, item })),
|
||||
};
|
||||
|
||||
// Bind events only if `onRowClick` specified
|
||||
const onTableRow = isFunction(this.props.onRowClick)
|
||||
@@ -164,17 +180,28 @@ export default class ItemsTable extends React.Component {
|
||||
: null;
|
||||
|
||||
const { showHeader } = this.props;
|
||||
if (this.props.loading) {
|
||||
if (isEmpty(tableDataProps.dataSource)) {
|
||||
tableDataProps.columns = tableDataProps.columns.map(column => ({
|
||||
...column,
|
||||
sorter: false,
|
||||
render: () => <Skeleton active paragraph={false} />,
|
||||
}));
|
||||
tableDataProps.dataSource = range(10).map(key => ({ key: `${key}` }));
|
||||
} else {
|
||||
tableDataProps.loading = { indicator: null };
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Table
|
||||
className={classNames("table-data", { "ant-table-headerless": !showHeader })}
|
||||
loading={this.props.loading}
|
||||
columns={columns}
|
||||
showHeader={showHeader}
|
||||
dataSource={rows}
|
||||
rowKey={row => row.key}
|
||||
rowKey={this.getRowKey}
|
||||
pagination={false}
|
||||
onRow={onTableRow}
|
||||
data-test={this.props["data-test"]}
|
||||
{...tableDataProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { useState, useCallback, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Input from "antd/lib/input";
|
||||
import AntdMenu from "antd/lib/menu";
|
||||
import Link from "@/components/Link";
|
||||
import TagsList from "@/components/TagsList";
|
||||
|
||||
/*
|
||||
@@ -59,7 +60,7 @@ export function Menu({ items, selected }) {
|
||||
<AntdMenu className="invert-stripe-position" mode="inline" selectable={false} selectedKeys={[selected]}>
|
||||
{map(items, item => (
|
||||
<AntdMenu.Item key={item.key} className="m-0">
|
||||
<a href={item.href}>
|
||||
<Link href={item.href}>
|
||||
{isString(item.icon) && item.icon !== "" && (
|
||||
<span className="btn-favourite m-r-5">
|
||||
<i className={item.icon} aria-hidden="true" />
|
||||
@@ -67,7 +68,7 @@ export function Menu({ items, selected }) {
|
||||
)}
|
||||
{isFunction(item.icon) && (item.icon(item) || null)}
|
||||
{item.title}
|
||||
</a>
|
||||
</Link>
|
||||
</AntdMenu.Item>
|
||||
))}
|
||||
</AntdMenu>
|
||||
@@ -131,13 +132,13 @@ ProfileImage.propTypes = {
|
||||
Tags
|
||||
*/
|
||||
|
||||
export function Tags({ url, onChange }) {
|
||||
export function Tags({ url, onChange, showUnselectAll }) {
|
||||
if (url === "") {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="m-b-10">
|
||||
<TagsList tagsUrl={url} onUpdate={onChange} />
|
||||
<TagsList tagsUrl={url} onUpdate={onChange} showUnselectAll={showUnselectAll} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -145,4 +146,6 @@ export function Tags({ url, onChange }) {
|
||||
Tags.propTypes = {
|
||||
url: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
showUnselectAll: PropTypes.bool,
|
||||
unselectAllButtonTitle: PropTypes.string,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
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]
|
||||
);
|
||||
}
|
||||
@@ -42,7 +42,7 @@ Content.defaultProps = defaultProps;
|
||||
|
||||
// Layout
|
||||
|
||||
export default function Layout({ className, children, ...props }) {
|
||||
export default function Layout({ children, className = undefined, ...props }) {
|
||||
return (
|
||||
<div className={classNames("layout-with-sidebar", className)} {...props}>
|
||||
{children}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
margin: 0;
|
||||
|
||||
> .layout-content {
|
||||
flex: 0 0 auto;
|
||||
flex: 1 0 auto;
|
||||
width: 75%;
|
||||
order: 0;
|
||||
margin: 0;
|
||||
@@ -18,6 +18,7 @@
|
||||
> .layout-sidebar {
|
||||
flex: 0 0 auto;
|
||||
width: 25%;
|
||||
max-width: 350px;
|
||||
order: 1;
|
||||
margin: 0;
|
||||
padding: 0 0 0 @spacing;
|
||||
@@ -34,6 +35,7 @@
|
||||
|
||||
> .layout-sidebar {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
order: 0;
|
||||
margin: 0 0 @spacing 0;
|
||||
padding: 0;
|
||||
|
||||
@@ -31,53 +31,6 @@ export const RefreshScheduleDefault = {
|
||||
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({
|
||||
id: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user