Compare commits

..

2 Commits

Author SHA1 Message Date
Gabriel Dutra
db5e4510f2 test with released eventlet 2020-07-27 22:14:44 -03:00
Gabriel Dutra
6393f1c588 Test eventlet 2020-07-27 21:30:20 -03:00
347 changed files with 6937 additions and 22408 deletions

View File

@@ -1,12 +1,12 @@
FROM cypress/browsers:node14.0.0-chrome84 FROM cypress/browsers:chrome67
ENV APP /usr/src/app ENV APP /usr/src/app
WORKDIR $APP WORKDIR $APP
COPY package.json package-lock.json $APP/ COPY package.json $APP/package.json
COPY viz-lib $APP/viz-lib RUN npm run cypress:install > /dev/null
RUN npm ci > /dev/null
COPY . $APP COPY client/cypress $APP/client/cypress
COPY cypress.json $APP/cypress.json
RUN ./node_modules/.bin/cypress verify RUN ./node_modules/.bin/cypress verify

View File

@@ -57,9 +57,6 @@ jobs:
- store_artifacts: - store_artifacts:
path: coverage.xml path: coverage.xml
frontend-lint: frontend-lint:
environment:
CYPRESS_INSTALL_BINARY: 0
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
docker: docker:
- image: circleci/node:12 - image: circleci/node:12
steps: steps:
@@ -70,9 +67,6 @@ jobs:
- store_test_results: - store_test_results:
path: /tmp/test-results path: /tmp/test-results
frontend-unit-tests: frontend-unit-tests:
environment:
CYPRESS_INSTALL_BINARY: 0
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
docker: docker:
- image: circleci/node:12 - image: circleci/node:12
steps: steps:
@@ -96,20 +90,11 @@ jobs:
PERCY_TOKEN_ENCODED: ZGRiY2ZmZDQ0OTdjMzM5ZWE0ZGQzNTZiOWNkMDRjOTk4Zjg0ZjMxMWRmMDZiM2RjOTYxNDZhOGExMjI4ZDE3MA== PERCY_TOKEN_ENCODED: ZGRiY2ZmZDQ0OTdjMzM5ZWE0ZGQzNTZiOWNkMDRjOTk4Zjg0ZjMxMWRmMDZiM2RjOTYxNDZhOGExMjI4ZDE3MA==
CYPRESS_PROJECT_ID_ENCODED: OTI0Y2th CYPRESS_PROJECT_ID_ENCODED: OTI0Y2th
CYPRESS_RECORD_KEY_ENCODED: YzA1OTIxMTUtYTA1Yy00NzQ2LWEyMDMtZmZjMDgwZGI2ODgx CYPRESS_RECORD_KEY_ENCODED: YzA1OTIxMTUtYTA1Yy00NzQ2LWEyMDMtZmZjMDgwZGI2ODgx
CYPRESS_INSTALL_BINARY: 0
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
docker: docker:
- image: circleci/node:12 - image: circleci/node:12
steps: steps:
- setup_remote_docker - setup_remote_docker
- checkout - checkout
- run:
name: Enable Code Coverage report for master branch
command: |
if [ "$CIRCLE_BRANCH" = "master" ]; then
echo 'export CODE_COVERAGE=true' >> $BASH_ENV
source $BASH_ENV
fi
- run: - run:
name: Install npm dependencies name: Install npm dependencies
command: | command: |
@@ -128,13 +113,6 @@ jobs:
command: | command: |
docker-compose logs docker-compose logs
when: on_fail when: on_fail
- run:
name: Copy Code Coverage results
command: |
docker cp cypress:/usr/src/app/coverage ./coverage || true
when: always
- store_artifacts:
path: coverage
build-docker-image: *build-docker-image-job build-docker-image: *build-docker-image-job
build-preview-docker-image: *build-docker-image-job build-preview-docker-image: *build-docker-image-job
workflows: workflows:

View File

@@ -1,4 +1,4 @@
version: '2.2' version: '3'
services: services:
redash: redash:
build: ../ build: ../

View File

@@ -1,20 +1,7 @@
version: "2.2" version: '3'
x-redash-service: &redash-service
build:
context: ../
args:
skip_dev_deps: "true"
skip_ds_deps: "true"
code_coverage: ${CODE_COVERAGE}
x-redash-environment: &redash-environment
REDASH_LOG_LEVEL: "INFO"
REDASH_REDIS_URL: "redis://redis:6379/0"
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
REDASH_RATELIMIT_ENABLED: "false"
REDASH_ENFORCE_CSRF: "true"
services: services:
server: server:
<<: *redash-service build: ../
command: server command: server
depends_on: depends_on:
- postgres - postgres
@@ -22,25 +9,29 @@ services:
ports: ports:
- "5000:5000" - "5000:5000"
environment: environment:
<<: *redash-environment
PYTHONUNBUFFERED: 0 PYTHONUNBUFFERED: 0
REDASH_LOG_LEVEL: "INFO"
REDASH_REDIS_URL: "redis://redis:6379/0"
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
REDASH_RATELIMIT_ENABLED: "false"
scheduler: scheduler:
<<: *redash-service build: ../
command: scheduler command: scheduler
depends_on: depends_on:
- server - server
environment: environment:
<<: *redash-environment REDASH_REDIS_URL: "redis://redis:6379/0"
worker: worker:
<<: *redash-service build: ../
command: worker command: worker
depends_on: depends_on:
- server - server
environment: environment:
<<: *redash-environment
PYTHONUNBUFFERED: 0 PYTHONUNBUFFERED: 0
REDASH_LOG_LEVEL: "INFO"
REDASH_REDIS_URL: "redis://redis:6379/0"
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
cypress: cypress:
ipc: host
build: build:
context: ../ context: ../
dockerfile: .circleci/Dockerfile.cypress dockerfile: .circleci/Dockerfile.cypress
@@ -50,7 +41,6 @@ services:
- scheduler - scheduler
environment: environment:
CYPRESS_baseUrl: "http://server:5000" CYPRESS_baseUrl: "http://server:5000"
CYPRESS_coverage: ${CODE_COVERAGE}
PERCY_TOKEN: ${PERCY_TOKEN} PERCY_TOKEN: ${PERCY_TOKEN}
PERCY_BRANCH: ${CIRCLE_BRANCH} PERCY_BRANCH: ${CIRCLE_BRANCH}
PERCY_COMMIT: ${CIRCLE_SHA1} PERCY_COMMIT: ${CIRCLE_SHA1}

2
.gitignore vendored
View File

@@ -5,8 +5,6 @@ venv/
.coveralls.yml .coveralls.yml
.idea .idea
*.pyc *.pyc
.nyc_output
coverage
.coverage .coverage
coverage.xml coverage.xml
client/dist client/dist

View File

@@ -3,24 +3,13 @@ FROM node:12 as frontend-builder
# Controls whether to build the frontend assets # Controls whether to build the frontend assets
ARG skip_frontend_build ARG skip_frontend_build
ENV CYPRESS_INSTALL_BINARY=0
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
RUN useradd -m -d /frontend redash
USER redash
WORKDIR /frontend WORKDIR /frontend
COPY --chown=redash package.json package-lock.json /frontend/ COPY package.json package-lock.json /frontend/
COPY --chown=redash viz-lib /frontend/viz-lib COPY viz-lib /frontend/viz-lib
# Controls whether to instrument code for coverage information
ARG code_coverage
ENV BABEL_ENV=${code_coverage:+test}
RUN if [ "x$skip_frontend_build" = "x" ] ; then npm ci --unsafe-perm; fi RUN if [ "x$skip_frontend_build" = "x" ] ; then npm ci --unsafe-perm; fi
COPY --chown=redash client /frontend/client COPY client /frontend/client
COPY --chown=redash webpack.config.js /frontend/ COPY webpack.config.js /frontend/
RUN if [ "x$skip_frontend_build" = "x" ] ; then npm run build; else mkdir -p /frontend/client/dist && touch /frontend/client/dist/multi_org.html && touch /frontend/client/dist/index.html; fi RUN if [ "x$skip_frontend_build" = "x" ] ; then npm run build; else mkdir -p /frontend/client/dist && touch /frontend/client/dist/multi_org.html && touch /frontend/client/dist/index.html; fi
FROM python:3.7-slim FROM python:3.7-slim

View File

@@ -35,7 +35,7 @@ backend-unit-tests: up test_db
docker-compose run --rm --name tests server tests docker-compose run --rm --name tests server tests
frontend-unit-tests: bundle frontend-unit-tests: bundle
CYPRESS_INSTALL_BINARY=0 PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 npm ci npm ci
npm run bundle npm run bundle
npm test npm test

View File

@@ -19,7 +19,7 @@ worker() {
export WORKERS_COUNT=${WORKERS_COUNT:-2} export WORKERS_COUNT=${WORKERS_COUNT:-2}
export QUEUES=${QUEUES:-} export QUEUES=${QUEUES:-}
exec supervisord -c worker.conf supervisord -c worker.conf
} }
dev_worker() { dev_worker() {
@@ -32,7 +32,7 @@ server() {
# Recycle gunicorn workers every n-th request. See http://docs.gunicorn.org/en/stable/settings.html#max-requests for more details. # Recycle gunicorn workers every n-th request. See http://docs.gunicorn.org/en/stable/settings.html#max-requests for more details.
MAX_REQUESTS=${MAX_REQUESTS:-1000} MAX_REQUESTS=${MAX_REQUESTS:-1000}
MAX_REQUESTS_JITTER=${MAX_REQUESTS_JITTER:-100} MAX_REQUESTS_JITTER=${MAX_REQUESTS_JITTER:-100}
exec /usr/local/bin/gunicorn -b 0.0.0.0:5000 --name redash -w${REDASH_WEB_WORKERS:-4} redash.wsgi:app --max-requests $MAX_REQUESTS --max-requests-jitter $MAX_REQUESTS_JITTER exec /usr/local/bin/gunicorn -b 0.0.0.0:5000 --name redash -w${REDASH_WEB_WORKERS:-4} redash.wsgi:app --max-requests $MAX_REQUESTS --max-requests-jitter $MAX_REQUESTS_JITTER --worker-class eventlet
} }
create_db() { create_db() {

View File

@@ -1,29 +1,20 @@
{ {
"presets": [ "presets": [
[ ["@babel/preset-env", {
"@babel/preset-env", "exclude": [
{ "@babel/plugin-transform-async-to-generator",
"exclude": ["@babel/plugin-transform-async-to-generator", "@babel/plugin-transform-arrow-functions"], "@babel/plugin-transform-arrow-functions"
"corejs": "2",
"useBuiltIns": "usage"
}
], ],
"useBuiltIns": "usage"
}],
"@babel/preset-react", "@babel/preset-react",
"@babel/preset-typescript" "@babel/preset-typescript"
], ],
"plugins": [ "plugins": [
"@babel/plugin-proposal-class-properties", "@babel/plugin-proposal-class-properties",
"@babel/plugin-transform-object-assign", "@babel/plugin-transform-object-assign",
[ ["babel-plugin-transform-builtin-extend", {
"babel-plugin-transform-builtin-extend",
{
"globals": ["Error"] "globals": ["Error"]
} }]
] ]
],
"env": {
"test": {
"plugins": ["istanbul"]
}
}
} }

View File

@@ -20,21 +20,6 @@ module.exports = {
// allow debugger during development // allow debugger during development
"no-debugger": process.env.NODE_ENV === "production" ? 2 : 0, "no-debugger": process.env.NODE_ENV === "production" ? 2 : 0,
"jsx-a11y/anchor-is-valid": "off", "jsx-a11y/anchor-is-valid": "off",
"no-restricted-imports": [
"error",
{
paths: [
{
name: "antd",
message: "Please use 'import XXX from antd/lib/XXX' import instead.",
},
{
name: "antd/lib",
message: "Please use 'import XXX from antd/lib/XXX' import instead.",
},
],
},
],
}, },
overrides: [ overrides: [
{ {
@@ -49,8 +34,6 @@ module.exports = {
// Do not complain about useless contructors in declaration files // Do not complain about useless contructors in declaration files
"no-useless-constructor": "off", "no-useless-constructor": "off",
"@typescript-eslint/no-useless-constructor": "error", "@typescript-eslint/no-useless-constructor": "error",
// Many API fields and generated types use camelcase
"@typescript-eslint/camelcase": "off",
}, },
}, },
], ],

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -16,6 +16,7 @@
@import "~antd/lib/pagination/style/index"; @import "~antd/lib/pagination/style/index";
@import "~antd/lib/table/style/index"; @import "~antd/lib/table/style/index";
@import "~antd/lib/popover/style/index"; @import "~antd/lib/popover/style/index";
@import "~antd/lib/icon/style/index";
@import "~antd/lib/tag/style/index"; @import "~antd/lib/tag/style/index";
@import "~antd/lib/grid/style/index"; @import "~antd/lib/grid/style/index";
@import "~antd/lib/switch/style/index"; @import "~antd/lib/switch/style/index";
@@ -30,7 +31,6 @@
@import "~antd/lib/badge/style/index"; @import "~antd/lib/badge/style/index";
@import "~antd/lib/card/style/index"; @import "~antd/lib/card/style/index";
@import "~antd/lib/spin/style/index"; @import "~antd/lib/spin/style/index";
@import "~antd/lib/skeleton/style/index";
@import "~antd/lib/tabs/style/index"; @import "~antd/lib/tabs/style/index";
@import "~antd/lib/notification/style/index"; @import "~antd/lib/notification/style/index";
@import "~antd/lib/collapse/style/index"; @import "~antd/lib/collapse/style/index";
@@ -401,14 +401,3 @@
.@{checkbox-prefix-cls} + span { .@{checkbox-prefix-cls} + span {
padding-right: 0; padding-right: 0;
} }
// make sure Multiple select has room for icons
.@{select-prefix-cls}-multiple {
&.@{select-prefix-cls}-show-arrow,
&.@{select-prefix-cls}-show-search,
&.@{select-prefix-cls}-loading {
.@{select-prefix-cls}-selector {
padding-right: 30px;
}
}
}

View File

@@ -23,10 +23,6 @@
padding: 5px 8px; padding: 5px 8px;
} }
.ant-form-item-explain {
margin-top: 10px;
}
.alert-last-triggered { .alert-last-triggered {
color: @headings-color; color: @headings-color;
} }

View File

@@ -78,6 +78,8 @@ strong {
} }
} }
// Fixed width layout for specific pages
.settings-screen, .settings-screen,
.home-page, .home-page,
.page-dashboard-list, .page-dashboard-list,
@@ -87,7 +89,7 @@ strong {
.admin-page-layout { .admin-page-layout {
.container { .container {
width: 100%; width: 100%;
max-width: none; max-width: 1200px;
} }
} }

View File

@@ -6,7 +6,7 @@
} }
&:not(.table-striped) > thead > tr > th { &:not(.table-striped) > thead > tr > th {
background-color: #fafafa; background-color: #FAFAFA;
} }
[class*="bg-"] { [class*="bg-"] {
@@ -33,8 +33,9 @@
& > thead > tr, & > thead > tr,
& > tbody > tr, & > tbody > tr,
& > tfoot > tr { & > tfoot > tr {
& > th,
& > td { & > th, & > td {
&:first-child { &:first-child {
padding-left: 30px; padding-left: 30px;
} }
@@ -42,6 +43,7 @@
&:last-child { &:last-child {
padding-right: 30px; padding-right: 30px;
} }
} }
} }
@@ -54,8 +56,7 @@
border: 0; border: 0;
& > tbody > tr { & > tbody > tr {
& > td, & > td, & > th {
& > th {
border-bottom: 0; border-bottom: 0;
border-left: 0; border-left: 0;
@@ -85,8 +86,10 @@
} }
.tile .table { .tile .table {
& > thead:not([class*="bg-"]) > tr > th { & > thead:not([class*="bg-"]) > tr > th {
border-top: 1px solid @table-border-color; border-top: 1px solid @table-border-color;
} }
} }
@@ -95,16 +98,11 @@
} }
.table-data { .table-data {
thead > tr > th {
white-space: nowrap;
}
tbody > tr > td { tbody > tr > td {
padding-top: 5px !important; padding-top: 5px !important;
} }
.btn-favourite, .btn-favourite, .btn-archive {
.btn-archive {
font-size: 15px; font-size: 15px;
} }
} }
@@ -116,10 +114,9 @@
.btn-favourite { .btn-favourite {
color: #d4d4d4; color: #d4d4d4;
transition: all 0.25s ease-in-out; transition: all .25s ease-in-out;
&:hover, &:hover, &:focus {
&:focus {
color: @yellow-darker; color: @yellow-darker;
cursor: pointer; cursor: pointer;
} }
@@ -131,10 +128,9 @@
.btn-archive { .btn-archive {
color: #d4d4d4; color: #d4d4d4;
transition: all 0.25s ease-in-out; transition: all .25s ease-in-out;
&:hover, &:hover, &:focus {
&:focus {
color: @gray-light; color: @gray-light;
} }
@@ -150,4 +146,4 @@
.table-data .label-tag { .table-data .label-tag {
display: inline-block; display: inline-block;
max-width: 135px; max-width: 135px;
} }

View File

@@ -141,7 +141,6 @@ a.label-tag {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-grow: 1; flex-grow: 1;
position: relative;
} }
.query-fullscreen { .query-fullscreen {

View File

@@ -2,22 +2,13 @@ import { first } from "lodash";
import React, { useState } from "react"; import React, { useState } from "react";
import Button from "antd/lib/button"; import Button from "antd/lib/button";
import Menu from "antd/lib/menu"; import Menu from "antd/lib/menu";
import Link from "@/components/Link"; import Icon from "antd/lib/icon";
import HelpTrigger from "@/components/HelpTrigger"; import HelpTrigger from "@/components/HelpTrigger";
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog"; import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
import { Auth, currentUser } from "@/services/auth"; import { Auth, currentUser } from "@/services/auth";
import settingsMenu from "@/services/settingsMenu"; import settingsMenu from "@/services/settingsMenu";
import logoUrl from "@/assets/images/redash_icon_small.png"; import logoUrl from "@/assets/images/redash_icon_small.png";
import DesktopOutlinedIcon from "@ant-design/icons/DesktopOutlined";
import CodeOutlinedIcon from "@ant-design/icons/CodeOutlined";
import AlertOutlinedIcon from "@ant-design/icons/AlertOutlined";
import PlusOutlinedIcon from "@ant-design/icons/PlusOutlined";
import QuestionCircleOutlinedIcon from "@ant-design/icons/QuestionCircleOutlined";
import SettingOutlinedIcon from "@ant-design/icons/SettingOutlined";
import MenuUnfoldOutlinedIcon from "@ant-design/icons/MenuUnfoldOutlined";
import MenuFoldOutlinedIcon from "@ant-design/icons/MenuFoldOutlined";
import VersionInfo from "./VersionInfo"; import VersionInfo from "./VersionInfo";
import "./DesktopNavbar.less"; import "./DesktopNavbar.less";
@@ -46,36 +37,34 @@ export default function DesktopNavbar() {
return ( return (
<div className="desktop-navbar"> <div className="desktop-navbar">
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-logo"> <NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-logo">
<div> <a href="./">
<Link href="./">
<img src={logoUrl} alt="Redash" /> <img src={logoUrl} alt="Redash" />
</Link> </a>
</div>
</NavbarSection> </NavbarSection>
<NavbarSection inlineCollapsed={collapsed}> <NavbarSection inlineCollapsed={collapsed}>
{currentUser.hasPermission("list_dashboards") && ( {currentUser.hasPermission("list_dashboards") && (
<Menu.Item key="dashboards"> <Menu.Item key="dashboards">
<Link href="dashboards"> <a href="dashboards">
<DesktopOutlinedIcon /> <Icon type="desktop" />
<span>Dashboards</span> <span>Dashboards</span>
</Link> </a>
</Menu.Item> </Menu.Item>
)} )}
{currentUser.hasPermission("view_query") && ( {currentUser.hasPermission("view_query") && (
<Menu.Item key="queries"> <Menu.Item key="queries">
<Link href="queries"> <a href="queries">
<CodeOutlinedIcon /> <Icon type="code" />
<span>Queries</span> <span>Queries</span>
</Link> </a>
</Menu.Item> </Menu.Item>
)} )}
{currentUser.hasPermission("list_alerts") && ( {currentUser.hasPermission("list_alerts") && (
<Menu.Item key="alerts"> <Menu.Item key="alerts">
<Link href="alerts"> <a href="alerts">
<AlertOutlinedIcon /> <Icon type="alert" />
<span>Alerts</span> <span>Alerts</span>
</Link> </a>
</Menu.Item> </Menu.Item>
)} )}
</NavbarSection> </NavbarSection>
@@ -89,16 +78,16 @@ export default function DesktopNavbar() {
title={ title={
<React.Fragment> <React.Fragment>
<span data-test="CreateButton"> <span data-test="CreateButton">
<PlusOutlinedIcon /> <Icon type="plus" />
<span>Create</span> <span>Create</span>
</span> </span>
</React.Fragment> </React.Fragment>
}> }>
{canCreateQuery && ( {canCreateQuery && (
<Menu.Item key="new-query"> <Menu.Item key="new-query">
<Link href="queries/new" data-test="CreateQueryMenuItem"> <a href="queries/new" data-test="CreateQueryMenuItem">
New Query New Query
</Link> </a>
</Menu.Item> </Menu.Item>
)} )}
{canCreateDashboard && ( {canCreateDashboard && (
@@ -110,9 +99,9 @@ export default function DesktopNavbar() {
)} )}
{canCreateAlert && ( {canCreateAlert && (
<Menu.Item key="new-alert"> <Menu.Item key="new-alert">
<Link data-test="CreateAlertMenuItem" href="alerts/new"> <a data-test="CreateAlertMenuItem" href="alerts/new">
New Alert New Alert
</Link> </a>
</Menu.Item> </Menu.Item>
)} )}
</Menu.SubMenu> </Menu.SubMenu>
@@ -122,16 +111,16 @@ export default function DesktopNavbar() {
<NavbarSection inlineCollapsed={collapsed}> <NavbarSection inlineCollapsed={collapsed}>
<Menu.Item key="help"> <Menu.Item key="help">
<HelpTrigger showTooltip={false} type="HOME"> <HelpTrigger showTooltip={false} type="HOME">
<QuestionCircleOutlinedIcon /> <Icon type="question-circle" />
<span>Help</span> <span>Help</span>
</HelpTrigger> </HelpTrigger>
</Menu.Item> </Menu.Item>
{firstSettingsTab && ( {firstSettingsTab && (
<Menu.Item key="settings"> <Menu.Item key="settings">
<Link href={firstSettingsTab.path} data-test="SettingsLink"> <a href={firstSettingsTab.path} data-test="SettingsLink">
<SettingOutlinedIcon /> <Icon type="setting" />
<span>Settings</span> <span>Settings</span>
</Link> </a>
</Menu.Item> </Menu.Item>
)} )}
<Menu.Divider /> <Menu.Divider />
@@ -148,11 +137,11 @@ export default function DesktopNavbar() {
</span> </span>
}> }>
<Menu.Item key="profile"> <Menu.Item key="profile">
<Link href="users/me">Profile</Link> <a href="users/me">Profile</a>
</Menu.Item> </Menu.Item>
{currentUser.hasPermission("super_admin") && ( {currentUser.hasPermission("super_admin") && (
<Menu.Item key="status"> <Menu.Item key="status">
<Link href="admin/status">System Status</Link> <a href="admin/status">System Status</a>
</Menu.Item> </Menu.Item>
)} )}
<Menu.Divider /> <Menu.Divider />
@@ -169,7 +158,7 @@ export default function DesktopNavbar() {
</NavbarSection> </NavbarSection>
<Button onClick={() => setCollapsed(!collapsed)} className="desktop-navbar-collapse-button"> <Button onClick={() => setCollapsed(!collapsed)} className="desktop-navbar-collapse-button">
{collapsed ? <MenuUnfoldOutlinedIcon /> : <MenuFoldOutlinedIcon />} <Icon type={collapsed ? "menu-unfold" : "menu-fold"} />
</Button> </Button>
</div> </div>
); );

View File

@@ -2,10 +2,9 @@ import { first } from "lodash";
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import Button from "antd/lib/button"; import Button from "antd/lib/button";
import MenuOutlinedIcon from "@ant-design/icons/MenuOutlined"; import Icon from "antd/lib/icon";
import Dropdown from "antd/lib/dropdown"; import Dropdown from "antd/lib/dropdown";
import Menu from "antd/lib/menu"; import Menu from "antd/lib/menu";
import Link from "@/components/Link";
import { Auth, currentUser } from "@/services/auth"; import { Auth, currentUser } from "@/services/auth";
import settingsMenu from "@/services/settingsMenu"; import settingsMenu from "@/services/settingsMenu";
import logoUrl from "@/assets/images/redash_icon_small.png"; import logoUrl from "@/assets/images/redash_icon_small.png";
@@ -18,9 +17,9 @@ export default function MobileNavbar({ getPopupContainer }) {
return ( return (
<div className="mobile-navbar"> <div className="mobile-navbar">
<div className="mobile-navbar-logo"> <div className="mobile-navbar-logo">
<Link href="./"> <a href="./">
<img src={logoUrl} alt="Redash" /> <img src={logoUrl} alt="Redash" />
</Link> </a>
</div> </div>
<div> <div>
<Dropdown <Dropdown
@@ -31,39 +30,39 @@ export default function MobileNavbar({ getPopupContainer }) {
<Menu mode="vertical" theme="dark" selectable={false} className="mobile-navbar-menu"> <Menu mode="vertical" theme="dark" selectable={false} className="mobile-navbar-menu">
{currentUser.hasPermission("list_dashboards") && ( {currentUser.hasPermission("list_dashboards") && (
<Menu.Item key="dashboards"> <Menu.Item key="dashboards">
<Link href="dashboards">Dashboards</Link> <a href="dashboards">Dashboards</a>
</Menu.Item> </Menu.Item>
)} )}
{currentUser.hasPermission("view_query") && ( {currentUser.hasPermission("view_query") && (
<Menu.Item key="queries"> <Menu.Item key="queries">
<Link href="queries">Queries</Link> <a href="queries">Queries</a>
</Menu.Item> </Menu.Item>
)} )}
{currentUser.hasPermission("list_alerts") && ( {currentUser.hasPermission("list_alerts") && (
<Menu.Item key="alerts"> <Menu.Item key="alerts">
<Link href="alerts">Alerts</Link> <a href="alerts">Alerts</a>
</Menu.Item> </Menu.Item>
)} )}
<Menu.Item key="profile"> <Menu.Item key="profile">
<Link href="users/me">Edit Profile</Link> <a href="users/me">Edit Profile</a>
</Menu.Item> </Menu.Item>
<Menu.Divider /> <Menu.Divider />
{firstSettingsTab && ( {firstSettingsTab && (
<Menu.Item key="settings"> <Menu.Item key="settings">
<Link href={firstSettingsTab.path}>Settings</Link> <a href={firstSettingsTab.path}>Settings</a>
</Menu.Item> </Menu.Item>
)} )}
{currentUser.hasPermission("super_admin") && ( {currentUser.hasPermission("super_admin") && (
<Menu.Item key="status"> <Menu.Item key="status">
<Link href="admin/status">System Status</Link> <a href="admin/status">System Status</a>
</Menu.Item> </Menu.Item>
)} )}
{currentUser.hasPermission("super_admin") && <Menu.Divider />} {currentUser.hasPermission("super_admin") && <Menu.Divider />}
<Menu.Item key="help"> <Menu.Item key="help">
{/* eslint-disable-next-line react/jsx-no-target-blank */} {/* eslint-disable-next-line react/jsx-no-target-blank */}
<Link href="https://redash.io/help" target="_blank" rel="noopener"> <a href="https://redash.io/help" target="_blank" rel="noopener">
Help Help
</Link> </a>
</Menu.Item> </Menu.Item>
<Menu.Item key="logout" onClick={() => Auth.logout()}> <Menu.Item key="logout" onClick={() => Auth.logout()}>
Log out Log out
@@ -71,7 +70,7 @@ export default function MobileNavbar({ getPopupContainer }) {
</Menu> </Menu>
}> }>
<Button className="mobile-navbar-toggle-button" ghost> <Button className="mobile-navbar-toggle-button" ghost>
<MenuOutlinedIcon /> <Icon type="menu" />
</Button> </Button>
</Dropdown> </Dropdown>
</div> </div>

View File

@@ -1,5 +1,4 @@
import React from "react"; import React from "react";
import Link from "@/components/Link";
import { clientConfig, currentUser } from "@/services/auth"; import { clientConfig, currentUser } from "@/services/auth";
import frontendVersion from "@/version.json"; import frontendVersion from "@/version.json";
@@ -13,10 +12,10 @@ export default function VersionInfo() {
{clientConfig.newVersionAvailable && currentUser.hasPermission("super_admin") && ( {clientConfig.newVersionAvailable && currentUser.hasPermission("super_admin") && (
<div className="m-t-10"> <div className="m-t-10">
{/* eslint-disable react/jsx-no-target-blank */} {/* eslint-disable react/jsx-no-target-blank */}
<Link href="https://version.redash.io/" className="update-available" target="_blank" rel="noopener"> <a href="https://version.redash.io/" className="update-available" target="_blank" rel="noopener">
Update Available Update Available
<i className="fa fa-external-link m-l-5" /> <i className="fa fa-external-link m-l-5" />
</Link> </a>
</div> </div>
)} )}
</React.Fragment> </React.Fragment>

View File

@@ -13,7 +13,6 @@ export default function ApplicationLayout({ children }) {
return ( return (
<React.Fragment> <React.Fragment>
<DynamicComponent name="ApplicationWrapper">
<div className="application-layout-side-menu"> <div className="application-layout-side-menu">
<DynamicComponent name="ApplicationDesktopNavbar"> <DynamicComponent name="ApplicationDesktopNavbar">
<DesktopNavbar /> <DesktopNavbar />
@@ -27,7 +26,6 @@ export default function ApplicationLayout({ children }) {
</nav> </nav>
{children} {children}
</div> </div>
</DynamicComponent>
</React.Fragment> </React.Fragment>
); );
} }

View File

@@ -1,10 +1,8 @@
import { get, isObject } from "lodash"; import { isObject, get } from "lodash";
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import "./ErrorMessage.less"; import "./ErrorMessage.less";
import DynamicComponent from "@/components/DynamicComponent";
import { ErrorMessageDetails } from "@/components/ApplicationArea/ErrorMessageDetails";
function getErrorMessageByStatus(status, defaultMessage) { function getErrorMessageByStatus(status, defaultMessage) {
switch (status) { switch (status) {
@@ -33,30 +31,21 @@ function getErrorMessage(error) {
return message; return message;
} }
export default function ErrorMessage({ error, message }) { export default function ErrorMessage({ error }) {
if (!error) { if (!error) {
return null; return null;
} }
console.error(error); console.error(error);
const errorDetailsProps = {
error,
message: message || getErrorMessage(error),
};
return ( return (
<div className="error-message-container" data-test="ErrorMessage" role="alert"> <div className="error-message-container" data-test="ErrorMessage">
<div className="error-state bg-white tiled"> <div className="error-state bg-white tiled">
<div className="error-state__icon"> <div className="error-state__icon">
<i className="zmdi zmdi-alert-circle-o" /> <i className="zmdi zmdi-alert-circle-o" />
</div> </div>
<div className="error-state__details"> <div className="error-state__details">
<DynamicComponent <h4>{getErrorMessage(error)}</h4>
name="ErrorMessageDetails"
fallback={<ErrorMessageDetails {...errorDetailsProps} />}
{...errorDetailsProps}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -65,5 +54,4 @@ export default function ErrorMessage({ error, message }) {
ErrorMessage.propTypes = { ErrorMessage.propTypes = {
error: PropTypes.object.isRequired, error: PropTypes.object.isRequired,
message: PropTypes.string,
}; };

View File

@@ -1,11 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
export function ErrorMessageDetails(props) {
return <h4>{props.message}</h4>;
}
ErrorMessageDetails.propTypes = {
error: PropTypes.instanceOf(Error).isRequired,
message: PropTypes.string.isRequired,
};

View File

@@ -1,5 +1,5 @@
import { isFunction, startsWith, trimStart, trimEnd } from "lodash"; import { isFunction, startsWith, trimStart, trimEnd } from "lodash";
import React, { useState, useEffect, useRef, useContext } from "react"; import React, { useState, useEffect, useRef } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import UniversalRouter from "universal-router"; import UniversalRouter from "universal-router";
import ErrorBoundary from "@redash/viz/lib/components/ErrorBoundary"; import ErrorBoundary from "@redash/viz/lib/components/ErrorBoundary";
@@ -14,12 +14,6 @@ function generateRouteKey() {
.substr(2); .substr(2);
} }
export const CurrentRouteContext = React.createContext(null);
export function useCurrentRoute() {
return useContext(CurrentRouteContext);
}
export function stripBase(href) { export function stripBase(href) {
// Resolve provided link and '' (root) relative to document's base. // Resolve provided link and '' (root) relative to document's base.
// If provided href is not related to current document (does not // If provided href is not related to current document (does not
@@ -59,7 +53,7 @@ export default function Router({ routes, onRouteChange }) {
errorHandlerRef.current.reset(); 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 // 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 // from this path - do nothing. It also prevents router from using outdated route in a case
@@ -115,11 +109,9 @@ export default function Router({ routes, onRouteChange }) {
} }
return ( return (
<CurrentRouteContext.Provider value={currentRoute}>
<ErrorBoundary ref={errorHandlerRef} renderError={error => <ErrorMessage error={error} />}> <ErrorBoundary ref={errorHandlerRef} renderError={error => <ErrorMessage error={error} />}>
{currentRoute.render(currentRoute)} {currentRoute.render(currentRoute)}
</ErrorBoundary> </ErrorBoundary>
</CurrentRouteContext.Provider>
); );
} }

View File

@@ -9,7 +9,7 @@ export default function handleNavigationIntent(event) {
} }
element = element.parentNode; element = element.parentNode;
} }
if (!element || !element.hasAttribute("href") || element.hasAttribute("download") || element.dataset.skipRouter) { if (!element || !element.hasAttribute("href") || element.hasAttribute("download")) {
return; return;
} }

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState, useContext } from "react"; import React, { useEffect, useState, useContext } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary"; import { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
import { Auth, clientConfig } from "@/services/auth"; import { Auth } from "@/services/auth";
// This wrapper modifies `route.render` function and instead of passing `currentRoute` passes an object // This wrapper modifies `route.render` function and instead of passing `currentRoute` passes an object
// that contains: // that contains:
@@ -33,7 +33,7 @@ function ApiKeySessionWrapper({ apiKey, currentRoute, renderChildren }) {
}; };
}, [apiKey]); }, [apiKey]);
if (!isAuthenticated || clientConfig.disablePublicUrls) { if (!isAuthenticated) {
return null; return null;
} }

View File

@@ -0,0 +1,81 @@
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} />
),
};
}

View File

@@ -1,108 +0,0 @@
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} />}
/>
);
},
};
}

View File

@@ -3,7 +3,6 @@ import Card from "antd/lib/card";
import Button from "antd/lib/button"; import Button from "antd/lib/button";
import Typography from "antd/lib/typography"; import Typography from "antd/lib/typography";
import { clientConfig } from "@/services/auth"; import { clientConfig } from "@/services/auth";
import Link from "@/components/Link";
import HelpTrigger from "@/components/HelpTrigger"; import HelpTrigger from "@/components/HelpTrigger";
import DynamicComponent from "@/components/DynamicComponent"; import DynamicComponent from "@/components/DynamicComponent";
import OrgSettings from "@/services/organizationSettings"; import OrgSettings from "@/services/organizationSettings";
@@ -66,8 +65,8 @@ function BeaconConsent() {
</div> </div>
<div className="m-t-15"> <div className="m-t-15">
<Text type="secondary"> <Text type="secondary">
You can change this setting anytime from the{" "} You can change this setting anytime from the <a href="settings/organization">Organization Settings</a>{" "}
<Link href="settings/organization">Organization Settings</Link> page. page.
</Text> </Text>
</div> </div>
</Card> </Card>

View File

@@ -2,7 +2,6 @@ import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import Button from "antd/lib/button"; import Button from "antd/lib/button";
import Tooltip from "antd/lib/tooltip"; import Tooltip from "antd/lib/tooltip";
import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined";
import "./CodeBlock.less"; import "./CodeBlock.less";
export default class CodeBlock extends React.Component { export default class CodeBlock extends React.Component {
@@ -60,7 +59,7 @@ export default class CodeBlock extends React.Component {
const copyButton = ( const copyButton = (
<Tooltip title={this.state.copied || "Copy"}> <Tooltip title={this.state.copied || "Copy"}>
<Button icon={<CopyOutlinedIcon />} type="dashed" size="small" onClick={this.copy} /> <Button icon="copy" type="dashed" size="small" onClick={this.copy} />
</Tooltip> </Tooltip>
); );

View File

@@ -7,7 +7,6 @@ import Modal from "antd/lib/modal";
import Input from "antd/lib/input"; import Input from "antd/lib/input";
import Steps from "antd/lib/steps"; import Steps from "antd/lib/steps";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper"; import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import Link from "@/components/Link";
import { PreviewCard } from "@/components/PreviewCard"; import { PreviewCard } from "@/components/PreviewCard";
import EmptyState from "@/components/items-list/components/EmptyState"; import EmptyState from "@/components/items-list/components/EmptyState";
import DynamicForm from "@/components/dynamic-form/DynamicForm"; import DynamicForm from "@/components/dynamic-form/DynamicForm";
@@ -119,9 +118,9 @@ class CreateSourceDialog extends React.Component {
{selectedType.type === "databricks" && ( {selectedType.type === "databricks" && (
<small> <small>
By using the Databricks Data Source you agree to the Databricks JDBC/ODBC{" "} By using the Databricks Data Source you agree to the Databricks JDBC/ODBC{" "}
<Link href="https://databricks.com/spark/odbc-driver-download" target="_blank" rel="noopener noreferrer"> <a href="https://databricks.com/spark/odbc-driver-download" target="_blank" rel="noopener noreferrer">
Driver Download Terms and Conditions Driver Download Terms and Conditions
</Link> </a>
. .
</small> </small>
)} )}
@@ -155,7 +154,7 @@ class CreateSourceDialog extends React.Component {
footer={ footer={
currentStep === StepEnum.SELECT_TYPE currentStep === StepEnum.SELECT_TYPE
? [ ? [
<Button key="cancel" onClick={() => dialog.dismiss()} data-test="CreateSourceCancelButton"> <Button key="cancel" onClick={() => dialog.dismiss()}>
Cancel Cancel
</Button>, </Button>,
<Button key="submit" type="primary" disabled> <Button key="submit" type="primary" disabled>
@@ -172,7 +171,7 @@ class CreateSourceDialog extends React.Component {
form="sourceForm" form="sourceForm"
type="primary" type="primary"
loading={savingSource} loading={savingSource}
data-test="CreateSourceSaveButton"> data-test="CreateSourceButton">
Create Create
</Button>, </Button>,
] ]

View File

@@ -1,30 +0,0 @@
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;
};
};

View File

@@ -1,4 +1,4 @@
import { isFunction, isString, isUndefined } from "lodash"; import { isFunction, isString } from "lodash";
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
@@ -24,7 +24,6 @@ export function unregisterComponent(name) {
export default class DynamicComponent extends React.Component { export default class DynamicComponent extends React.Component {
static propTypes = { static propTypes = {
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
fallback: PropTypes.node,
children: PropTypes.node, children: PropTypes.node,
}; };
@@ -41,11 +40,10 @@ export default class DynamicComponent extends React.Component {
} }
render() { render() {
const { name, children, fallback, ...props } = this.props; const { name, children, ...props } = this.props;
const RealComponent = componentsRegistry.get(name); const RealComponent = componentsRegistry.get(name);
if (!RealComponent) { if (!RealComponent) {
// return fallback if any, otherwise return children return children;
return isUndefined(fallback) ? children : fallback;
} }
return <RealComponent {...props}>{children}</RealComponent>; return <RealComponent {...props}>{children}</RealComponent>;
} }

View File

@@ -1,5 +1,5 @@
import { includes, words, capitalize, clone, isNull, map, get, find } from "lodash"; import { includes, words, capitalize, clone, isNull } from "lodash";
import React, { useState, useEffect, useRef, useMemo } from "react"; import React, { useState, useEffect } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import Checkbox from "antd/lib/checkbox"; import Checkbox from "antd/lib/checkbox";
import Modal from "antd/lib/modal"; import Modal from "antd/lib/modal";
@@ -11,8 +11,6 @@ import Divider from "antd/lib/divider";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper"; import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import QuerySelector from "@/components/QuerySelector"; import QuerySelector from "@/components/QuerySelector";
import { Query } from "@/services/query"; import { Query } from "@/services/query";
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
import QueryBasedParameterMappingTable from "./query-based-parameter/QueryBasedParameterMappingTable";
const { Option } = Select; const { Option } = Select;
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } }; const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
@@ -71,27 +69,17 @@ NameInput.propTypes = {
function EditParameterSettingsDialog(props) { function EditParameterSettingsDialog(props) {
const [param, setParam] = useState(clone(props.parameter)); const [param, setParam] = useState(clone(props.parameter));
const [isNameValid, setIsNameValid] = useState(true); const [isNameValid, setIsNameValid] = useState(true);
const [paramQuery, setParamQuery] = useState(); const [initialQuery, setInitialQuery] = useState();
const mappingParameters = useMemo(
() =>
map(paramQuery && paramQuery.getParametersDefs(), mappingParam => ({
mappingParam,
existingMapping: get(param.parameterMapping, mappingParam.name, {
mappingType: QueryBasedParameterMappingType.UNDEFINED,
}),
})),
[param.parameterMapping, paramQuery]
);
const isNew = !props.parameter.name; const isNew = !props.parameter.name;
// fetch query by id // fetch query by id
const initialQueryId = useRef(props.parameter.queryId);
useEffect(() => { useEffect(() => {
if (initialQueryId.current) { const queryId = props.parameter.queryId;
Query.get({ id: initialQueryId.current }).then(setParamQuery); if (queryId) {
Query.get({ id: queryId }).then(setInitialQuery);
} }
}, []); }, [props.parameter.queryId]);
function isFulfilled() { function isFulfilled() {
// name // name
@@ -105,20 +93,14 @@ function EditParameterSettingsDialog(props) {
} }
// query // query
if (param.type === "query") { if (param.type === "query" && !param.queryId) {
if (!param.queryId) {
return false; return false;
} }
if (find(mappingParameters, { existingMapping: { mappingType: QueryBasedParameterMappingType.UNDEFINED } })) {
return false;
}
}
return true; return true;
} }
function onConfirm() { function onConfirm(e) {
// update title to default // update title to default
if (!param.title) { if (!param.title) {
// forced to do this cause param won't update in time for save // forced to do this cause param won't update in time for save
@@ -127,6 +109,8 @@ function EditParameterSettingsDialog(props) {
} }
props.dialog.close(param); props.dialog.close(param);
e.preventDefault(); // stops form redirect
} }
return ( return (
@@ -148,7 +132,7 @@ function EditParameterSettingsDialog(props) {
{isNew ? "Add Parameter" : "OK"} {isNew ? "Add Parameter" : "OK"}
</Button>, </Button>,
]}> ]}>
<Form layout="horizontal" onFinish={onConfirm} id="paramForm"> <Form layout="horizontal" onSubmit={onConfirm} id="paramForm">
{isNew && ( {isNew && (
<NameInput <NameInput
name={param.name} name={param.name}
@@ -158,7 +142,7 @@ function EditParameterSettingsDialog(props) {
type={param.type} type={param.type}
/> />
)} )}
<Form.Item required label="Title" {...formItemProps}> <Form.Item label="Title" {...formItemProps}>
<Input <Input
value={isNull(param.title) ? getDefaultTitle(param.name) : param.title} value={isNull(param.title) ? getDefaultTitle(param.name) : param.title}
onChange={e => setParam({ ...param, title: e.target.value })} onChange={e => setParam({ ...param, title: e.target.value })}
@@ -205,28 +189,14 @@ function EditParameterSettingsDialog(props) {
</Form.Item> </Form.Item>
)} )}
{param.type === "query" && ( {param.type === "query" && (
<Form.Item label="Query" help="Select query to load dropdown values from" required {...formItemProps}> <Form.Item label="Query" help="Select query to load dropdown values from" {...formItemProps}>
<QuerySelector <QuerySelector
selectedQuery={paramQuery} selectedQuery={initialQuery}
onChange={q => { onChange={q => setParam({ ...param, queryId: q && q.id })}
if (q) {
setParamQuery(q);
setParam({ ...param, queryId: q.id, parameterMapping: {} });
}
}}
type="select" type="select"
/> />
</Form.Item> </Form.Item>
)} )}
{param.type === "query" && paramQuery && paramQuery.hasParameters() && (
<Form.Item className="m-t-15 m-b-5" label="Parameters" required {...formItemProps}>
<QueryBasedParameterMappingTable
param={param}
mappingParameters={mappingParameters}
onChangeParam={setParam}
/>
</Form.Item>
)}
{(param.type === "enum" || param.type === "query") && ( {(param.type === "enum" || param.type === "query") && (
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}> <Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
<Checkbox <Checkbox

View File

@@ -1,134 +0,0 @@
import React, { useState, useEffect, useRef, useReducer } from "react";
import PropTypes from "prop-types";
import { values } from "lodash";
import Button from "antd/lib/button";
import Tooltip from "antd/lib/tooltip";
import Radio from "antd/lib/radio";
import Typography from "antd/lib/typography/Typography";
import ParameterValueInput from "@/components/ParameterValueInput";
import InputPopover from "@/components/InputPopover";
import Form from "antd/lib/form";
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
const { Text } = Typography;
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
export default function QueryBasedParameterMappingEditor({ parameter, mapping, searchAvailable, onChange }) {
const [showPopover, setShowPopover] = useState(false);
const [newMapping, setNewMapping] = useReducer((prevState, updates) => ({ ...prevState, ...updates }), mapping);
const newMappingRef = useRef(newMapping);
useEffect(() => {
if (
mapping.mappingType !== newMappingRef.current.mappingType ||
mapping.staticValue !== newMappingRef.current.staticValue
) {
setNewMapping(mapping);
}
}, [mapping]);
const parameterRef = useRef(parameter);
useEffect(() => {
parameterRef.current.setValue(mapping.staticValue);
}, [mapping.staticValue]);
const onCancel = () => {
setNewMapping(mapping);
setShowPopover(false);
};
const onOk = () => {
onChange(newMapping);
setShowPopover(false);
};
let currentState = <Text type="secondary">Pick a type</Text>;
if (mapping.mappingType === QueryBasedParameterMappingType.DROPDOWN_SEARCH) {
currentState = "Dropdown Search";
} else if (mapping.mappingType === QueryBasedParameterMappingType.STATIC) {
currentState = `Value: ${mapping.staticValue}`;
}
return (
<>
{currentState}
<InputPopover
placement="left"
trigger="click"
header="Edit Parameter Source"
okButtonProps={{
disabled: newMapping.mappingType === QueryBasedParameterMappingType.STATIC && parameter.isEmpty,
}}
onOk={onOk}
onCancel={onCancel}
content={
<Form>
<Form.Item className="m-b-15" label="Source" {...formItemProps}>
<Radio.Group
value={newMapping.mappingType}
onChange={({ target }) => setNewMapping({ mappingType: target.value })}>
<Radio
className="radio"
value={QueryBasedParameterMappingType.DROPDOWN_SEARCH}
disabled={!searchAvailable || parameter.type !== "text"}>
Dropdown Search{" "}
{(!searchAvailable || parameter.type !== "text") && (
<Tooltip
title={
parameter.type !== "text"
? "Dropdown Search is only available for Text Parameters"
: "There is already a parameter mapped with the Dropdown Search type."
}>
<QuestionCircleFilledIcon />
</Tooltip>
)}
</Radio>
<Radio className="radio" value={QueryBasedParameterMappingType.STATIC}>
Static Value
</Radio>
</Radio.Group>
</Form.Item>
{newMapping.mappingType === QueryBasedParameterMappingType.STATIC && (
<Form.Item label="Value" required {...formItemProps}>
<ParameterValueInput
type={parameter.type}
value={parameter.normalizedValue}
enumOptions={parameter.enumOptions}
queryId={parameter.queryId}
parameter={parameter}
onSelect={value => {
parameter.setValue(value);
setNewMapping({ staticValue: parameter.getExecutionValue({ joinListValues: true }) });
}}
/>
</Form.Item>
)}
</Form>
}
visible={showPopover}
onVisibleChange={setShowPopover}>
<Button className="m-l-5" size="small" type="dashed">
<EditOutlinedIcon />
</Button>
</InputPopover>
</>
);
}
QueryBasedParameterMappingEditor.propTypes = {
parameter: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
mapping: PropTypes.shape({
mappingType: PropTypes.oneOf(values(QueryBasedParameterMappingType)),
staticValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
}),
searchAvailable: PropTypes.bool,
onChange: PropTypes.func,
};
QueryBasedParameterMappingEditor.defaultProps = {
mapping: { mappingType: QueryBasedParameterMappingType.UNDEFINED, staticValue: undefined },
searchAvailable: false,
onChange: () => {},
};

View File

@@ -1,56 +0,0 @@
import React from "react";
import { findKey } from "lodash";
import PropTypes from "prop-types";
import Table from "antd/lib/table";
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
import QueryBasedParameterMappingEditor from "./QueryBasedParameterMappingEditor";
export default function QueryBasedParameterMappingTable({ param, mappingParameters, onChangeParam }) {
return (
<Table
dataSource={mappingParameters}
size="middle"
pagination={false}
rowKey={({ mappingParam }) => `param${mappingParam.name}`}>
<Table.Column title="Title" key="title" render={({ mappingParam }) => mappingParam.getTitle()} />
<Table.Column
title="Keyword"
key="keyword"
className="keyword"
render={({ mappingParam }) => <code>{`{{ ${mappingParam.name} }}`}</code>}
/>
<Table.Column
title="Value Source"
key="source"
render={({ mappingParam, existingMapping }) => (
<QueryBasedParameterMappingEditor
parameter={mappingParam.setValue(existingMapping.staticValue)}
mapping={existingMapping}
searchAvailable={
!findKey(param.parameterMapping, {
mappingType: QueryBasedParameterMappingType.DROPDOWN_SEARCH,
}) || existingMapping.mappingType === QueryBasedParameterMappingType.DROPDOWN_SEARCH
}
onChange={mapping =>
onChangeParam({
...param,
parameterMapping: { ...param.parameterMapping, [mappingParam.name]: mapping },
})
}
/>
)}
/>
</Table>
);
}
QueryBasedParameterMappingTable.propTypes = {
param: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
mappingParameters: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
onChangeParam: PropTypes.func,
};
QueryBasedParameterMappingTable.defaultProps = {
mappingParameters: [],
onChangeParam: () => {},
};

View File

@@ -3,13 +3,7 @@ import PropTypes from "prop-types";
import Dropdown from "antd/lib/dropdown"; import Dropdown from "antd/lib/dropdown";
import Menu from "antd/lib/menu"; import Menu from "antd/lib/menu";
import Button from "antd/lib/button"; import Button from "antd/lib/button";
import { clientConfig } from "@/services/auth"; import Icon from "antd/lib/icon";
import PlusCircleFilledIcon from "@ant-design/icons/PlusCircleFilled";
import ShareAltOutlinedIcon from "@ant-design/icons/ShareAltOutlined";
import FileOutlinedIcon from "@ant-design/icons/FileOutlined";
import FileExcelOutlinedIcon from "@ant-design/icons/FileExcelOutlined";
import EllipsisOutlinedIcon from "@ant-design/icons/EllipsisOutlined";
import QueryResultsLink from "./QueryResultsLink"; import QueryResultsLink from "./QueryResultsLink";
@@ -19,14 +13,14 @@ export default function QueryControlDropdown(props) {
{!props.query.isNew() && (!props.query.is_draft || !props.query.is_archived) && ( {!props.query.isNew() && (!props.query.is_draft || !props.query.is_archived) && (
<Menu.Item> <Menu.Item>
<a target="_self" onClick={() => props.openAddToDashboardForm(props.selectedTab)}> <a target="_self" onClick={() => props.openAddToDashboardForm(props.selectedTab)}>
<PlusCircleFilledIcon /> Add to Dashboard <Icon type="plus-circle" theme="filled" /> Add to Dashboard
</a> </a>
</Menu.Item> </Menu.Item>
)} )}
{!clientConfig.disablePublicUrls && !props.query.isNew() && ( {!props.query.isNew() && (
<Menu.Item> <Menu.Item>
<a onClick={() => props.showEmbedDialog(props.query, props.selectedTab)} data-test="ShowEmbedDialogButton"> <a onClick={() => props.showEmbedDialog(props.query, props.selectedTab)} data-test="ShowEmbedDialogButton">
<ShareAltOutlinedIcon /> Embed Elsewhere <Icon type="share-alt" /> Embed Elsewhere
</a> </a>
</Menu.Item> </Menu.Item>
)} )}
@@ -38,7 +32,7 @@ export default function QueryControlDropdown(props) {
queryResult={props.queryResult} queryResult={props.queryResult}
embed={props.embed} embed={props.embed}
apiKey={props.apiKey}> apiKey={props.apiKey}>
<FileOutlinedIcon /> Download as CSV File <Icon type="file" /> Download as CSV File
</QueryResultsLink> </QueryResultsLink>
</Menu.Item> </Menu.Item>
<Menu.Item> <Menu.Item>
@@ -49,7 +43,7 @@ export default function QueryControlDropdown(props) {
queryResult={props.queryResult} queryResult={props.queryResult}
embed={props.embed} embed={props.embed}
apiKey={props.apiKey}> apiKey={props.apiKey}>
<FileOutlinedIcon /> Download as TSV File <Icon type="file" /> Download as TSV File
</QueryResultsLink> </QueryResultsLink>
</Menu.Item> </Menu.Item>
<Menu.Item> <Menu.Item>
@@ -60,7 +54,7 @@ export default function QueryControlDropdown(props) {
queryResult={props.queryResult} queryResult={props.queryResult}
embed={props.embed} embed={props.embed}
apiKey={props.apiKey}> apiKey={props.apiKey}>
<FileExcelOutlinedIcon /> Download as Excel File <Icon type="file-excel" /> Download as Excel File
</QueryResultsLink> </QueryResultsLink>
</Menu.Item> </Menu.Item>
</Menu> </Menu>
@@ -69,7 +63,7 @@ export default function QueryControlDropdown(props) {
return ( return (
<Dropdown trigger={["click"]} overlay={menu} overlayClassName="query-control-dropdown-overlay"> <Dropdown trigger={["click"]} overlay={menu} overlayClassName="query-control-dropdown-overlay">
<Button data-test="QueryControlDropdownButton"> <Button data-test="QueryControlDropdownButton">
<EllipsisOutlinedIcon rotate={90} /> <Icon type="ellipsis" rotate={90} />
</Button> </Button>
</Dropdown> </Dropdown>
); );

View File

@@ -1,6 +1,5 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import Link from "@/components/Link";
export default function QueryResultsLink(props) { export default function QueryResultsLink(props) {
let href = ""; let href = "";
@@ -18,9 +17,9 @@ export default function QueryResultsLink(props) {
} }
return ( return (
<Link target="_blank" rel="noopener noreferrer" disabled={props.disabled} href={href} download> <a target="_blank" rel="noopener noreferrer" disabled={props.disabled} href={href} download>
{props.children} {props.children}
</Link> </a>
); );
} }

View File

@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import Button from "antd/lib/button"; import Button from "antd/lib/button";
import FormOutlinedIcon from "@ant-design/icons/FormOutlined"; import Icon from "antd/lib/icon";
export default function EditVisualizationButton(props) { export default function EditVisualizationButton(props) {
return ( return (
@@ -9,7 +9,7 @@ export default function EditVisualizationButton(props) {
data-test="EditVisualization" data-test="EditVisualization"
className="edit-visualization" className="edit-visualization"
onClick={() => props.openVisualizationEditor(props.selectedTab)}> onClick={() => props.openVisualizationEditor(props.selectedTab)}>
<FormOutlinedIcon /> <Icon type="form" />
<span className="hidden-xs hidden-s hidden-m">Edit Visualization</span> <span className="hidden-xs hidden-s hidden-m">Edit Visualization</span>
</Button> </Button>
); );

View File

@@ -1,13 +1,12 @@
import { startsWith, get, some, mapValues } from "lodash"; import { startsWith, get } from "lodash";
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import cx from "classnames"; import cx from "classnames";
import Tooltip from "antd/lib/tooltip"; import Tooltip from "antd/lib/tooltip";
import Drawer from "antd/lib/drawer"; import Drawer from "antd/lib/drawer";
import Link from "@/components/Link"; import Icon from "antd/lib/icon";
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
import BigMessage from "@/components/BigMessage"; import BigMessage from "@/components/BigMessage";
import DynamicComponent, { registerComponent } from "@/components/DynamicComponent"; import DynamicComponent from "@/components/DynamicComponent";
import "./HelpTrigger.less"; import "./HelpTrigger.less";
@@ -16,8 +15,7 @@ const HELP_PATH = "/help";
const IFRAME_TIMEOUT = 20000; const IFRAME_TIMEOUT = 20000;
const IFRAME_URL_UPDATE_MESSAGE = "iframe_url"; const IFRAME_URL_UPDATE_MESSAGE = "iframe_url";
export const TYPES = mapValues( export const TYPES = {
{
HOME: ["", "Help"], HOME: ["", "Help"],
VALUE_SOURCE_OPTIONS: ["/user-guide/querying/query-parameters#Value-Source-Options", "Guide: Value Source Options"], VALUE_SOURCE_OPTIONS: ["/user-guide/querying/query-parameters#Value-Source-Options", "Guide: Value Source Options"],
SHARE_DASHBOARD: ["/user-guide/dashboards/sharing-dashboards", "Guide: Sharing and Embedding Dashboards"], SHARE_DASHBOARD: ["/user-guide/dashboards/sharing-dashboards", "Guide: Sharing and Embedding Dashboards"],
@@ -27,10 +25,7 @@ export const TYPES = mapValues(
DS_BIGQUERY: ["/data-sources/bigquery-setup", "Guide: Help Setting up BigQuery"], DS_BIGQUERY: ["/data-sources/bigquery-setup", "Guide: Help Setting up BigQuery"],
DS_URL: ["/data-sources/querying-urls", "Guide: Help Setting up URL"], DS_URL: ["/data-sources/querying-urls", "Guide: Help Setting up URL"],
DS_MONGODB: ["/data-sources/mongodb-setup", "Guide: Help Setting up MongoDB"], DS_MONGODB: ["/data-sources/mongodb-setup", "Guide: Help Setting up MongoDB"],
DS_GOOGLE_SPREADSHEETS: [ DS_GOOGLE_SPREADSHEETS: ["/data-sources/querying-a-google-spreadsheet", "Guide: Help Setting up 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_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_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"], DS_RESULTS: ["/user-guide/querying/query-results-data-source", "Guide: Help Setting up Query Results"],
@@ -43,43 +38,27 @@ export const TYPES = mapValues(
"Guide: Managing Query Permissions", "Guide: Managing Query Permissions",
], ],
NUMBER_FORMAT_SPECS: ["/user-guide/visualizations/formatting-numbers", "Formatting Numbers"], NUMBER_FORMAT_SPECS: ["/user-guide/visualizations/formatting-numbers", "Formatting Numbers"],
GETTING_STARTED: ["/user-guide/getting-started", "Guide: Getting Started"], };
DASHBOARDS: ["/user-guide/dashboards", "Guide: Dashboards"],
QUERIES: ["/help/user-guide/querying", "Guide: Queries"],
ALERTS: ["/user-guide/alerts", "Guide: Alerts"],
},
([url, title]) => [DOMAIN + HELP_PATH + url, title]
);
const HelpTriggerPropTypes = { export default class HelpTrigger extends React.Component {
type: PropTypes.string, static propTypes = {
type: PropTypes.oneOf(Object.keys(TYPES)),
href: PropTypes.string, href: PropTypes.string,
title: PropTypes.node, title: PropTypes.node,
className: PropTypes.string, className: PropTypes.string,
showTooltip: PropTypes.bool, showTooltip: PropTypes.bool,
renderAsLink: PropTypes.bool,
children: PropTypes.node, children: PropTypes.node,
}; };
const HelpTriggerDefaultProps = { static defaultProps = {
type: null, type: null,
href: null, href: null,
title: null, title: null,
className: null, className: null,
showTooltip: true, showTooltip: true,
renderAsLink: false,
children: <i className="fa fa-question-circle" />, 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(); iframeRef = React.createRef();
iframeLoadingTimeout = null; iframeLoadingTimeout = null;
@@ -116,7 +95,7 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
}; };
onPostMessageReceived = event => { onPostMessageReceived = event => {
if (!some(allowedDomains, domain => startsWith(event.origin, domain))) { if (!startsWith(event.origin, DOMAIN)) {
return; return;
} }
@@ -129,18 +108,14 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
}; };
getUrl = () => { getUrl = () => {
const helpTriggerType = get(types, this.props.type); const helpTriggerType = get(TYPES, this.props.type);
return helpTriggerType ? helpTriggerType[0] : this.props.href; return helpTriggerType ? DOMAIN + HELP_PATH + helpTriggerType[0] : this.props.href;
}; };
openDrawer = e => { openDrawer = () => {
// keep "open in new tab" behavior
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
this.setState({ visible: true }); this.setState({ visible: true });
// wait for drawer animation to complete so there's no animation jank // wait for drawer animation to complete so there's no animation jank
setTimeout(() => this.loadIframe(this.getUrl()), 300); setTimeout(() => this.loadIframe(this.getUrl()), 300);
}
}; };
closeDrawer = event => { closeDrawer = event => {
@@ -152,16 +127,11 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
}; };
render() { render() {
const targetUrl = this.getUrl(); const tooltip = get(TYPES, `${this.props.type}[1]`, this.props.title);
if (!targetUrl) {
return null;
}
const tooltip = get(types, `${this.props.type}[1]`, this.props.title);
const className = cx("help-trigger", this.props.className); const className = cx("help-trigger", this.props.className);
const url = this.state.currentUrl; const url = this.state.currentUrl;
const isAllowedDomain = some(allowedDomains, domain => startsWith(url || targetUrl, domain));
const shouldRenderAsLink = this.props.renderAsLink || !isAllowedDomain; const isAllowedDomain = startsWith(url || this.getUrl(), DOMAIN);
return ( return (
<React.Fragment> <React.Fragment>
@@ -170,25 +140,26 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
this.props.showTooltip ? ( this.props.showTooltip ? (
<> <>
{tooltip} {tooltip}
{shouldRenderAsLink && <i className="fa fa-external-link" style={{ marginLeft: 5 }} />} {!isAllowedDomain && <i className="fa fa-external-link" style={{ marginLeft: 5 }} />}
</> </>
) : null ) : null
}> }>
<Link {isAllowedDomain ? (
href={url || this.getUrl()} <a onClick={this.openDrawer} className={className}>
className={className}
rel="noopener noreferrer"
target="_blank"
onClick={shouldRenderAsLink ? () => {} : this.openDrawer}>
{this.props.children} {this.props.children}
</Link> </a>
) : (
<a href={url || this.getUrl()} className={className} rel="noopener noreferrer" target="_blank">
{this.props.children}
</a>
)}
</Tooltip> </Tooltip>
<Drawer <Drawer
placement="right" placement="right"
closable={false} closable={false}
onClose={this.closeDrawer} onClose={this.closeDrawer}
visible={this.state.visible} visible={this.state.visible}
className={cx("help-drawer", drawerClassName)} className="help-drawer"
destroyOnClose destroyOnClose
width={400}> width={400}>
<div className="drawer-wrapper"> <div className="drawer-wrapper">
@@ -196,14 +167,14 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
{url && ( {url && (
<Tooltip title="Open page in a new window" placement="left"> <Tooltip title="Open page in a new window" placement="left">
{/* eslint-disable-next-line react/jsx-no-target-blank */} {/* eslint-disable-next-line react/jsx-no-target-blank */}
<Link href={url} target="_blank"> <a href={url} target="_blank">
<i className="fa fa-external-link" /> <i className="fa fa-external-link" />
</Link> </a>
</Tooltip> </Tooltip>
)} )}
<Tooltip title="Close" placement="bottom"> <Tooltip title="Close" placement="bottom">
<a onClick={this.closeDrawer}> <a onClick={this.closeDrawer}>
<CloseOutlinedIcon /> <Icon type="close" />
</a> </a>
</Tooltip> </Tooltip>
</div> </div>
@@ -212,7 +183,7 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
{!this.state.error && ( {!this.state.error && (
<iframe <iframe
ref={this.iframeRef} ref={this.iframeRef}
title="Usage Help" title="Redash Help"
src="about:blank" src="about:blank"
className={cx({ ready: !this.state.loading })} className={cx({ ready: !this.state.loading })}
onLoad={this.onIframeLoaded} onLoad={this.onIframeLoaded}
@@ -230,9 +201,9 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
Something went wrong. Something went wrong.
<br /> <br />
{/* eslint-disable-next-line react/jsx-no-target-blank */} {/* eslint-disable-next-line react/jsx-no-target-blank */}
<Link href={this.state.error} target="_blank" rel="noopener"> <a href={this.state.error} target="_blank" rel="noopener">
Click here Click here
</Link>{" "} </a>{" "}
to open the page in a new window. to open the page in a new window.
</BigMessage> </BigMessage>
)} )}
@@ -244,14 +215,4 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
</React.Fragment> </React.Fragment>
); );
} }
};
} }
registerComponent("HelpTrigger", helpTriggerWithTypes(TYPES, [DOMAIN]));
export default function HelpTrigger(props) {
return <DynamicComponent {...props} name="HelpTrigger" />;
}
HelpTrigger.propTypes = HelpTriggerPropTypes;
HelpTrigger.defaultProps = HelpTriggerDefaultProps;

View File

@@ -1,57 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import Popover from "antd/lib/popover";
import "./index.less";
export default function InputPopover({
header,
content,
children,
okButtonProps,
cancelButtonProps,
onCancel,
onOk,
...props
}) {
return (
<Popover
{...props}
content={
<div className="input-popover-content" data-test="InputPopoverContent">
{header && <header>{header}</header>}
{content}
<footer>
<Button onClick={onCancel} {...cancelButtonProps}>
Cancel
</Button>
<Button onClick={onOk} type="primary" {...okButtonProps}>
OK
</Button>
</footer>
</div>
}>
{children}
</Popover>
);
}
InputPopover.propTypes = {
header: PropTypes.node,
content: PropTypes.node,
children: PropTypes.node,
okButtonProps: PropTypes.object,
cancelButtonProps: PropTypes.object,
onOk: PropTypes.func,
onCancel: PropTypes.func,
};
InputPopover.defaultProps = {
header: null,
children: null,
okButtonProps: null,
cancelButtonProps: null,
onOk: () => {},
onCancel: () => {},
};

View File

@@ -1,37 +0,0 @@
@import "~antd/lib/modal/style/index"; // for ant @vars
.input-popover-content {
width: 390px;
.radio {
display: block;
height: 30px;
line-height: 30px;
}
.form-item {
margin-bottom: 10px;
}
header {
padding: 0 16px 10px;
margin: 0 -16px 20px;
border-bottom: @border-width-base @border-style-base @border-color-split;
font-size: @font-size-lg;
font-weight: 500;
color: @heading-color;
display: flex;
justify-content: space-between;
}
footer {
border-top: @border-width-base @border-style-base @border-color-split;
padding: 10px 16px 0;
margin: 0 -16px;
text-align: right;
button {
margin-left: 8px;
}
}
}

View File

@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import Input from "antd/lib/input"; import Input from "antd/lib/input";
import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined"; import Icon from "antd/lib/icon";
import Tooltip from "antd/lib/tooltip"; import Tooltip from "antd/lib/tooltip";
export default class InputWithCopy extends React.Component { export default class InputWithCopy extends React.Component {
@@ -42,7 +42,7 @@ export default class InputWithCopy extends React.Component {
render() { render() {
const copyButton = ( const copyButton = (
<Tooltip title={this.state.copied || "Copy"}> <Tooltip title={this.state.copied || "Copy"}>
<CopyOutlinedIcon style={{ cursor: "pointer" }} onClick={this.copy} /> <Icon type="copy" style={{ cursor: "pointer" }} onClick={this.copy} />
</Tooltip> </Tooltip>
); );

View File

@@ -1,26 +0,0 @@
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;

View File

@@ -8,6 +8,7 @@ import Select from "antd/lib/select";
import Table from "antd/lib/table"; import Table from "antd/lib/table";
import Popover from "antd/lib/popover"; import Popover from "antd/lib/popover";
import Button from "antd/lib/button"; import Button from "antd/lib/button";
import Icon from "antd/lib/icon";
import Tag from "antd/lib/tag"; import Tag from "antd/lib/tag";
import Input from "antd/lib/input"; import Input from "antd/lib/input";
import Radio from "antd/lib/radio"; import Radio from "antd/lib/radio";
@@ -17,15 +18,11 @@ import ParameterValueInput from "@/components/ParameterValueInput";
import { ParameterMappingType } from "@/services/widget"; import { ParameterMappingType } from "@/services/widget";
import { Parameter, cloneParameter } from "@/services/parameters"; import { Parameter, cloneParameter } from "@/services/parameters";
import HelpTrigger from "@/components/HelpTrigger"; import HelpTrigger from "@/components/HelpTrigger";
import InputPopover from "@/components/InputPopover";
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
import CheckOutlinedIcon from "@ant-design/icons/CheckOutlined";
import "./ParameterMappingInput.less"; import "./ParameterMappingInput.less";
const { Option } = Select;
export const MappingType = { export const MappingType = {
DashboardAddNew: "dashboard-add-new", DashboardAddNew: "dashboard-add-new",
DashboardMapToExisting: "dashboard-map-to-existing", DashboardMapToExisting: "dashboard-map-to-existing",
@@ -184,7 +181,7 @@ export class ParameterMappingInput extends React.Component {
Existing dashboard parameter{" "} Existing dashboard parameter{" "}
{noExisting ? ( {noExisting ? (
<Tooltip title="There are no dashboard parameters corresponding to this data type"> <Tooltip title="There are no dashboard parameters corresponding to this data type">
<QuestionCircleFilledIcon /> <Icon type="question-circle" theme="filled" />
</Tooltip> </Tooltip>
) : null} ) : null}
</Radio> </Radio>
@@ -207,9 +204,19 @@ export class ParameterMappingInput extends React.Component {
renderDashboardMapToExisting() { renderDashboardMapToExisting() {
const { mapping, existingParamNames } = this.props; const { mapping, existingParamNames } = this.props;
const options = map(existingParamNames, paramName => ({ label: paramName, value: paramName }));
return <Select value={mapping.mapTo} onChange={mapTo => this.updateParamMapping({ mapTo })} options={options} />; return (
<Select
value={mapping.mapTo}
onChange={mapTo => this.updateParamMapping({ mapTo })}
dropdownMatchSelectWidth={false}>
{map(existingParamNames, name => (
<Option value={name} key={name}>
{name}
</Option>
))}
</Select>
);
} }
renderStaticValue() { renderStaticValue() {
@@ -314,34 +321,43 @@ class MappingEditor extends React.Component {
this.setState({ visible: false }); this.setState({ visible: false });
}; };
render() { renderContent() {
const { visible, mapping, inputError } = this.state; const { mapping, inputError } = this.state;
return ( return (
<InputPopover <div className="parameter-mapping-editor" data-test="EditParamMappingPopover">
placement="left" <header>
trigger="click"
header={
<>
Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" /> Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" />
</> </header>
}
content={
<ParameterMappingInput <ParameterMappingInput
mapping={mapping} mapping={mapping}
existingParamNames={this.props.existingParamNames} existingParamNames={this.props.existingParamNames}
onChange={this.onChange} onChange={this.onChange}
inputError={inputError} inputError={inputError}
/> />
<footer>
<Button onClick={this.hide}>Cancel</Button>
<Button onClick={this.save} disabled={!!inputError} type="primary">
OK
</Button>
</footer>
</div>
);
} }
onOk={this.save}
onCancel={this.hide} render() {
okButtonProps={{ disabled: !!inputError }} const { visible, mapping } = this.state;
return (
<Popover
placement="left"
trigger="click"
content={this.renderContent()}
visible={visible} visible={visible}
onVisibleChange={this.onVisibleChange}> onVisibleChange={this.onVisibleChange}>
<Button size="small" type="dashed" data-test={`EditParamMappingButton-${mapping.param.name}`}> <Button size="small" type="dashed" data-test={`EditParamMappingButon-${mapping.param.name}`}>
<EditOutlinedIcon /> <Icon type="edit" />
</Button> </Button>
</InputPopover> </Popover>
); );
} }
} }
@@ -418,10 +434,10 @@ class TitleEditor extends React.Component {
autoFocus autoFocus
/> />
<Button size="small" type="dashed" onClick={this.hide}> <Button size="small" type="dashed" onClick={this.hide}>
<CloseOutlinedIcon /> <Icon type="close" />
</Button> </Button>
<Button size="small" type="dashed" onClick={this.save}> <Button size="small" type="dashed" onClick={this.save}>
<CheckOutlinedIcon /> <Icon type="check" />
</Button> </Button>
</div> </div>
); );
@@ -444,7 +460,7 @@ class TitleEditor extends React.Component {
visible={this.state.showPopup} visible={this.state.showPopup}
onVisibleChange={this.onPopupVisibleChange}> onVisibleChange={this.onPopupVisibleChange}>
<Button size="small" type="dashed"> <Button size="small" type="dashed">
<EditOutlinedIcon /> <Icon type="edit" />
</Button> </Button>
</Popover> </Popover>
); );

View File

@@ -1,4 +1,4 @@
@import "~antd/lib/modal/style/index"; // for ant @vars @import '~antd/lib/modal/style/index'; // for ant @vars
.parameters-mapping-list { .parameters-mapping-list {
.keyword { .keyword {
@@ -22,13 +22,48 @@
} }
} }
.parameter-mapping-editor {
width: 390px;
.radio {
display: block;
height: 30px;
line-height: 30px;
}
.form-item {
margin-bottom: 10px;
}
header {
padding: 0 16px 10px;
margin: 0 -16px 20px;
border-bottom: @border-width-base @border-style-base @border-color-split;
font-size: @font-size-lg;
font-weight: 500;
color: @heading-color;
display: flex;
justify-content: space-between;
}
footer {
border-top: @border-width-base @border-style-base @border-color-split;
padding: 10px 16px 0;
margin: 0 -16px;
text-align: right;
button {
margin-left: 8px;
}
}
}
.parameter-mapping-title { .parameter-mapping-title {
.text { .text {
margin-right: 3px; margin-right: 3px;
} }
&.disabled, &.disabled, .fa {
.fa {
color: #a4a4a4; color: #a4a4a4;
} }

View File

@@ -1,7 +1,7 @@
import { isEqual, isEmpty, map } from "lodash"; import { isEqual, isEmpty } from "lodash";
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll"; import Select from "antd/lib/select";
import Input from "antd/lib/input"; import Input from "antd/lib/input";
import InputNumber from "antd/lib/input-number"; import InputNumber from "antd/lib/input-number";
import DateParameter from "@/components/dynamic-parameters/DateParameter"; import DateParameter from "@/components/dynamic-parameters/DateParameter";
@@ -10,6 +10,8 @@ import QueryBasedParameterInput from "./QueryBasedParameterInput";
import "./ParameterValueInput.less"; import "./ParameterValueInput.less";
const { Option } = Select;
const multipleValuesProps = { const multipleValuesProps = {
maxTagCount: 3, maxTagCount: 3,
maxTagTextLength: 10, maxTagTextLength: 10,
@@ -96,20 +98,25 @@ class ParameterValueInput extends React.Component {
const enumOptionsArray = enumOptions.split("\n").filter(v => v !== ""); const enumOptionsArray = enumOptions.split("\n").filter(v => v !== "");
// Antd Select doesn't handle null in multiple mode // Antd Select doesn't handle null in multiple mode
const normalize = val => (parameter.multiValuesOptions && val === null ? [] : val); const normalize = val => (parameter.multiValuesOptions && val === null ? [] : val);
return ( return (
<SelectWithVirtualScroll <Select
className={this.props.className} className={this.props.className}
mode={parameter.multiValuesOptions ? "multiple" : "default"} mode={parameter.multiValuesOptions ? "multiple" : "default"}
optionFilterProp="children" optionFilterProp="children"
value={normalize(value)} value={normalize(value)}
onChange={this.onSelect} onChange={this.onSelect}
options={map(enumOptionsArray, opt => ({ label: String(opt), value: opt }))} dropdownMatchSelectWidth={false}
showSearch showSearch
showArrow showArrow
style={{ minWidth: 60 }}
notFoundContent={isEmpty(enumOptionsArray) ? "No options available" : null} notFoundContent={isEmpty(enumOptionsArray) ? "No options available" : null}
{...multipleValuesProps} {...multipleValuesProps}>
/> {enumOptionsArray.map(option => (
<Option key={option} value={option}>
{option}
</Option>
))}
</Select>
); );
} }

View File

@@ -1,4 +1,4 @@
@import "~antd/lib/input-number/style/index"; // for ant @vars @import '~antd/lib/input-number/style/index'; // for ant @vars
@input-dirty: #fffce1; @input-dirty: #fffce1;
@@ -17,11 +17,10 @@
} }
&[data-dirty] { &[data-dirty] {
.@{ant-prefix}-input, .@{ant-prefix}-input, // covers also ant date component
.@{ant-prefix}-input-number, .@{ant-prefix}-input-number,
.@{ant-prefix}-select-selector, .@{ant-prefix}-select-selection {
.@{ant-prefix}-picker { background-color: @input-dirty;
background-color: @input-dirty !important;
} }
} }
} }

View File

@@ -7,6 +7,7 @@ import { Parameter, createParameter } from "@/services/parameters";
import ParameterApplyButton from "@/components/ParameterApplyButton"; import ParameterApplyButton from "@/components/ParameterApplyButton";
import ParameterValueInput from "@/components/ParameterValueInput"; import ParameterValueInput from "@/components/ParameterValueInput";
import EditParameterSettingsDialog from "./EditParameterSettingsDialog"; import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
import { toHuman } from "@/lib/utils";
import "./Parameters.less"; import "./Parameters.less";
@@ -120,7 +121,7 @@ export default class Parameters extends React.Component {
return ( return (
<div key={param.name} className="di-block" data-test={`ParameterName-${param.name}`}> <div key={param.name} className="di-block" data-test={`ParameterName-${param.name}`}>
<div className="parameter-heading"> <div className="parameter-heading">
<label>{param.getTitle()}</label> <label>{param.title || toHuman(param.name)}</label>
{editable && ( {editable && (
<button <button
className="btn btn-default btn-xs m-l-5" className="btn btn-default btn-xs m-l-5"

View File

@@ -1,7 +1,6 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import classNames from "classnames"; import classNames from "classnames";
import Link from "@/components/Link";
// PreviewCard // PreviewCard
@@ -43,7 +42,7 @@ PreviewCard.defaultProps = {
// UserPreviewCard // UserPreviewCard
export function UserPreviewCard({ user, withLink, children, ...props }) { export function UserPreviewCard({ user, withLink, children, ...props }) {
const title = withLink ? <Link href={"users/" + user.id}>{user.name}</Link> : user.name; const title = withLink ? <a href={"users/" + user.id}>{user.name}</a> : user.name;
return ( return (
<PreviewCard {...props} imageUrl={user.profile_image_url} title={title} body={user.email}> <PreviewCard {...props} imageUrl={user.profile_image_url} title={title} body={user.email}>
{children} {children}
@@ -69,8 +68,8 @@ UserPreviewCard.defaultProps = {
// DataSourcePreviewCard // DataSourcePreviewCard
export function DataSourcePreviewCard({ dataSource, withLink, children, ...props }) { export function DataSourcePreviewCard({ dataSource, withLink, children, ...props }) {
const imageUrl = `static/images/db-logos/${dataSource.type}.png`; const imageUrl = `/static/images/db-logos/${dataSource.type}.png`;
const title = withLink ? <Link href={"data_sources/" + dataSource.id}>{dataSource.name}</Link> : dataSource.name; const title = withLink ? <a href={"data_sources/" + dataSource.id}>{dataSource.name}</a> : dataSource.name;
return ( return (
<PreviewCard {...props} imageUrl={imageUrl} title={title}> <PreviewCard {...props} imageUrl={imageUrl} title={title}>
{children} {children}

View File

@@ -1,18 +1,9 @@
import { find, isArray, get, first, map, intersection, isEqual, isEmpty, trim, debounce, isNil } from "lodash"; import { find, isArray, get, first, map, intersection, isEqual, isEmpty } from "lodash";
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll"; import Select from "antd/lib/select";
const SEARCH_DEBOUNCE_TIME = 300; const { Option } = Select;
function filterValuesThatAreNotInOptions(value, options) {
if (isArray(value)) {
const optionValues = map(options, option => option.value);
return intersection(value, optionValues);
}
const found = find(options, option => option.value === value) !== undefined;
return found ? value : get(first(options), "value");
}
export default class QueryBasedParameterInput extends React.Component { export default class QueryBasedParameterInput extends React.Component {
static propTypes = { static propTypes = {
@@ -39,7 +30,6 @@ export default class QueryBasedParameterInput extends React.Component {
options: [], options: [],
value: null, value: null,
loading: false, loading: false,
currentSearchTerm: null,
}; };
} }
@@ -48,10 +38,9 @@ export default class QueryBasedParameterInput extends React.Component {
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (this.props.queryId !== prevProps.queryId || this.props.parameter !== prevProps.parameter) { if (this.props.queryId !== prevProps.queryId) {
this._loadOptions(this.props.queryId); this._loadOptions(this.props.queryId);
} }
if (this.props.value !== prevProps.value) { if (this.props.value !== prevProps.value) {
this.setValue(this.props.value); this.setValue(this.props.value);
} }
@@ -59,26 +48,26 @@ export default class QueryBasedParameterInput extends React.Component {
setValue(value) { setValue(value) {
const { options } = this.state; const { options } = this.state;
const { mode, parameter } = this.props; if (this.props.mode === "multiple") {
if (mode === "multiple") {
if (isNil(value)) {
value = [];
}
value = isArray(value) ? value : [value]; value = isArray(value) ? value : [value];
const optionValues = map(options, option => option.value);
const validValues = intersection(value, optionValues);
this.setState({ value: validValues });
return validValues;
} }
const found = find(options, option => option.value === this.props.value) !== undefined;
// parameters with search don't have options available, so we trust what we get value = found ? value : get(first(options), "value");
if (!parameter.searchFunction) {
value = filterValuesThatAreNotInOptions(value, options);
}
this.setState({ value }); this.setState({ value });
return value; return value;
} }
updateOptions(options) { 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) {
this.setState({ options, loading: false }, () => { this.setState({ options, loading: false }, () => {
const updatedValue = this.setValue(this.props.value); const updatedValue = this.setValue(this.props.value);
if (!isEqual(updatedValue, this.props.value)) { if (!isEqual(updatedValue, this.props.value)) {
@@ -86,59 +75,33 @@ 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() { render() {
const { parameter, className, mode, onSelect, queryId, value, ...otherProps } = this.props; const { className, value, mode, onSelect, ...otherProps } = this.props;
const { loading, options } = this.state; const { loading, options } = this.state;
const selectProps = { ...otherProps };
if (parameter.searchColumn) {
selectProps.filterOption = false;
selectProps.onSearch = this.searchFunction;
selectProps.onChange = value => onSelect(parameter.normalizeValue(value));
selectProps.notFoundContent = null;
selectProps.labelInValue = true;
}
return ( return (
<span> <span>
<SelectWithVirtualScroll <Select
className={className} className={className}
disabled={!parameter.searchFunction && loading} disabled={loading}
loading={loading} loading={loading}
mode={mode} mode={mode}
value={this.state.value || undefined} value={this.state.value}
onChange={onSelect} onChange={onSelect}
options={options} dropdownMatchSelectWidth={false}
optionFilterProp="children" optionFilterProp="children"
showSearch showSearch
showArrow showArrow
notFoundContent={isEmpty(options) ? "No options available" : null} notFoundContent={isEmpty(options) ? "No options available" : null}
{...selectProps} {...otherProps}>
/> {options.map(option => (
<Option value={option.value} key={option.value}>
{option.name}
</Option>
))}
</Select>
</span> </span>
); );
} }

View File

@@ -1,7 +1,6 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { VisualizationType } from "@redash/viz/lib"; import { VisualizationType } from "@redash/viz/lib";
import Link from "@/components/Link";
import VisualizationName from "@/components/visualizations/VisualizationName"; import VisualizationName from "@/components/visualizations/VisualizationName";
import "./QueryLink.less"; import "./QueryLink.less";
@@ -22,9 +21,9 @@ function QueryLink({ query, visualization, readOnly }) {
}; };
return ( return (
<Link href={readOnly ? null : getUrl()} className="query-link"> <a href={readOnly ? null : getUrl()} className="query-link">
<VisualizationName visualization={visualization} /> <span>{query.name}</span> <VisualizationName visualization={visualization} /> <span>{query.name}</span>
</Link> </a>
); );
} }

View File

@@ -1,38 +0,0 @@
import React, { useMemo } from "react";
import { maxBy } from "lodash";
import AntdSelect, { SelectProps, LabeledValue } from "antd/lib/select";
import { calculateTextWidth } from "@/lib/calculateTextWidth";
const MIN_LEN_FOR_VIRTUAL_SCROLL = 400;
interface VirtualScrollLabeledValue extends LabeledValue {
label: string;
}
interface VirtualScrollSelectProps extends SelectProps<string> {
options: Array<VirtualScrollLabeledValue>;
}
function SelectWithVirtualScroll({ options, ...props }: VirtualScrollSelectProps): JSX.Element {
const dropdownMatchSelectWidth = useMemo<number | boolean>(() => {
if (options && options.length > MIN_LEN_FOR_VIRTUAL_SCROLL) {
const largestOpt = maxBy(options, "label.length");
if (largestOpt) {
const offset = 40;
const optionText = largestOpt.label;
const width = calculateTextWidth(optionText);
if (width) {
return width + offset;
}
}
return true;
}
return false;
}, [options]);
return <AntdSelect<string> dropdownMatchSelectWidth={dropdownMatchSelectWidth} options={options} {...props} />;
}
export default SelectWithVirtualScroll;

View File

@@ -1,7 +1,6 @@
import React from "react"; import React from "react";
import Menu from "antd/lib/menu"; import Menu from "antd/lib/menu";
import PageHeader from "@/components/PageHeader"; import PageHeader from "@/components/PageHeader";
import Link from "@/components/Link";
import location from "@/services/location"; import location from "@/services/location";
import settingsMenu from "@/services/settingsMenu"; import settingsMenu from "@/services/settingsMenu";
@@ -18,9 +17,9 @@ function wrapSettingsTab(id, options, WrappedComponent) {
<Menu selectedKeys={[activeItem && activeItem.title]} selectable={false} mode="horizontal"> <Menu selectedKeys={[activeItem && activeItem.title]} selectable={false} mode="horizontal">
{settingsMenu.getAvailableItems().map(item => ( {settingsMenu.getAvailableItems().map(item => (
<Menu.Item key={item.title}> <Menu.Item key={item.title}>
<Link href={item.path} data-test="SettingsScreenItem"> <a href={item.path} data-test="SettingsScreenItem">
{item.title} {item.title}
</Link> </a>
</Menu.Item> </Menu.Item>
))} ))}
</Menu> </Menu>

View File

@@ -0,0 +1,82 @@
import { map } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Badge from "antd/lib/badge";
import Menu from "antd/lib/menu";
import getTags from "@/services/getTags";
import "./TagsList.less";
export default class TagsList extends React.Component {
static propTypes = {
tagsUrl: PropTypes.string.isRequired,
onUpdate: PropTypes.func,
};
static defaultProps = {
onUpdate: () => {},
};
constructor(props) {
super(props);
this.state = {
// An array of objects that with the name and count of the tagged items
allTags: [],
// A set of tag names
selectedTags: new Set(),
};
}
componentDidMount() {
getTags(this.props.tagsUrl).then(allTags => {
this.setState({ allTags });
});
}
toggleTag(event, tag) {
const { selectedTags } = this.state;
if (event.shiftKey) {
// toggle tag
if (selectedTags.has(tag)) {
selectedTags.delete(tag);
} else {
selectedTags.add(tag);
}
} else {
// if the tag is the only selected, deselect it, otherwise select only it
if (selectedTags.has(tag) && selectedTags.size === 1) {
selectedTags.clear();
} else {
selectedTags.clear();
selectedTags.add(tag);
}
}
this.forceUpdate();
this.props.onUpdate([...this.state.selectedTags]);
}
render() {
const { allTags, selectedTags } = this.state;
if (allTags.length > 0) {
return (
<div className="m-t-10 tags-list tiled">
<Menu className="invert-stripe-position" mode="inline" selectedKeys={[...selectedTags]}>
{map(allTags, tag => (
<Menu.Item key={tag.name} className="m-0">
<a
className="d-flex align-items-center justify-content-between"
onClick={event => this.toggleTag(event, tag.name)}>
<span className="max-character col-xs-11">{tag.name}</span>
<Badge count={tag.count} />
</a>
</Menu.Item>
))}
</Menu>
</div>
);
}
return null;
}
}

View File

@@ -1,47 +1,15 @@
@import "~@/assets/less/ant"; @import '~@/assets/less/ant';
.tags-list { .tags-list {
.tags-list-title {
margin: 15px 5px 5px 5px;
display: flex;
justify-content: space-between;
align-items: center;
label {
display: block;
white-space: nowrap;
margin: 0;
}
a {
display: block;
white-space: nowrap;
cursor: pointer;
.anticon {
font-size: 75%;
margin-right: 2px;
}
}
}
.ant-badge-count { .ant-badge-count {
background-color: fade(@redash-gray, 10%); background-color: fade(@redash-gray, 10%);
color: fade(@redash-gray, 75%); color: fade(@redash-gray, 75%);
} }
.ant-menu.ant-menu-inline {
border: none;
.ant-menu-item {
width: 100%;
}
.ant-menu-item-selected { .ant-menu-item-selected {
.ant-badge-count { .ant-badge-count {
background-color: @primary-color; background-color: @primary-color;
color: white; color: white;
} }
} }
}
} }

View File

@@ -1,107 +0,0 @@
import { map, includes, difference } from "lodash";
import React, { useState, useCallback, useEffect } from "react";
import Badge from "antd/lib/badge";
import Menu from "antd/lib/menu";
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
import getTags from "@/services/getTags";
import "./TagsList.less";
type Tag = {
name: string;
count?: number;
};
type TagsListProps = {
tagsUrl: string;
showUnselectAll: boolean;
onUpdate?: (selectedTags: string[]) => void;
};
function TagsList({ tagsUrl, showUnselectAll = false, onUpdate }: TagsListProps): JSX.Element | null {
const [allTags, setAllTags] = useState<Tag[]>([]);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
useEffect(() => {
let isCancelled = false;
getTags(tagsUrl).then(tags => {
if (!isCancelled) {
setAllTags(tags);
}
});
return () => {
isCancelled = true;
};
}, [tagsUrl]);
const toggleTag = useCallback(
(event, tag) => {
let newSelectedTags;
if (event.shiftKey) {
// toggle tag
if (includes(selectedTags, tag)) {
newSelectedTags = difference(selectedTags, [tag]);
} else {
newSelectedTags = [...selectedTags, tag];
}
} else {
// if the tag is the only selected, deselect it, otherwise select only it
if (includes(selectedTags, tag) && selectedTags.length === 1) {
newSelectedTags = [];
} else {
newSelectedTags = [tag];
}
}
setSelectedTags(newSelectedTags);
if (onUpdate) {
onUpdate([...newSelectedTags]);
}
},
[selectedTags, onUpdate]
);
const unselectAll = useCallback(() => {
setSelectedTags([]);
if (onUpdate) {
onUpdate([]);
}
}, [onUpdate]);
if (allTags.length === 0) {
return null;
}
return (
<div className="tags-list">
<div className="tags-list-title">
<label>Tags</label>
{showUnselectAll && selectedTags.length > 0 && (
<a onClick={unselectAll}>
<CloseOutlinedIcon />
clear selection
</a>
)}
</div>
<div className="tiled">
<Menu className="invert-stripe-position" mode="inline" selectedKeys={selectedTags}>
{map(allTags, tag => (
<Menu.Item key={tag.name} className="m-0">
<a
className="d-flex align-items-center justify-content-between"
onClick={event => toggleTag(event, tag.name)}>
<span className="max-character col-xs-11">{tag.name}</span>
<Badge count={tag.count} />
</a>
</Menu.Item>
))}
</Menu>
</div>
</div>
);
}
export default TagsList;

View File

@@ -11,7 +11,7 @@ function toMoment(value) {
return value && value.isValid() ? value : null; return value && value.isValid() ? value : null;
} }
export default function TimeAgo({ date, placeholder, autoUpdate, variation }) { export default function TimeAgo({ date, placeholder, autoUpdate }) {
const startDate = toMoment(date); const startDate = toMoment(date);
const [value, setValue] = useState(null); const [value, setValue] = useState(null);
const title = useMemo(() => (startDate ? startDate.format(clientConfig.dateTimeFormat) : null), [startDate]); const title = useMemo(() => (startDate ? startDate.format(clientConfig.dateTimeFormat) : null), [startDate]);
@@ -28,13 +28,6 @@ export default function TimeAgo({ date, placeholder, autoUpdate, variation }) {
} }
}, [autoUpdate, startDate, placeholder]); }, [autoUpdate, startDate, placeholder]);
if (variation === "timeAgoInTooltip") {
return (
<Tooltip title={value}>
<span data-test="TimeAgo">{title}</span>
</Tooltip>
);
}
return ( return (
<Tooltip title={title}> <Tooltip title={title}>
<span data-test="TimeAgo">{value}</span> <span data-test="TimeAgo">{value}</span>
@@ -46,7 +39,6 @@ TimeAgo.propTypes = {
date: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.instanceOf(Date), Moment]), date: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.instanceOf(Date), Moment]),
placeholder: PropTypes.string, placeholder: PropTypes.string,
autoUpdate: PropTypes.bool, autoUpdate: PropTypes.bool,
variation: PropTypes.oneOf(["timeAgoInTooltip"]),
}; };
TimeAgo.defaultProps = { TimeAgo.defaultProps = {

View File

@@ -1,32 +0,0 @@
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,
};

View File

@@ -1,7 +0,0 @@
.user-groups {
margin: -5px 0 0 -5px;
.ant-tag {
margin: 5px 0 0 5px;
}
}

View File

@@ -1,8 +1,7 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import Menu from "antd/lib/menu"; import Tabs from "antd/lib/tabs";
import PageHeader from "@/components/PageHeader"; import PageHeader from "@/components/PageHeader";
import Link from "@/components/Link";
import "./layout.less"; import "./layout.less";
@@ -11,19 +10,19 @@ export default function Layout({ activeTab, children }) {
<div className="admin-page-layout"> <div className="admin-page-layout">
<div className="container"> <div className="container">
<PageHeader title="Admin" /> <PageHeader title="Admin" />
<div className="bg-white tiled"> <div className="bg-white tiled">
<Menu selectedKeys={[activeTab]} selectable={false} mode="horizontal"> <Tabs className="admin-page-layout-tabs" defaultActiveKey={activeTab} animated={false} tabBarGutter={0}>
<Menu.Item key="system_status"> <Tabs.TabPane key="system_status" tab={<a href="admin/status">System Status</a>}>
<Link href="admin/status">System Status</Link> {activeTab === "system_status" ? children : null}
</Menu.Item> </Tabs.TabPane>
<Menu.Item key="jobs"> <Tabs.TabPane key="jobs" tab={<a href="admin/queries/jobs">RQ Status</a>}>
<Link href="admin/queries/jobs">RQ Status</Link> {activeTab === "jobs" ? children : null}
</Menu.Item> </Tabs.TabPane>
<Menu.Item key="outdated_queries"> <Tabs.TabPane key="outdated_queries" tab={<a href="admin/queries/outdated">Outdated Queries</a>}>
<Link href="admin/queries/outdated">Outdated Queries</Link> {activeTab === "outdated_queries" ? children : null}
</Menu.Item> </Tabs.TabPane>
</Menu> </Tabs>
{children}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,17 @@
.admin-page-layout { .admin-page-layout {
.ant-table { &-tabs.ant-tabs {
overflow-x: auto; > .ant-tabs-bar {
margin: 0;
.ant-tabs-tab {
padding: 0;
a {
display: inline-block;
padding: 12px 16px;
color: inherit;
}
}
}
} }
} }

View File

@@ -0,0 +1,83 @@
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>
);
}
}

View File

@@ -1,80 +0,0 @@
import { includes, isEmpty } from "lodash";
import PropTypes from "prop-types";
import React, { useState } from "react";
import Input from "antd/lib/input";
import Link from "@/components/Link";
import EmptyState from "@/components/items-list/components/EmptyState";
import "./CardsList.less";
export interface CardsListItem {
title: string;
imgSrc: string;
onClick?: () => void;
href?: string;
}
export interface CardsListProps {
items?: CardsListItem[];
showSearch?: boolean;
}
interface ListItemProps {
item: CardsListItem;
keySuffix: string;
}
function ListItem({ item, keySuffix }: ListItemProps) {
return (
<Link key={`card${keySuffix}`} className="visual-card" onClick={item.onClick} href={item.href}>
<img alt={item.title} src={item.imgSrc} />
<h3>{item.title}</h3>
</Link>
);
}
export default function CardsList({ items = [], showSearch = false }: CardsListProps) {
const [searchText, setSearchText] = useState("");
const filteredItems = items.filter(
item => isEmpty(searchText) || includes(item.title.toLowerCase(), searchText.toLowerCase())
);
return (
<div data-test="CardsList">
{showSearch && (
<div className="row p-10">
<div className="col-md-4 col-md-offset-4">
<Input.Search
placeholder="Search..."
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchText(e.target.value)}
autoFocus
/>
</div>
</div>
)}
{isEmpty(filteredItems) ? (
<EmptyState className="" />
) : (
<div className="row">
<div className="col-lg-12 d-inline-flex flex-wrap visual-card-list">
{filteredItems.map((item: CardsListItem, index: number) => (
<ListItem key={index} item={item} keySuffix={index.toString()} />
))}
</div>
</div>
)}
</div>
);
}
CardsList.propTypes = {
items: PropTypes.arrayOf(
PropTypes.shape({
title: PropTypes.string.isRequired,
imgSrc: PropTypes.string.isRequired,
onClick: PropTypes.func,
href: PropTypes.string,
})
),
showSearch: PropTypes.bool,
};

View File

@@ -238,7 +238,6 @@ class DashboardGrid extends React.Component {
return ( return (
<div className={className}> <div className={className}>
<ResponsiveGridLayout <ResponsiveGridLayout
draggableCancel="input"
className={cx("layout", { "disable-animations": this.state.disableAnimations })} className={cx("layout", { "disable-animations": this.state.disableAnimations })}
cols={{ [MULTI]: cfg.columns, [SINGLE]: 1 }} cols={{ [MULTI]: cfg.columns, [SINGLE]: 1 }}
rowHeight={cfg.rowHeight - cfg.margins} rowHeight={cfg.rowHeight - cfg.margins}

View File

@@ -3,11 +3,10 @@ import PropTypes from "prop-types";
import Button from "antd/lib/button"; import Button from "antd/lib/button";
import Modal from "antd/lib/modal"; import Modal from "antd/lib/modal";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper"; import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import { FiltersType } from "@/components/Filters";
import VisualizationRenderer from "@/components/visualizations/VisualizationRenderer"; import VisualizationRenderer from "@/components/visualizations/VisualizationRenderer";
import VisualizationName from "@/components/visualizations/VisualizationName"; import VisualizationName from "@/components/visualizations/VisualizationName";
function ExpandedWidgetDialog({ dialog, widget, filters }) { function ExpandedWidgetDialog({ dialog, widget }) {
return ( return (
<Modal <Modal
{...dialog.props} {...dialog.props}
@@ -21,7 +20,6 @@ function ExpandedWidgetDialog({ dialog, widget, filters }) {
<VisualizationRenderer <VisualizationRenderer
visualization={widget.visualization} visualization={widget.visualization}
queryResult={widget.getQueryResult()} queryResult={widget.getQueryResult()}
filters={filters}
context="widget" context="widget"
/> />
</Modal> </Modal>
@@ -31,11 +29,6 @@ function ExpandedWidgetDialog({ dialog, widget, filters }) {
ExpandedWidgetDialog.propTypes = { ExpandedWidgetDialog.propTypes = {
dialog: DialogPropType.isRequired, dialog: DialogPropType.isRequired,
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
filters: FiltersType,
};
ExpandedWidgetDialog.defaultProps = {
filters: [],
}; };
export default wrapDialog(ExpandedWidgetDialog); export default wrapDialog(ExpandedWidgetDialog);

View File

@@ -7,7 +7,6 @@ import Modal from "antd/lib/modal";
import Input from "antd/lib/input"; import Input from "antd/lib/input";
import Tooltip from "antd/lib/tooltip"; import Tooltip from "antd/lib/tooltip";
import Divider from "antd/lib/divider"; import Divider from "antd/lib/divider";
import Link from "@/components/Link";
import HtmlContent from "@redash/viz/lib/components/HtmlContent"; import HtmlContent from "@redash/viz/lib/components/HtmlContent";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper"; import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import notification from "@/services/notification"; import notification from "@/services/notification";
@@ -79,12 +78,9 @@ function TextboxDialog({ dialog, isNew, ...props }) {
/> />
<small> <small>
Supports basic{" "} Supports basic{" "}
<Link <a target="_blank" rel="noopener noreferrer" href="https://www.markdownguide.org/cheat-sheet/#basic-syntax">
target="_blank"
rel="noopener noreferrer"
href="https://www.markdownguide.org/cheat-sheet/#basic-syntax">
<Tooltip title="Markdown guide opens in new window">Markdown</Tooltip> <Tooltip title="Markdown guide opens in new window">Markdown</Tooltip>
</Link> </a>
. .
</small> </small>
{text && ( {text && (

View File

@@ -48,10 +48,10 @@
top: 0; top: 0;
left: 0; left: 0;
bottom: 85px; bottom: 85px;
right: 0; right: 15px;
background: linear-gradient(to bottom, transparent, transparent 2px, #f6f8f9 2px, #f6f8f9 5px), background: linear-gradient(to bottom, transparent, transparent 2px, #f6f8f9 2px, #f6f8f9 5px),
linear-gradient(to left, #b3babf, #b3babf 1px, transparent 1px, transparent); linear-gradient(to left, #b3babf, #b3babf 1px, transparent 1px, transparent);
background-size: calc((100% + 15px) / 6) 5px; background-size: calc((100vw - 15px) / 6) 5px;
background-position: -7px 1px; background-position: -7px 1px;
} }
} }

View File

@@ -8,7 +8,6 @@ import HtmlContent from "@redash/viz/lib/components/HtmlContent";
import { currentUser } from "@/services/auth"; import { currentUser } from "@/services/auth";
import recordEvent from "@/services/recordEvent"; import recordEvent from "@/services/recordEvent";
import { formatDateTime } from "@/lib/utils"; import { formatDateTime } from "@/lib/utils";
import Link from "@/components/Link";
import Parameters from "@/components/Parameters"; import Parameters from "@/components/Parameters";
import TimeAgo from "@/components/TimeAgo"; import TimeAgo from "@/components/TimeAgo";
import Timer from "@/components/Timer"; import Timer from "@/components/Timer";
@@ -31,27 +30,27 @@ function visualizationWidgetMenuOptions({ widget, canEditDashboard, onParameters
return compact([ return compact([
<Menu.Item key="download_csv" disabled={isQueryResultEmpty}> <Menu.Item key="download_csv" disabled={isQueryResultEmpty}>
{!isQueryResultEmpty ? ( {!isQueryResultEmpty ? (
<Link href={downloadLink("csv")} download={downloadName("csv")} target="_self"> <a href={downloadLink("csv")} download={downloadName("csv")} target="_self">
Download as CSV File Download as CSV File
</Link> </a>
) : ( ) : (
"Download as CSV File" "Download as CSV File"
)} )}
</Menu.Item>, </Menu.Item>,
<Menu.Item key="download_tsv" disabled={isQueryResultEmpty}> <Menu.Item key="download_tsv" disabled={isQueryResultEmpty}>
{!isQueryResultEmpty ? ( {!isQueryResultEmpty ? (
<Link href={downloadLink("tsv")} download={downloadName("tsv")} target="_self"> <a href={downloadLink("tsv")} download={downloadName("tsv")} target="_self">
Download as TSV File Download as TSV File
</Link> </a>
) : ( ) : (
"Download as TSV File" "Download as TSV File"
)} )}
</Menu.Item>, </Menu.Item>,
<Menu.Item key="download_excel" disabled={isQueryResultEmpty}> <Menu.Item key="download_excel" disabled={isQueryResultEmpty}>
{!isQueryResultEmpty ? ( {!isQueryResultEmpty ? (
<Link href={downloadLink("xlsx")} download={downloadName("xlsx")} target="_self"> <a href={downloadLink("xlsx")} download={downloadName("xlsx")} target="_self">
Download as Excel File Download as Excel File
</Link> </a>
) : ( ) : (
"Download as Excel File" "Download as Excel File"
)} )}
@@ -59,7 +58,7 @@ function visualizationWidgetMenuOptions({ widget, canEditDashboard, onParameters
(canViewQuery || canEditParameters) && <Menu.Divider key="divider" />, (canViewQuery || canEditParameters) && <Menu.Divider key="divider" />,
canViewQuery && ( canViewQuery && (
<Menu.Item key="view_query"> <Menu.Item key="view_query">
<Link href={widget.getQuery().getUrl(true, widget.visualization.id)}>View Query</Link> <a href={widget.getQuery().getUrl(true, widget.visualization.id)}>View Query</a>
</Menu.Item> </Menu.Item>
), ),
canEditParameters && ( canEditParameters && (
@@ -209,10 +208,7 @@ class VisualizationWidget extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = { localParameters: props.widget.getLocalParameters() };
localParameters: props.widget.getLocalParameters(),
localFilters: props.filters,
};
} }
componentDidMount() { componentDidMount() {
@@ -222,12 +218,8 @@ class VisualizationWidget extends React.Component {
onLoad(); onLoad();
} }
onLocalFiltersChange = localFilters => {
this.setState({ localFilters });
};
expandWidget = () => { expandWidget = () => {
ExpandedWidgetDialog.showModal({ widget: this.props.widget, filters: this.state.localFilters }); ExpandedWidgetDialog.showModal({ widget: this.props.widget });
}; };
editParameterMappings = () => { editParameterMappings = () => {
@@ -267,7 +259,6 @@ class VisualizationWidget extends React.Component {
visualization={widget.visualization} visualization={widget.visualization}
queryResult={widgetQueryResult} queryResult={widgetQueryResult}
filters={filters} filters={filters}
onFiltersChange={this.onLocalFiltersChange}
context="widget" context="widget"
/> />
</div> </div>

View File

@@ -1,29 +1,24 @@
import React, { useState, useReducer, useCallback } from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import cx from "classnames"; import cx from "classnames";
import Form from "antd/lib/form"; import Form from "antd/lib/form";
import Input from "antd/lib/input";
import InputNumber from "antd/lib/input-number";
import Checkbox from "antd/lib/checkbox";
import Button from "antd/lib/button"; import Button from "antd/lib/button";
import { includes, isFunction, filter, find, difference, isEmpty, mapValues } from "lodash"; import Upload from "antd/lib/upload";
import Icon from "antd/lib/icon";
import { includes, isFunction, filter, difference, isEmpty } from "lodash";
import Select from "antd/lib/select";
import notification from "@/services/notification"; import notification from "@/services/notification";
import Collapse from "@/components/Collapse"; import Collapse from "@/components/Collapse";
import DynamicFormField, { FieldType } from "./DynamicFormField"; import AceEditorInput from "@/components/AceEditorInput";
import getFieldLabel from "./getFieldLabel"; import { toHuman } from "@/lib/utils";
import { Field, Action, AntdForm } from "../proptypes";
import helper from "./dynamicFormHelper"; import helper from "./dynamicFormHelper";
import "./DynamicForm.less"; import "./DynamicForm.less";
const ActionType = PropTypes.shape({
name: PropTypes.string.isRequired,
callback: PropTypes.func.isRequired,
type: PropTypes.string,
pullRight: PropTypes.bool,
disabledWhenDirty: PropTypes.bool,
});
const AntdFormType = PropTypes.shape({
validateFieldsAndScroll: PropTypes.func,
});
const fieldRules = ({ type, required, minLength }) => { const fieldRules = ({ type, required, minLength }) => {
const requiredRule = required; const requiredRule = required;
const minLengthRule = minLength && includes(["text", "email", "password"], type); const minLengthRule = minLength && includes(["text", "email", "password"], type);
@@ -36,200 +31,20 @@ const fieldRules = ({ type, required, minLength }) => {
].filter(rule => rule); ].filter(rule => rule);
}; };
function normalizeEmptyValuesToNull(fields, values) { class DynamicForm extends React.Component {
return mapValues(values, (value, key) => { static propTypes = {
const { initialValue } = find(fields, { name: key }) || {};
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, id: PropTypes.string,
fields: PropTypes.arrayOf(FieldType), fields: PropTypes.arrayOf(Field),
actions: PropTypes.arrayOf(ActionType), actions: PropTypes.arrayOf(Action),
feedbackIcons: PropTypes.bool, feedbackIcons: PropTypes.bool,
hideSubmitButton: PropTypes.bool, hideSubmitButton: PropTypes.bool,
defaultShowExtraFields: PropTypes.bool, defaultShowExtraFields: PropTypes.bool,
saveText: PropTypes.string, saveText: PropTypes.string,
onSubmit: PropTypes.func, onSubmit: PropTypes.func,
}; form: AntdForm.isRequired,
};
DynamicForm.defaultProps = { static defaultProps = {
id: null, id: null,
fields: [], fields: [],
actions: [], actions: [],
@@ -238,4 +53,260 @@ DynamicForm.defaultProps = {
defaultShowExtraFields: false, defaultShowExtraFields: false,
saveText: "Save", saveText: "Save",
onSubmit: () => {}, 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);

View File

@@ -1,82 +0,0 @@
import React from "react";
import { get } from "lodash";
import PropTypes from "prop-types";
import getFieldLabel from "./getFieldLabel";
import {
AceEditorField,
CheckboxField,
ContentField,
FileField,
InputField,
NumberField,
SelectField,
TextAreaField,
} from "./fields";
export const FieldType = PropTypes.shape({
name: PropTypes.string.isRequired,
title: PropTypes.string,
type: PropTypes.oneOf([
"ace",
"text",
"textarea",
"email",
"password",
"number",
"checkbox",
"file",
"select",
"content",
]).isRequired,
initialValue: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.bool,
PropTypes.arrayOf(PropTypes.string),
PropTypes.arrayOf(PropTypes.number),
]),
content: PropTypes.node,
mode: PropTypes.string,
required: PropTypes.bool,
extra: PropTypes.bool,
readOnly: PropTypes.bool,
autoFocus: PropTypes.bool,
minLength: PropTypes.number,
placeholder: PropTypes.string,
contentAfter: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
loading: PropTypes.bool,
props: PropTypes.object, // eslint-disable-line react/forbid-prop-types
});
const FieldTypeComponent = {
checkbox: CheckboxField,
file: FileField,
select: SelectField,
number: NumberField,
textarea: TextAreaField,
ace: AceEditorField,
content: ContentField,
};
export default function DynamicFormField({ form, field, ...otherProps }) {
const { name, type, readOnly, autoFocus } = field;
const fieldLabel = getFieldLabel(field);
const fieldProps = {
...field.props,
className: "w-100",
name,
type,
readOnly,
autoFocus,
placeholder: field.placeholder,
"data-test": fieldLabel,
...otherProps,
};
const FieldComponent = get(FieldTypeComponent, type, InputField);
return <FieldComponent {...fieldProps} form={form} field={field} />;
}
DynamicFormField.propTypes = { field: FieldType.isRequired };

View File

@@ -1,6 +0,0 @@
import React from "react";
import AceEditorInput from "@/components/AceEditorInput";
export default function AceEditorField({ form, field, ...otherProps }) {
return <AceEditorInput {...otherProps} />;
}

View File

@@ -1,8 +0,0 @@
import React from "react";
import Checkbox from "antd/lib/checkbox";
import getFieldLabel from "../getFieldLabel";
export default function CheckboxField({ form, field, ...otherProps }) {
const fieldLabel = getFieldLabel(field);
return <Checkbox {...otherProps}>{fieldLabel}</Checkbox>;
}

View File

@@ -1,3 +0,0 @@
export default function ContentField({ field }) {
return field.content;
}

View File

@@ -1,18 +0,0 @@
import React from "react";
import Button from "antd/lib/button";
import Upload from "antd/lib/upload";
import UploadOutlinedIcon from "@ant-design/icons/UploadOutlined";
export default function FileField({ form, field, ...otherProps }) {
const { name, initialValue } = field;
const { getFieldValue } = form;
const disabled = getFieldValue(name) !== undefined && getFieldValue(name) !== initialValue;
return (
<Upload {...otherProps} beforeUpload={() => false}>
<Button disabled={disabled}>
<UploadOutlinedIcon /> Click to upload
</Button>
</Upload>
);
}

View File

@@ -1,6 +0,0 @@
import React from "react";
import Input from "antd/lib/input";
export default function InputField({ form, field, ...otherProps }) {
return <Input {...otherProps} />;
}

View File

@@ -1,6 +0,0 @@
import React from "react";
import InputNumber from "antd/lib/input-number";
export default function NumberField({ form, field, ...otherProps }) {
return <InputNumber {...otherProps} />;
}

View File

@@ -1,21 +0,0 @@
import React from "react";
import Select from "antd/lib/select";
export default function SelectField({ form, field, ...otherProps }) {
const { readOnly } = field;
return (
<Select
{...otherProps}
optionFilterProp="children"
loading={field.loading || false}
mode={field.mode}
getPopupContainer={trigger => trigger.parentNode}>
{field.options &&
field.options.map(option => (
<Select.Option key={`${option.value}`} value={option.value} disabled={readOnly}>
{option.name || option.value}
</Select.Option>
))}
</Select>
);
}

View File

@@ -1,6 +0,0 @@
import React from "react";
import Input from "antd/lib/input";
export default function TextAreaField({ form, field, ...otherProps }) {
return <Input.TextArea {...otherProps} />;
}

View File

@@ -1,8 +0,0 @@
export { default as AceEditorField } from "./AceEditorField";
export { default as CheckboxField } from "./CheckboxField";
export { default as ContentField } from "./ContentField";
export { default as FileField } from "./FileField";
export { default as InputField } from "./InputField";
export { default as NumberField } from "./NumberField";
export { default as SelectField } from "./SelectField";
export { default as TextAreaField } from "./TextAreaField";

View File

@@ -1,6 +0,0 @@
import { toHuman } from "@/lib/utils";
export default function getFieldLabel(field) {
const { title, name } = field;
return title || toHuman(name);
}

View File

@@ -1,7 +1,14 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { getDynamicDateFromString } from "@/services/parameters/DateParameter"; import classNames from "classnames";
import DynamicDatePicker from "@/components/dynamic-parameters/DynamicDatePicker"; import moment from "moment";
import { includes } from "lodash";
import { isDynamicDate, getDynamicDateFromString } from "@/services/parameters/DateParameter";
import DateInput from "@/components/DateInput";
import DateTimeInput from "@/components/DateTimeInput";
import DynamicButton from "@/components/dynamic-parameters/DynamicButton";
import "./DynamicParameters.less";
const DYNAMIC_DATE_OPTIONS = [ const DYNAMIC_DATE_OPTIONS = [
{ {
@@ -22,24 +29,86 @@ const DYNAMIC_DATE_OPTIONS = [
}, },
]; ];
function DateParameter(props) { class DateParameter extends React.Component {
return <DynamicDatePicker dynamicButtonOptions={{ options: DYNAMIC_DATE_OPTIONS }} {...props} />; static propTypes = {
}
DateParameter.propTypes = {
type: PropTypes.string, type: PropTypes.string,
className: PropTypes.string, className: PropTypes.string,
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
onSelect: PropTypes.func, onSelect: PropTypes.func,
}; };
DateParameter.defaultProps = { static defaultProps = {
type: "", type: "",
className: "", className: "",
value: null, value: null,
parameter: null, parameter: null,
onSelect: () => {}, 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; export default DateParameter;

View File

@@ -1,8 +1,14 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { includes } from "lodash"; import classNames from "classnames";
import { getDynamicDateRangeFromString } from "@/services/parameters/DateRangeParameter"; import moment from "moment";
import DynamicDateRangePicker from "@/components/dynamic-parameters/DynamicDateRangePicker"; import { includes, isArray, isObject } from "lodash";
import { isDynamicDateRange, getDynamicDateRangeFromString } from "@/services/parameters/DateRangeParameter";
import DateRangeInput from "@/components/DateRangeInput";
import DateTimeRangeInput from "@/components/DateTimeRangeInput";
import DynamicButton from "@/components/dynamic-parameters/DynamicButton";
import "./DynamicParameters.less";
const DYNAMIC_DATE_OPTIONS = [ const DYNAMIC_DATE_OPTIONS = [
{ {
@@ -128,25 +134,97 @@ const DYNAMIC_DATETIME_OPTIONS = [
...DYNAMIC_DATE_OPTIONS, ...DYNAMIC_DATE_OPTIONS,
]; ];
function DateRangeParameter(props) { const widthByType = {
const options = includes(props.type, "datetime-range") ? DYNAMIC_DATETIME_OPTIONS : DYNAMIC_DATE_OPTIONS; "date-range": 294,
return <DynamicDateRangePicker {...props} dynamicButtonOptions={{ options }} />; "datetime-range": 352,
"datetime-range-with-seconds": 382,
};
function isValidDateRangeValue(value) {
return isArray(value) && value.length === 2 && moment.isMoment(value[0]) && moment.isMoment(value[1]);
} }
DateRangeParameter.propTypes = { class DateRangeParameter extends React.Component {
static propTypes = {
type: PropTypes.string, type: PropTypes.string,
className: PropTypes.string, className: PropTypes.string,
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
onSelect: PropTypes.func, onSelect: PropTypes.func,
}; };
DateRangeParameter.defaultProps = { static defaultProps = {
type: "", type: "",
className: "", className: "",
value: null, value: null,
parameter: null, parameter: null,
onSelect: () => {}, onSelect: () => {},
}; };
constructor(props) {
super(props);
this.dateRangeComponentRef = React.createRef();
}
onDynamicValueSelect = dynamicValue => {
const { onSelect, parameter } = this.props;
if (dynamicValue === "static") {
const parameterValue = parameter.getExecutionValue();
if (isObject(parameterValue) && parameterValue.start && parameterValue.end) {
onSelect([moment(parameterValue.start), moment(parameterValue.end)]);
} else {
onSelect(null);
}
} else {
onSelect(dynamicValue.value);
}
// give focus to the DatePicker to get keyboard shortcuts to work
this.dateRangeComponentRef.current.focus();
};
render() {
const { type, value, onSelect, className } = this.props;
const isDateTimeRange = includes(type, "datetime-range");
const hasDynamicValue = isDynamicDateRange(value);
const options = isDateTimeRange ? DYNAMIC_DATETIME_OPTIONS : DYNAMIC_DATE_OPTIONS;
const additionalAttributes = {};
let DateRangeComponent = DateRangeInput;
if (isDateTimeRange) {
DateRangeComponent = DateTimeRangeInput;
if (includes(type, "with-seconds")) {
additionalAttributes.withSeconds = true;
}
}
if (isValidDateRangeValue(value) || value === null) {
additionalAttributes.value = value;
}
if (hasDynamicValue) {
additionalAttributes.placeholder = [value && value.name];
additionalAttributes.value = null;
}
return (
<DateRangeComponent
ref={this.dateRangeComponentRef}
className={classNames("redash-datepicker date-range-input", { "dynamic-value": hasDynamicValue }, className)}
onSelect={onSelect}
style={{ width: hasDynamicValue ? 195 : widthByType[type] }}
suffixIcon={
<DynamicButton
options={options}
selectedDynamicValue={hasDynamicValue ? value : null}
enabled={hasDynamicValue}
onSelect={this.onDynamicValueSelect}
/>
}
{...additionalAttributes}
/>
);
}
}
export default DateRangeParameter; export default DateRangeParameter;

View File

@@ -2,20 +2,17 @@ import React, { useRef } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { isFunction, get, findIndex } from "lodash"; import { isFunction, get, findIndex } from "lodash";
import Dropdown from "antd/lib/dropdown"; import Dropdown from "antd/lib/dropdown";
import Icon from "antd/lib/icon";
import Menu from "antd/lib/menu"; import Menu from "antd/lib/menu";
import Typography from "antd/lib/typography"; import Typography from "antd/lib/typography";
import { DynamicDateType } from "@/services/parameters/DateParameter"; import { DynamicDateType } from "@/services/parameters/DateParameter";
import { DynamicDateRangeType } from "@/services/parameters/DateRangeParameter"; import { DynamicDateRangeType } from "@/services/parameters/DateRangeParameter";
import ArrowLeftOutlinedIcon from "@ant-design/icons/ArrowLeftOutlined";
import ThunderboltTwoToneIcon from "@ant-design/icons/ThunderboltTwoTone";
import ThunderboltOutlinedIcon from "@ant-design/icons/ThunderboltOutlined";
import "./DynamicButton.less"; import "./DynamicButton.less";
const { Text } = Typography; const { Text } = Typography;
function DynamicButton({ options, selectedDynamicValue, onSelect, enabled, staticValueLabel }) { function DynamicButton({ options, selectedDynamicValue, onSelect, enabled }) {
const menu = ( const menu = (
<Menu <Menu
className="dynamic-menu" className="dynamic-menu"
@@ -31,8 +28,8 @@ function DynamicButton({ options, selectedDynamicValue, onSelect, enabled, stati
{enabled && <Menu.Divider />} {enabled && <Menu.Divider />}
{enabled && ( {enabled && (
<Menu.Item> <Menu.Item>
<ArrowLeftOutlinedIcon /> <Icon type="arrow-left" />
<Text type="secondary">{staticValueLabel}</Text> <Text type="secondary">Back to Static Value</Text>
</Menu.Item> </Menu.Item>
)} )}
</Menu> </Menu>
@@ -48,13 +45,7 @@ function DynamicButton({ options, selectedDynamicValue, onSelect, enabled, stati
className="dynamic-button" className="dynamic-button"
placement="bottomRight" placement="bottomRight"
trigger={["click"]} trigger={["click"]}
icon={ icon={<Icon type="thunderbolt" theme={enabled ? "twoTone" : "outlined"} className="dynamic-icon" />}
enabled ? (
<ThunderboltTwoToneIcon className="dynamic-icon" />
) : (
<ThunderboltOutlinedIcon className="dynamic-icon" />
)
}
getPopupContainer={() => containerRef.current} getPopupContainer={() => containerRef.current}
data-test="DynamicButton" data-test="DynamicButton"
/> />
@@ -68,7 +59,6 @@ DynamicButton.propTypes = {
selectedDynamicValue: PropTypes.oneOfType([DynamicDateType, DynamicDateRangeType]), selectedDynamicValue: PropTypes.oneOfType([DynamicDateType, DynamicDateRangeType]),
onSelect: PropTypes.func, onSelect: PropTypes.func,
enabled: PropTypes.bool, enabled: PropTypes.bool,
staticValueLabel: PropTypes.string,
}; };
DynamicButton.defaultProps = { DynamicButton.defaultProps = {
@@ -76,7 +66,6 @@ DynamicButton.defaultProps = {
selectedDynamicValue: null, selectedDynamicValue: null,
onSelect: () => {}, onSelect: () => {},
enabled: false, enabled: false,
staticValueLabel: "Back to Static Value",
}; };
export default DynamicButton; export default DynamicButton;

View File

@@ -34,9 +34,3 @@
font-size: 11px; font-size: 11px;
} }
} }
.dynamic-icon {
display: flex !important;
align-items: center;
justify-content: center;
}

View File

@@ -1,112 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import moment from "moment";
import { includes } from "lodash";
import { isDynamicDate } from "@/services/parameters/DateParameter";
import DateInput from "@/components/DateInput";
import DateTimeInput from "@/components/DateTimeInput";
import DynamicButton from "@/components/dynamic-parameters/DynamicButton";
import "./DynamicParameters.less";
class DynamicDatePicker extends React.Component {
static propTypes = {
type: PropTypes.string,
className: PropTypes.string,
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
onSelect: PropTypes.func,
dynamicButtonOptions: PropTypes.shape({
staticValueLabel: PropTypes.string,
options: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string,
value: PropTypes.object,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
})
),
}),
dateOptions: PropTypes.any, // eslint-disable-line react/forbid-prop-types
};
static defaultProps = {
type: "",
className: "",
value: null,
parameter: null,
dynamicButtonOptions: {
options: [],
},
onSelect: () => {},
};
constructor(props) {
super(props);
this.dateComponentRef = React.createRef();
}
onDynamicValueSelect = dynamicValue => {
const { onSelect, parameter } = this.props;
if (dynamicValue === "static") {
const parameterValue = parameter.getExecutionValue();
if (parameterValue) {
onSelect(moment(parameterValue));
} else {
onSelect(null);
}
} else {
onSelect(dynamicValue.value);
}
// give focus to the DatePicker to get keyboard shortcuts to work
this.dateComponentRef.current.focus();
};
render() {
const { type, value, className, dateOptions, dynamicButtonOptions, onSelect } = this.props;
const hasDynamicValue = isDynamicDate(value);
const isDateTime = includes(type, "datetime");
const additionalAttributes = {};
let DateComponent = DateInput;
if (isDateTime) {
DateComponent = DateTimeInput;
if (includes(type, "with-seconds")) {
additionalAttributes.withSeconds = true;
}
}
if (moment.isMoment(value) || value === null) {
additionalAttributes.value = value;
}
if (hasDynamicValue) {
const dynamicDate = value;
additionalAttributes.placeholder = dynamicDate && dynamicDate.name;
additionalAttributes.value = null;
}
return (
<div className={classNames("date-parameter", className)}>
<DateComponent
{...dateOptions}
ref={this.dateComponentRef}
className={classNames("redash-datepicker", type, { "dynamic-value": hasDynamicValue })}
onSelect={onSelect}
suffixIcon={null}
{...additionalAttributes}
/>
<DynamicButton
options={dynamicButtonOptions.options}
staticValueLabel={dynamicButtonOptions.staticValueLabel}
selectedDynamicValue={hasDynamicValue ? value : null}
enabled={hasDynamicValue}
onSelect={this.onDynamicValueSelect}
/>
</div>
);
}
}
export default DynamicDatePicker;

View File

@@ -1,115 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import moment from "moment";
import { includes, isArray, isObject } from "lodash";
import { isDynamicDateRange } from "@/services/parameters/DateRangeParameter";
import DateRangeInput from "@/components/DateRangeInput";
import DateTimeRangeInput from "@/components/DateTimeRangeInput";
import DynamicButton from "@/components/dynamic-parameters/DynamicButton";
import "./DynamicParameters.less";
function isValidDateRangeValue(value) {
return isArray(value) && value.length === 2 && moment.isMoment(value[0]) && moment.isMoment(value[1]);
}
class DynamicDateRangePicker extends React.Component {
static propTypes = {
type: PropTypes.oneOf(["date-range", "datetime-range", "datetime-range-with-seconds"]).isRequired,
className: PropTypes.string,
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
onSelect: PropTypes.func,
dynamicButtonOptions: PropTypes.shape({
staticValueLabel: PropTypes.string,
options: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string,
value: PropTypes.object,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
})
),
}),
dateRangeOptions: PropTypes.any, // eslint-disable-line react/forbid-prop-types
};
static defaultProps = {
type: "date-range",
className: "",
value: null,
parameter: null,
dynamicButtonOptions: {
options: [],
},
onSelect: () => {},
};
constructor(props) {
super(props);
this.dateRangeComponentRef = React.createRef();
}
onDynamicValueSelect = dynamicValue => {
const { onSelect, parameter } = this.props;
if (dynamicValue === "static") {
const parameterValue = parameter.getExecutionValue();
if (isObject(parameterValue) && parameterValue.start && parameterValue.end) {
onSelect([moment(parameterValue.start), moment(parameterValue.end)]);
} else {
onSelect(null);
}
} else {
onSelect(dynamicValue.value);
}
// give focus to the DatePicker to get keyboard shortcuts to work
this.dateRangeComponentRef.current.focus();
};
render() {
const { type, value, onSelect, className, dynamicButtonOptions, dateRangeOptions, parameter, ...rest } = this.props;
const isDateTimeRange = includes(type, "datetime-range");
const hasDynamicValue = isDynamicDateRange(value);
const additionalAttributes = {};
let DateRangeComponent = DateRangeInput;
if (isDateTimeRange) {
DateRangeComponent = DateTimeRangeInput;
if (includes(type, "with-seconds")) {
additionalAttributes.withSeconds = true;
}
}
if (isValidDateRangeValue(value) || value === null) {
additionalAttributes.value = value;
}
if (hasDynamicValue) {
additionalAttributes.placeholder = [value && value.name];
additionalAttributes.value = null;
}
return (
<div {...rest} className={classNames("date-range-parameter", className)}>
<DateRangeComponent
{...dateRangeOptions}
ref={this.dateRangeComponentRef}
className={classNames("redash-datepicker date-range-input", type, { "dynamic-value": hasDynamicValue })}
onSelect={onSelect}
suffixIcon={null}
{...additionalAttributes}
/>
<DynamicButton
options={dynamicButtonOptions.options}
staticValueLabel={dynamicButtonOptions.staticValueLabel}
selectedDynamicValue={hasDynamicValue ? value : null}
enabled={hasDynamicValue}
onSelect={this.onDynamicValueSelect}
/>
</div>
);
}
}
export default DynamicDateRangePicker;

View File

@@ -1,28 +1,8 @@
@import "../../assets/less/inc/variables"; @import '../../assets/less/inc/variables';
.date-range-parameter,
.date-parameter {
position: relative;
}
.redash-datepicker { .redash-datepicker {
padding-right: 35px !important; .ant-calendar-picker-clear {
right: 35px;
&.date-range {
width: 294px;
}
&.datetime-range {
width: 352px;
}
&.datetime-range-with-seconds {
width: 382px;
}
&.dynamic-value {
width: 195px;
}
&.ant-picker-range .ant-picker-clear {
right: 35px !important;
background: transparent; background: transparent;
} }
@@ -32,22 +12,19 @@
&.dynamic-value { &.dynamic-value {
& ::placeholder { & ::placeholder {
color: @input-color !important; color: @text-color !important;
} }
&.date-range-input { &.date-range-input {
.ant-picker-active-bar { .ant-calendar-range-picker-input {
opacity: 0; width: 100%;
text-align: left;
} }
.ant-picker-separator, .ant-calendar-range-picker-separator,
.ant-picker-range-separator { .ant-calendar-range-picker-input:not(:first-child) {
display: none; display: none;
} }
.ant-picker-input:not(:first-child) {
width: 0;
}
} }
} }
} }

View File

@@ -1,50 +1,18 @@
import React from "react"; import React from "react";
type DefaultStepKey = "dataSources" | "queries" | "alerts" | "dashboards" | "users"; export interface EmptyStateProps {
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; header?: string;
icon?: string; icon?: string;
description: string; description: string;
illustration: string; illustration: string;
illustrationPath?: string; helpLink: string;
helpMessage?: React.ReactNode;
closable?: boolean;
onClose?: () => void;
onboardingMode?: boolean; onboardingMode?: boolean;
showAlertStep?: boolean; showAlertStep?: boolean;
showDashboardStep?: boolean; showDashboardStep?: boolean;
showDataSourceStep?: boolean;
showInviteStep?: boolean; showInviteStep?: boolean;
getStepsItems?: (items: Array<StepItem<K>>) => Array<StepItem<K>>;
} }
declare class EmptyState<R> extends React.Component<EmptyStateProps<R>> {} declare const EmptyState: React.FunctionComponent<EmptyStateProps>;
export default EmptyState; 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>;

View File

@@ -2,24 +2,21 @@ import { keys, some } from "lodash";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import classNames from "classnames"; import classNames from "classnames";
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
import Link from "@/components/Link";
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog"; import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
import HelpTrigger from "@/components/HelpTrigger";
import { currentUser } from "@/services/auth"; import { currentUser } from "@/services/auth";
import organizationStatus from "@/services/organizationStatus"; import organizationStatus from "@/services/organizationStatus";
import "./empty-state.less"; import "./empty-state.less";
export function Step({ show, completed, text, url, urlTarget, urlText, onClick }) { function Step({ show, completed, text, url, urlText, onClick }) {
if (!show) { if (!show) {
return null; return null;
} }
return ( return (
<li className={classNames({ done: completed })}> <li className={classNames({ done: completed })}>
<Link href={url} onClick={onClick} target={urlTarget}> <a href={url} onClick={onClick}>
{urlText} {urlText}
</Link>{" "} </a>{" "}
{text} {text}
</li> </li>
); );
@@ -28,54 +25,31 @@ export function Step({ show, completed, text, url, urlTarget, urlText, onClick }
Step.propTypes = { Step.propTypes = {
show: PropTypes.bool.isRequired, show: PropTypes.bool.isRequired,
completed: PropTypes.bool.isRequired, completed: PropTypes.bool.isRequired,
text: PropTypes.node, text: PropTypes.string.isRequired,
url: PropTypes.string, url: PropTypes.string,
urlTarget: PropTypes.string, urlText: PropTypes.string,
urlText: PropTypes.node,
onClick: PropTypes.func, onClick: PropTypes.func,
}; };
Step.defaultProps = { Step.defaultProps = {
url: null, url: null,
urlTarget: null,
urlText: null, urlText: null,
text: null,
onClick: null, onClick: null,
}; };
export function EmptyStateHelpMessage({ helpTriggerType }) {
return (
<p>
Need more support?{" "}
<HelpTrigger className="f-14" type={helpTriggerType} showTooltip={false}>
See our Help
</HelpTrigger>
</p>
);
}
EmptyStateHelpMessage.propTypes = {
helpTriggerType: PropTypes.string.isRequired,
};
function EmptyState({ function EmptyState({
icon, icon,
header, header,
description, description,
illustration, illustration,
helpMessage, helpLink,
closable,
onClose,
onboardingMode, onboardingMode,
showAlertStep, showAlertStep,
showDashboardStep, showDashboardStep,
showDataSourceStep,
showInviteStep, showInviteStep,
getStepsItems,
illustrationPath,
}) { }) {
const isAvailable = { const isAvailable = {
dataSource: showDataSourceStep, dataSource: true,
query: true, query: true,
alert: showAlertStep, alert: showAlertStep,
dashboard: showDashboardStep, dashboard: showDashboardStep,
@@ -101,89 +75,7 @@ function EmptyState({
return null; return null;
} }
const renderDataSourcesStep = () => {
if (currentUser.isAdmin) {
return ( 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 bg-white tiled">
<div className="empty-state__summary"> <div className="empty-state__summary">
{header && <h4>{header}</h4>} {header && <h4>{header}</h4>}
@@ -191,19 +83,68 @@ function EmptyState({
<i className={icon} /> <i className={icon} />
</h2> </h2>
<p>{description}</p> <p>{description}</p>
<img src={imageSource} alt={illustration + " Illustration"} width="75%" /> <img
src={"/static/images/illustrations/" + illustration + ".svg"}
alt={illustration + " Illustration"}
width="75%"
/>
</div> </div>
<div className="empty-state__steps"> <div className="empty-state__steps">
<h4>Let&apos;s get started</h4> <h4>Let&apos;s get started</h4>
<ol>{stepsItems.map(item => item.node)}</ol> <ol>
{helpMessage} {currentUser.isAdmin && (
</div> <Step
</div> show={isAvailable.dataSource}
{closable && ( completed={isCompleted.dataSource}
<a className="close-button" onClick={onClose}> url="data_sources/new"
<CloseOutlinedIcon /> urlText="Connect"
</a> 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>
</div>
</div> </div>
); );
} }
@@ -213,31 +154,21 @@ EmptyState.propTypes = {
header: PropTypes.string, header: PropTypes.string,
description: PropTypes.string.isRequired, description: PropTypes.string.isRequired,
illustration: PropTypes.string.isRequired, illustration: PropTypes.string.isRequired,
illustrationPath: PropTypes.string, helpLink: PropTypes.string.isRequired,
helpMessage: PropTypes.node,
closable: PropTypes.bool,
onClose: PropTypes.func,
onboardingMode: PropTypes.bool, onboardingMode: PropTypes.bool,
showAlertStep: PropTypes.bool, showAlertStep: PropTypes.bool,
showDashboardStep: PropTypes.bool, showDashboardStep: PropTypes.bool,
showDataSourceStep: PropTypes.bool,
showInviteStep: PropTypes.bool, showInviteStep: PropTypes.bool,
getStepItems: PropTypes.func,
}; };
EmptyState.defaultProps = { EmptyState.defaultProps = {
icon: null, icon: null,
header: null, header: null,
helpMessage: null,
closable: false,
onClose: () => {},
onboardingMode: false, onboardingMode: false,
showAlertStep: false, showAlertStep: false,
showDashboardStep: false, showDashboardStep: false,
showDataSourceStep: true,
showInviteStep: false, showInviteStep: false,
}; };

View File

@@ -1,5 +1,3 @@
@import (reference, less) "~@/assets/less/ant";
// Empty states // Empty states
.empty-state { .empty-state {
width: 100%; width: 100%;
@@ -21,14 +19,11 @@
padding-left: 0px; padding-left: 0px;
} }
.empty-state__summary { .empty-state__summary {
align-self: flex-start; align-self: flex-start;
text-align: center; text-align: center;
background: rgba(102, 136, 153, 0.025); background: rgba(102, 136, 153, 0.025);
p {
margin-bottom: 0;
}
} }
ol { ol {
@@ -49,6 +44,10 @@
margin-bottom: 15px; margin-bottom: 15px;
} }
p {
margin-bottom: 0;
}
a:hover { a:hover {
cursor: pointer; cursor: pointer;
} }
@@ -72,22 +71,3 @@
} }
} }
} }
// close button
.empty-state-wrapper {
position: relative;
.close-button {
position: absolute;
top: 15px;
right: 25px;
font-size: 15px;
color: @text-color-secondary;
cursor: pointer;
transition: color @animation-duration-slow;
&:hover {
color: @text-color;
}
}
}

View File

@@ -3,42 +3,6 @@ import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import hoistNonReactStatics from "hoist-non-react-statics"; import hoistNonReactStatics from "hoist-non-react-statics";
import { clientConfig } from "@/services/auth"; 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({ export const ControllerType = PropTypes.shape({
// values of props declared by wrapped component and some additional props from items list // values of props declared by wrapped component and some additional props from items list
@@ -71,40 +35,15 @@ export const ControllerType = PropTypes.shape({
handleError: PropTypes.func.isRequired, // (error) => void handleError: PropTypes.func.isRequired, // (error) => void
}); });
export type GenericItemSourceError = AxiosError | Error; export function wrap(WrappedComponent, createItemsSource, createStateStorage) {
class ItemsListWrapper extends React.Component {
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 = { static propTypes = {
onError: PropTypes.func, onError: PropTypes.func,
children: PropTypes.node, children: PropTypes.node,
}; };
static defaultProps = { static defaultProps = {
onError: (error: GenericItemSourceError) => { onError: error => {
// Allow calling chain to roll up, and then throw the error in global context // Allow calling chain to roll up, and then throw the error in global context
setTimeout(() => { setTimeout(() => {
throw error; throw error;
@@ -113,7 +52,7 @@ export function wrap<I, P = any>(
children: null, children: null,
}; };
constructor(props: ItemsListWrapperProps) { constructor(props) {
super(props); super(props);
const stateStorage = createStateStorage(); const stateStorage = createStateStorage();
@@ -134,9 +73,7 @@ export function wrap<I, P = any>(
this.setState(this.getState({ ...state, isLoaded: true })); this.setState(this.getState({ ...state, isLoaded: true }));
}; };
itemsSource.onError = (error: GenericItemSourceError) => itemsSource.onError = error => this.props.onError(error);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.props.onError!(error);
const initialState = this.getState({ ...itemsSource.getState(), isLoaded: false }); const initialState = this.getState({ ...itemsSource.getState(), isLoaded: false });
const { updatePagination, toggleSorting, updateSearch, updateSelectedTags, update, handleError } = itemsSource; const { updatePagination, toggleSorting, updateSearch, updateSelectedTags, update, handleError } = itemsSource;
@@ -156,22 +93,13 @@ export function wrap<I, P = any>(
} }
componentWillUnmount() { componentWillUnmount() {
// eslint-disable-next-line @typescript-eslint/no-empty-function
this._itemsSource.onBeforeUpdate = () => {}; this._itemsSource.onBeforeUpdate = () => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
this._itemsSource.onAfterUpdate = () => {}; this._itemsSource.onAfterUpdate = () => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
this._itemsSource.onError = () => {}; this._itemsSource.onError = () => {};
} }
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
getState({ getState({ isLoaded, totalCount, pageItems, params, ...rest }) {
isLoaded,
totalCount,
pageItems,
params,
...rest
}: ItemsListWrapperState<I, P>): ItemsListWrapperState<I, P> {
return { return {
...rest, ...rest,
@@ -182,9 +110,9 @@ export function wrap<I, P = any>(
isLoaded, isLoaded,
isEmpty: !isLoaded || totalCount === 0, isEmpty: !isLoaded || totalCount === 0,
totalItemsCount: totalCount || 0, totalItemsCount: isLoaded ? totalCount : 0,
pageSizeOptions: (clientConfig as any).pageSizeOptions, // TODO: Type auth.js pageSizeOptions: clientConfig.pageSizeOptions,
pageItems: pageItems || [], pageItems: isLoaded ? pageItems : [],
}; };
} }

View File

@@ -1,51 +0,0 @@
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>);
}

View File

@@ -10,8 +10,6 @@ export class ItemsSource {
onError = null; onError = null;
sortByIteratees = undefined;
getCallbackContext = () => null; getCallbackContext = () => null;
_beforeUpdate() { _beforeUpdate() {
@@ -43,34 +41,21 @@ export class ItemsSource {
extend(customParams, params); extend(customParams, params);
}, },
}; };
return this._beforeUpdate().then(() => { return this._beforeUpdate().then(() =>
const fetchToken = Math.random() this._fetcher
.toString(36)
.substr(2);
this._currentFetchToken = fetchToken;
return this._fetcher
.fetch(changes, state, context) .fetch(changes, state, context)
.then(({ results, count, allResults }) => { .then(({ results, count, allResults }) => {
if (this._currentFetchToken === fetchToken) {
this._pageItems = results; this._pageItems = results;
this._allItems = allResults || null; this._allItems = allResults || null;
this._paginator.setTotalCount(count); this._paginator.setTotalCount(count);
this._params = { ...this._params, ...customParams }; this._params = { ...this._params, ...customParams };
return this._afterUpdate(); return this._afterUpdate();
}
}) })
.catch(error => this.handleError(error)); .catch(error => this.handleError(error))
}); );
} }
constructor({ constructor({ getRequest, doRequest, processResults, isPlainList = false, ...defaultState }) {
getRequest,
doRequest,
processResults,
isPlainList = false,
sortByIteratees = undefined,
...defaultState
}) {
if (!isFunction(getRequest)) { if (!isFunction(getRequest)) {
getRequest = identity; getRequest = identity;
} }
@@ -79,8 +64,6 @@ export class ItemsSource {
? new PlainListFetcher({ getRequest, doRequest, processResults }) ? new PlainListFetcher({ getRequest, doRequest, processResults })
: new PaginatedListFetcher({ getRequest, doRequest, processResults }); : new PaginatedListFetcher({ getRequest, doRequest, processResults });
this.sortByIteratees = sortByIteratees;
this.setState(defaultState); this.setState(defaultState);
this._pageItems = []; this._pageItems = [];
@@ -104,7 +87,7 @@ export class ItemsSource {
setState(state) { setState(state) {
this._paginator = new Paginator(state); this._paginator = new Paginator(state);
this._sorter = new Sorter(state, this.sortByIteratees); this._sorter = new Sorter(state);
this._searchTerm = state.searchTerm || ""; this._searchTerm = state.searchTerm || "";
this._selectedTags = state.selectedTags || []; this._selectedTags = state.selectedTags || [];

View File

@@ -24,8 +24,6 @@ export default class Sorter {
reverse = false; reverse = false;
sortByIteratees = null;
get compiled() { get compiled() {
return compile(this.field, this.reverse); return compile(this.field, this.reverse);
} }
@@ -44,10 +42,9 @@ export default class Sorter {
this.reverse = !!value; // cast to boolean this.reverse = !!value; // cast to boolean
} }
constructor({ orderByField, orderByReverse } = {}, sortByIteratees = undefined) { constructor({ orderByField, orderByReverse } = {}) {
this.setField(orderByField); this.setField(orderByField);
this.setReverse(orderByReverse); this.setReverse(orderByReverse);
this.sortByIteratees = sortByIteratees;
} }
toggleField(field) { toggleField(field) {
@@ -64,8 +61,7 @@ export default class Sorter {
sort(items) { sort(items) {
if (this.field) { if (this.field) {
const customIteratee = this.sortByIteratees && this.sortByIteratees[this.field]; items = sortBy(items, this.field);
items = sortBy(items, customIteratee ? [customIteratee] : this.field);
if (this.reverse) { if (this.reverse) {
items.reverse(); items.reverse();
} }

View File

@@ -1,9 +1,8 @@
import { isFunction, map, filter, extend, omit, identity, range, isEmpty } from "lodash"; import { isFunction, map, filter, extend, omit, identity } from "lodash";
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import classNames from "classnames"; import classNames from "classnames";
import Table from "antd/lib/table"; import Table from "antd/lib/table";
import Skeleton from "antd/lib/skeleton";
import FavoritesControl from "@/components/FavoritesControl"; import FavoritesControl from "@/components/FavoritesControl";
import TimeAgo from "@/components/TimeAgo"; import TimeAgo from "@/components/TimeAgo";
import { durationHumanize, formatDate, formatDateTime } from "@/lib/utils"; import { durationHumanize, formatDate, formatDateTime } from "@/lib/utils";
@@ -67,10 +66,10 @@ export const Columns = {
overrides overrides
); );
}, },
timeAgo(overrides, timeAgoCustomProps = undefined) { timeAgo(overrides) {
return extend( return extend(
{ {
render: value => <TimeAgo date={value} {...timeAgoCustomProps} />, render: value => <TimeAgo date={value} />,
}, },
overrides overrides
); );
@@ -110,8 +109,6 @@ export default class ItemsTable extends React.Component {
orderByField: PropTypes.string, orderByField: PropTypes.string,
orderByReverse: PropTypes.bool, orderByReverse: PropTypes.bool,
toggleSorting: PropTypes.func, toggleSorting: PropTypes.func,
"data-test": PropTypes.string,
rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
}; };
static defaultProps = { static defaultProps = {
@@ -144,7 +141,7 @@ export default class ItemsTable extends React.Component {
return extend(omit(column, ["field", "orderByField", "render"]), { return extend(omit(column, ["field", "orderByField", "render"]), {
key: "column" + index, key: "column" + index,
dataIndex: ["item", column.field], dataIndex: "item[" + JSON.stringify(column.field) + "]",
defaultSortOrder: column.orderByField === orderByField ? orderByDirection : null, defaultSortOrder: column.orderByField === orderByField ? orderByDirection : null,
onHeaderCell, onHeaderCell,
render, render,
@@ -153,22 +150,9 @@ export default class ItemsTable extends React.Component {
); );
} }
getRowKey = record => {
const { rowKey } = this.props;
if (rowKey) {
if (isFunction(rowKey)) {
return rowKey(record.item);
}
return record.item[rowKey];
}
return record.key;
};
render() { render() {
const tableDataProps = { const columns = this.prepareColumns();
columns: this.prepareColumns(), const rows = map(this.props.items, (item, index) => ({ key: "row" + index, item }));
dataSource: map(this.props.items, (item, index) => ({ key: "row" + index, item })),
};
// Bind events only if `onRowClick` specified // Bind events only if `onRowClick` specified
const onTableRow = isFunction(this.props.onRowClick) const onTableRow = isFunction(this.props.onRowClick)
@@ -180,28 +164,17 @@ export default class ItemsTable extends React.Component {
: null; : null;
const { showHeader } = this.props; 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 ( return (
<Table <Table
className={classNames("table-data", { "ant-table-headerless": !showHeader })} className={classNames("table-data", { "ant-table-headerless": !showHeader })}
loading={this.props.loading}
columns={columns}
showHeader={showHeader} showHeader={showHeader}
rowKey={this.getRowKey} dataSource={rows}
rowKey={row => row.key}
pagination={false} pagination={false}
onRow={onTableRow} onRow={onTableRow}
data-test={this.props["data-test"]}
{...tableDataProps}
/> />
); );
} }

View File

@@ -3,7 +3,6 @@ import React, { useState, useCallback, useEffect } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import Input from "antd/lib/input"; import Input from "antd/lib/input";
import AntdMenu from "antd/lib/menu"; import AntdMenu from "antd/lib/menu";
import Link from "@/components/Link";
import TagsList from "@/components/TagsList"; import TagsList from "@/components/TagsList";
/* /*
@@ -60,7 +59,7 @@ export function Menu({ items, selected }) {
<AntdMenu className="invert-stripe-position" mode="inline" selectable={false} selectedKeys={[selected]}> <AntdMenu className="invert-stripe-position" mode="inline" selectable={false} selectedKeys={[selected]}>
{map(items, item => ( {map(items, item => (
<AntdMenu.Item key={item.key} className="m-0"> <AntdMenu.Item key={item.key} className="m-0">
<Link href={item.href}> <a href={item.href}>
{isString(item.icon) && item.icon !== "" && ( {isString(item.icon) && item.icon !== "" && (
<span className="btn-favourite m-r-5"> <span className="btn-favourite m-r-5">
<i className={item.icon} aria-hidden="true" /> <i className={item.icon} aria-hidden="true" />
@@ -68,7 +67,7 @@ export function Menu({ items, selected }) {
)} )}
{isFunction(item.icon) && (item.icon(item) || null)} {isFunction(item.icon) && (item.icon(item) || null)}
{item.title} {item.title}
</Link> </a>
</AntdMenu.Item> </AntdMenu.Item>
))} ))}
</AntdMenu> </AntdMenu>
@@ -132,13 +131,13 @@ ProfileImage.propTypes = {
Tags Tags
*/ */
export function Tags({ url, onChange, showUnselectAll }) { export function Tags({ url, onChange }) {
if (url === "") { if (url === "") {
return null; return null;
} }
return ( return (
<div className="m-b-10"> <div className="m-b-10">
<TagsList tagsUrl={url} onUpdate={onChange} showUnselectAll={showUnselectAll} /> <TagsList tagsUrl={url} onUpdate={onChange} />
</div> </div>
); );
} }
@@ -146,6 +145,4 @@ export function Tags({ url, onChange, showUnselectAll }) {
Tags.propTypes = { Tags.propTypes = {
url: PropTypes.string.isRequired, url: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
showUnselectAll: PropTypes.bool,
unselectAllButtonTitle: PropTypes.string,
}; };

View File

@@ -1,77 +0,0 @@
import { filter, includes, intersection } from "lodash";
import React, { useState, useMemo, useEffect, useCallback } from "react";
import Checkbox from "antd/lib/checkbox";
import { Columns } from "../components/ItemsTable";
export default function useItemsListExtraActions(controller, listColumns, ExtraActionsComponent) {
const [actionsState, setActionsState] = useState({ isAvailable: false });
const [selectedItems, setSelectedItems] = useState([]);
// clear selection when page changes
useEffect(() => {
setSelectedItems([]);
}, [controller.pageItems, actionsState.isAvailable]);
const areAllItemsSelected = useMemo(() => {
const allItems = controller.pageItems;
if (allItems.length === 0 || selectedItems.length === 0) {
return false;
}
return intersection(selectedItems, allItems).length === allItems.length;
}, [selectedItems, controller.pageItems]);
const toggleAllItems = useCallback(() => {
if (areAllItemsSelected) {
setSelectedItems([]);
} else {
setSelectedItems(controller.pageItems);
}
}, [areAllItemsSelected, controller.pageItems]);
const toggleItem = useCallback(
item => {
if (includes(selectedItems, item)) {
setSelectedItems(filter(selectedItems, s => s !== item));
} else {
setSelectedItems([...selectedItems, item]);
}
},
[selectedItems]
);
const checkboxColumn = useMemo(
() =>
Columns.custom(
(text, item) => <Checkbox checked={includes(selectedItems, item)} onChange={() => toggleItem(item)} />,
{
title: () => <Checkbox checked={areAllItemsSelected} onChange={toggleAllItems} />,
field: "id",
width: "1%",
}
),
[selectedItems, areAllItemsSelected, toggleAllItems, toggleItem]
);
const Component = useCallback(
function ItemsListExtraActionsComponentWrapper(props) {
// this check mostly needed to avoid eslint exhaustive deps warning
if (!ExtraActionsComponent) {
return null;
}
return <ExtraActionsComponent onStateChange={setActionsState} {...props} />;
},
[ExtraActionsComponent]
);
return useMemo(
() => ({
areExtraActionsAvailable: actionsState.isAvailable,
listColumns: actionsState.isAvailable ? [checkboxColumn, ...listColumns] : listColumns,
Component,
selectedItems,
setSelectedItems,
}),
[actionsState, listColumns, checkboxColumn, selectedItems, Component]
);
}

View File

@@ -42,7 +42,7 @@ Content.defaultProps = defaultProps;
// Layout // Layout
export default function Layout({ children, className = undefined, ...props }) { export default function Layout({ className, children, ...props }) {
return ( return (
<div className={classNames("layout-with-sidebar", className)} {...props}> <div className={classNames("layout-with-sidebar", className)} {...props}>
{children} {children}

View File

@@ -9,7 +9,7 @@
margin: 0; margin: 0;
> .layout-content { > .layout-content {
flex: 1 0 auto; flex: 0 0 auto;
width: 75%; width: 75%;
order: 0; order: 0;
margin: 0; margin: 0;
@@ -18,7 +18,6 @@
> .layout-sidebar { > .layout-sidebar {
flex: 0 0 auto; flex: 0 0 auto;
width: 25%; width: 25%;
max-width: 350px;
order: 1; order: 1;
margin: 0; margin: 0;
padding: 0 0 0 @spacing; padding: 0 0 0 @spacing;
@@ -35,7 +34,6 @@
> .layout-sidebar { > .layout-sidebar {
width: 100%; width: 100%;
max-width: none;
order: 0; order: 0;
margin: 0 0 @spacing 0; margin: 0 0 @spacing 0;
padding: 0; padding: 0;

View File

@@ -31,6 +31,53 @@ export const RefreshScheduleDefault = {
until: null, until: null,
}; };
export const Field = PropTypes.shape({
name: PropTypes.string.isRequired,
title: PropTypes.string,
type: PropTypes.oneOf([
"ace",
"text",
"textarea",
"email",
"password",
"number",
"checkbox",
"file",
"select",
"content",
]).isRequired,
initialValue: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.bool,
PropTypes.arrayOf(PropTypes.string),
PropTypes.arrayOf(PropTypes.number),
]),
content: PropTypes.node,
mode: PropTypes.string,
required: PropTypes.bool,
extra: PropTypes.bool,
readOnly: PropTypes.bool,
autoFocus: PropTypes.bool,
minLength: PropTypes.number,
placeholder: PropTypes.string,
contentAfter: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
loading: PropTypes.bool,
props: PropTypes.object, // eslint-disable-line react/forbid-prop-types
});
export const Action = PropTypes.shape({
name: PropTypes.string.isRequired,
callback: PropTypes.func.isRequired,
type: PropTypes.string,
pullRight: PropTypes.bool,
disabledWhenDirty: PropTypes.bool,
});
export const AntdForm = PropTypes.shape({
validateFieldsAndScroll: PropTypes.func,
});
export const UserProfile = PropTypes.shape({ export const UserProfile = PropTypes.shape({
id: PropTypes.number.isRequired, id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,

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