Compare commits

...

58 Commits

Author SHA1 Message Date
Elad Ossadon
7d1cd87a5c 10 2020-12-17 15:46:36 -08:00
Elad Ossadon
14e51da97a 9 2020-12-17 15:31:12 -08:00
Elad Ossadon
6b3f1f9e27 7 2020-12-17 15:16:33 -08:00
Elad Ossadon
cbd51a896a 6 2020-12-17 15:16:33 -08:00
Elad Ossadon
0bf15ed559 5 2020-12-17 15:16:03 -08:00
Elad Ossadon
79591657e0 4 2020-12-17 15:16:03 -08:00
Elad Ossadon
b7ab070b62 3 2020-12-17 15:15:32 -08:00
Elad Ossadon
6d86312d6f [ts-migrate][client] Run TS Migrate
Co-authored-by: ts-migrate <>
2020-12-17 15:15:22 -08:00
Elad Ossadon
b0ec7a25d2 [ts-migrate][client] Rename files from JS/JSX to TS/TSX
Co-authored-by: ts-migrate <>
2020-12-17 15:13:51 -08:00
Elad Ossadon
13dead75fa 2 2020-12-17 15:10:23 -08:00
Patrick Yang
d0793c4ba8 Obfuscate non-email alert destinations (#5318) 2020-12-16 15:39:30 -08:00
Lingkai Kong
7b8bcdf356 change item element in system status page (#5323) 2020-12-16 11:22:19 -08:00
Elad Ossadon
c290864ccd Convert viz-lib to TypeScript (#5310)
Co-authored-by: ts-migrate <>
2020-12-15 18:21:37 -08:00
Rafael Wendel
b70e95a323 added eslint no-console (#5305)
* added eslint no-console

* Update client/.eslintrc.js to allow warnings

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
2020-12-14 10:09:43 -03:00
Elad Ossadon
18ee5343aa Sync date format from settings with clientConfig (#5299) 2020-12-10 11:16:31 -08:00
Elad Ossadon
fdf636a393 Fix disabled hot reload flow (#5306) 2020-12-07 16:02:52 -08:00
Rafael Wendel
88c13868a3 removed leftover console.log (#5303) 2020-12-07 17:21:40 -03:00
Elad Ossadon
aab11dc79b Add React Fast Refresh + Hot Module Reloading (#5291) 2020-12-07 11:46:46 -08:00
Elad Ossadon
00c77cf36e Redesign desktop nav bar (#5294) 2020-12-06 12:09:19 -08:00
Rafael Wendel
6e2631dec2 Changed 'Delete Alert' into 'Delete' for consistency (#5287) 2020-11-30 18:48:35 -03:00
Rafael Wendel
4b88959341 Fix QuerySourceDropdown value type (#5284) 2020-11-24 11:42:20 -03:00
Rafael Wendel
fa2b57a209 Remove unwanted props from Select component (#5277)
* Explicitly selected props so as to avoid errors from non-wanted props

* Simplified approach

* Ran prettier 😬

* Fixed minor issues
2020-11-22 13:07:56 -03:00
Jiajie Zhong
132fed64b3 Correct cleanup_query_results comment (#5276)
Correct comment from QUERY_RESULTS_MAX_AGE
to QUERY_RESULTS_CLEANUP_MAX_AGE
2020-11-20 23:11:13 +02:00
Gabriel Dutra
fa7ecca485 Frontend updates from internal fork (#5259)
* DynamicComponent for QuerySourceAlerts

* General Settings updates

* Dynamic Date[Range] updates

* EmptyState updates

* Query and SchemaBrowser updates

* Adjust page headers and add disablePublish

* Policy updates

* Separate Home FavoritesList component

* Update FormatQuery

* Autolimit frontend fixes

* Misc updates

* Keep registering of QuerySourceDropdown

* Undo changes in DynamicComponent

* Change sql-formatter package.json syntax

* Allow opening help trigger in new tab

* Don't run npm commands as root in Dockerfile

* Cypress: Remove extra execute query
2020-11-10 14:59:15 +02:00
deecay
8f484706b1 Enable Boxplot to be horizontal (#5262) 2020-11-08 23:17:08 +02:00
Josh Bohde
e2e8714155 Enable graceful shutdown of rq workers (#5214)
* Enable graceful shutdown of rq workers

* Use `exec` in the `worker` command of the entrypoint to propagate
  the `TERM` signal
* Allow rq processes managed by supervisor to exit without restart on
  expected status codes
* Allow supervisorctl to contact the running supervisor
* Add a `shutdown_worker` command that will send `TERM` to all running
  worker processes and then sleep. This allows orchestration systems
  to initiate a graceful shutdown before sending `SIGTERM` to
  supervisord

* Use Heroku worker as the BaseWorker

This implements a graceful shutdown on SIGTERM, which simplifies
external shutdown procedures.

* Fix imports based upon review

* Remove supervisorctl config
2020-11-05 11:49:45 +02:00
Jerry
c6bf8a1c55 bugfix: fix #5254 (#5255)
Co-authored-by: Jerry <jerry.yuan@webweye.com>
2020-11-04 20:56:41 +02:00
Rafael Wendel
12f71925c2 Multiselect dropdown slowness (fix) (#5221)
* created util to estimate reasonable width for dropdown

* removed unused import

* improved calculation of item percentile

* added getItemOfPercentileLength to relevant spots

* added getItemOfPercentileLength to relevant spots

* Added missing import

* created custom select element

* added check for property path

* removed uses of percentile util

* gave up on getting element reference

* finished testing Select component

* removed unused imports

* removed older uses of Option component

* added canvas calculation

* removed minWidth from Select

* improved calculation

* added fallbacks

* added estimated offset

* removed leftovers 😅

* replaced to percentiles to max value

* switched to memo and renamed component

* proper useMemo syntax

* Update client/app/components/Select.tsx

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>

* created custom restrictive types

* added quick const

* fixed style

* fixed generics

* added pos absolute to fix percy

* removed custom select from ParameterMappingInput

* applied prettier

* Revert "added pos absolute to fix percy"

This reverts commit 4daf1d4bef.

* Pin Percy version to 0.24.3

* Update client/app/components/ParameterMappingInput.jsx

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>

* renamed Select.jsx to SelectWithVirtualScroll

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
2020-11-03 21:50:39 +02:00
Omer Lachish
cae088f35b extend the refresh_queries timeout from 3 minutes to 10 minutes (#5253) 2020-11-02 22:36:57 +02:00
Rafael Wendel
a3c79f26b9 Fix for the typo button in ParameterMappingInput (#5244) 2020-10-29 17:24:13 -03:00
Jonathan Hult
c7c92a3192 Fix annotation bug causing queries not to run - ORA-00933 (#5179) 2020-10-28 10:03:26 +02:00
Rafael Wendel
55cf17aa47 added required to Form.Item and Input for better UI (#5231)
* added required to Form.Item and Input for better UI

* removed required from input

* Revert "removed required from input"

This reverts commit b56cd76fa1.

* Redo "removed required from input"

* removed typo

Co-authored-by: rafawendel2010@gmail.com <rafawendel>
2020-10-28 09:37:16 +02:00
Levko Kravets
8dd76a00c5 Fix dashboard background grid (#5238) 2020-10-26 21:46:38 +02:00
Christopher Grant
e242ac2b10 Static SAML configuration and assertion encryption (#5175)
* Change front-end and data model for SAML2 auth - static configuration

* Add changes to use inline metadata.

* add switch for static and dynamic SAML configurations

* Fixed config of backend static/dynamic to match UI

* add ability to encrypt/decrypt SAML assertions with pem and crt files. Upgraded to pysaml2 6.1.0 to mitigate signature mismatch during decryption

* remove print debug statement

* Use utility to find xmlsec binary for encryption, formatting saml_auth module

* format SAML Javascript, revert want_signed_response to pre-PR value

* pysaml2's entityid should point to the sp, not the idp

* add logging for entityid for validation

* use mustache_render instead of string formatting. put all static logic into static branch

* move mustache template for inline saml metadata to the global level

* Incorporate SAML type with Enabled setting

* Update client/app/pages/settings/components/AuthSettings/SAMLSettings.jsx

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>

Co-authored-by: Chad Chen <chad.chen@databricks.com>
Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
2020-10-25 12:06:45 -03:00
Gabriel Dutra
66463aedd4 Fix Home EmptyState help link (#5217) 2020-10-16 11:53:21 -03:00
Rafael Wendel
8a6524c1ba Add horizontal bar chart (#5154)
* added bar chart boilerplate

* added x/y manipulation

* replaced x/y management to inner series preparer

* added tests

* moved axis inversion to all charts series

* removed line and area

* inverted labels ui

* removed normalizer check, simplified inverted axes check

* finished working hbar

* minor review

* added conditional title to YAxis

* generalized horizontal chart for line charts, resetted state on globalSeriesType change

* fixed updates

* fixed updates to layout

* fixed minor issues

* removed right Y axis when axes inverted

* ran prettier

* fixed updater function conflict and misuse of getOptions

* renamed inverted to swapped

* created mappingtypes for swapped columns

* removed unused import

* minor polishing

* improved series behaviour in h-bar

* minor fix

* added basic filter to ChartTypeSelect

* final setup of filtered chart types

* Update viz-lib/src/components/visualizations/editor/createTabbedEditor.jsx

* added proptypes and renamed ChartTypeSelect props

* Add missing import

* fixed import, moved result array to global scope

* merged import

* clearer naming in ChartTypeSelect

* better lodash map syntax

* fixed global modification

* moved result inside useMemo

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
Co-authored-by: Levko Kravets <levko.ne@gmail.com>
2020-10-15 21:34:38 +03:00
Gabriel Dutra
9097feb100 Frontend updates from internal fork (#5209) 2020-10-15 14:25:22 -03:00
Gabriel Dutra
db4e97fa6f Remove build args from Cypress start script (#5203) 2020-10-09 12:23:14 -03:00
Levko Kravets
0d4615a482 Extra actions on Queries and Dashboards pages (#5201)
* Extra actions for Query View and Query Source pages

* Convert Queries List page to functional component

* Convert Dashboards List page to functional component

* Extra actions for Query List page

* Extra actions for Dashboard List page

* Extra actions for Dashboard page

* Pass some extra data to Dashboard.HeaderExtra component

* CR1
2020-10-09 12:12:56 +03:00
Alexander Rusanov
ff008a076b Updated Cypress to v5.3 and fixed e2e tests (#5199)
* Upgraded Cypress to v5.3 and fixed e2e tests

* Updated cypress image

* Fixed failing tests

* Updated NODE_VERSION in netlify

* Update client/cypress/integration/visualizations/choropleth_spec.js

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>

* fixed test in choropleth

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
2020-10-06 16:06:47 -03:00
Gabriel Dutra
8d548ecbac Share Embed Spec: Make sure query is executed (#5191) 2020-10-04 16:01:30 +03:00
Gabriel Dutra
2992c382d1 ScheduleDialog: Filter empty interval groups (#5196) 2020-10-03 05:54:05 +03:00
Gabriel Dutra
f4dcb2918a Move Cypress to dev dependencies (#3991)
* Test Cypress on package list

* Skip Puppeteer Chromium as well

* Put back missing npm install on netlify.toml

* Netlify: move env vars to build.environment

* Remove cypress:install script

* Update Cypress dockerfile

* Copy package-lock.json to Cypress dockerfile
2020-09-29 09:51:28 +03:00
Gabriel Dutra
c821cab4cb Generate Code Coverage report for Cypress (#5137) 2020-09-28 21:43:04 -03:00
Levko Kravets
4fb77867b0 Align Y axes at zero (#5053)
* Align Y axes as zero

* Fix typo (with @deecay)

* Add alignYAxesAtZero function

* Avoid 0 division

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
2020-09-28 13:12:40 +03:00
Levko Kravets
a473611cb0 Some Choropleth improvements/refactoring (#5186)
* Directly map query results column to GeoJSON property

* Use cache for geoJson requests

* Don't handle bounds changes while loading geoJson data

* Choropleth: fix map "jumping" on load; don't save bounds if user didn't edit them; refine code a bit

* Improve cache

* Optimize Japan Perfectures map (remove irrelevant GeoJson properties)

* Improve getOptions for Choropleth; remove unused code

* Fix test

* Add US states map

* Convert USA map to Albers projection

* Allow to specify user-friendly field names for maps
2020-09-24 14:39:09 +03:00
Levko Kravets
210008c714 Ask user to log in when session expires (#5178)
* Ask user to log in when session expires

* Update implementation

* Update implementation

* Minor fix

* Update modal

* Do not intercept calls to api/session as Auth.requireSession() relies on it

* Refine code; adjust popup size and position
2020-09-23 16:30:08 +03:00
Omer Lachish
aa5d4f5f4e add 'cancelled' meta directive to all cancelled jobs (#5187) 2020-09-23 12:54:48 +03:00
Omer Lachish
6b811c5245 Refresh CSRF tokens (#5177)
* expire CSRF tokens after 6 hours

* use axios' built-in cookie to header copy mechanism

* add axios-auth-refresh

* retry CSRF-related 400 errors by refreshing the cookie

* export the auth refresh interceptor to support ejecting it if neccessary

* reject the original request if it's unrelated to CSRF
2020-09-21 23:21:14 +03:00
Levko Kravets
83726da48a Keep additional URL params when forking a query (#5184) 2020-09-21 12:54:55 +03:00
Levko Kravets
72dc157bbe Allow to clear selected tags on list pages (#5142)
* Convert TagsList to functional component

* Convert TagsList to typescript

* Allow to unselect all tags

* Add title to Tags block and explicit "clear filter" button

* Some tweaks
2020-09-17 14:01:15 +03:00
Lingkai Kong
1b8ff8e810 Add default limit (1000) to SQL queries (#5088)
* add default limit 1000

* Add frontend changes and connect to backend

* Fix query hash because of default limit

* fix CircleCI test

* adjust for comment
2020-09-14 14:18:31 +03:00
Omer Lachish
31ddd0fb79 prevent assigning queries to view_only data sources (#5152) 2020-09-10 15:43:25 +03:00
Levko Kravets
5cabf7a724 Keep selected filters when switching visualizations (#5146)
* getredash/redash#4944 Query pages: keep selected filters when switching visualizations

* Pass current filters to expanded widget modal
2020-09-10 13:42:53 +03:00
max-voronov
59b135ace7 Move CardsList to typescript (#5136)
* Refactor CardsList - pass a suffix for list item

Adding :id to an item to be used as a key suffix is redundant and the same
can be accomplished by using :index from the map function.

* Move CardsList to typescript

* Convert CardsList component to functional component

* CR1

* CR2
2020-09-05 20:08:01 +03:00
Gabriel Dutra
32b41e4112 Misc frontend changes from internal fork (#5143) 2020-09-04 08:00:30 -03:00
Gabriel Dutra
2e31b91054 Antd v4: Fix CreateUserDialog (#5139)
* Antd v4: Update CreateUserDialog

* Add Cypress test for user creation
2020-09-04 07:57:43 -03:00
Gabriel Dutra
205915e6db Add toggle to disable public URLs (#5140)
* Add toggle to disable public URLs

* Add Cypress tests
2020-09-01 08:49:30 -03:00
746 changed files with 34118 additions and 23122 deletions

View File

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

View File

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

View File

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

2
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -18,8 +18,8 @@ worker() {
export WORKERS_COUNT=${WORKERS_COUNT:-2}
export QUEUES=${QUEUES:-}
supervisord -c worker.conf
exec supervisord -c worker.conf
}
dev_worker() {

View File

@@ -20,5 +20,10 @@
"globals": ["Error"]
}
]
]
],
"env": {
"test": {
"plugins": ["istanbul"]
}
}
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

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

View File

@@ -3,7 +3,7 @@ import AceEditor from "react-ace";
import "./AceEditorInput.less";
function AceEditorInput(props, ref) {
function AceEditorInput(props: any, ref: any) {
return (
<div className="ace-editor-input" data-test={props["data-test"]}>
<AceEditor

View File

@@ -1,176 +0,0 @@
import { first } from "lodash";
import React, { useState } from "react";
import Button from "antd/lib/button";
import Menu from "antd/lib/menu";
import Link from "@/components/Link";
import HelpTrigger from "@/components/HelpTrigger";
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
import { Auth, currentUser } from "@/services/auth";
import settingsMenu from "@/services/settingsMenu";
import logoUrl from "@/assets/images/redash_icon_small.png";
import DesktopOutlinedIcon from "@ant-design/icons/DesktopOutlined";
import CodeOutlinedIcon from "@ant-design/icons/CodeOutlined";
import AlertOutlinedIcon from "@ant-design/icons/AlertOutlined";
import PlusOutlinedIcon from "@ant-design/icons/PlusOutlined";
import QuestionCircleOutlinedIcon from "@ant-design/icons/QuestionCircleOutlined";
import SettingOutlinedIcon from "@ant-design/icons/SettingOutlined";
import MenuUnfoldOutlinedIcon from "@ant-design/icons/MenuUnfoldOutlined";
import MenuFoldOutlinedIcon from "@ant-design/icons/MenuFoldOutlined";
import VersionInfo from "./VersionInfo";
import "./DesktopNavbar.less";
function NavbarSection({ inlineCollapsed, children, ...props }) {
return (
<Menu
selectable={false}
mode={inlineCollapsed ? "inline" : "vertical"}
inlineCollapsed={inlineCollapsed}
theme="dark"
{...props}>
{children}
</Menu>
);
}
export default function DesktopNavbar() {
const [collapsed, setCollapsed] = useState(true);
const firstSettingsTab = first(settingsMenu.getAvailableItems());
const canCreateQuery = currentUser.hasPermission("create_query");
const canCreateDashboard = currentUser.hasPermission("create_dashboard");
const canCreateAlert = currentUser.hasPermission("list_alerts");
return (
<div className="desktop-navbar">
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-logo">
<div>
<Link href="./">
<img src={logoUrl} alt="Redash" />
</Link>
</div>
</NavbarSection>
<NavbarSection inlineCollapsed={collapsed}>
{currentUser.hasPermission("list_dashboards") && (
<Menu.Item key="dashboards">
<Link href="dashboards">
<DesktopOutlinedIcon />
<span>Dashboards</span>
</Link>
</Menu.Item>
)}
{currentUser.hasPermission("view_query") && (
<Menu.Item key="queries">
<Link href="queries">
<CodeOutlinedIcon />
<span>Queries</span>
</Link>
</Menu.Item>
)}
{currentUser.hasPermission("list_alerts") && (
<Menu.Item key="alerts">
<Link href="alerts">
<AlertOutlinedIcon />
<span>Alerts</span>
</Link>
</Menu.Item>
)}
</NavbarSection>
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-spacer">
{(canCreateQuery || canCreateDashboard || canCreateAlert) && <Menu.Divider />}
{(canCreateQuery || canCreateDashboard || canCreateAlert) && (
<Menu.SubMenu
key="create"
popupClassName="desktop-navbar-submenu"
title={
<React.Fragment>
<span data-test="CreateButton">
<PlusOutlinedIcon />
<span>Create</span>
</span>
</React.Fragment>
}>
{canCreateQuery && (
<Menu.Item key="new-query">
<Link href="queries/new" data-test="CreateQueryMenuItem">
New Query
</Link>
</Menu.Item>
)}
{canCreateDashboard && (
<Menu.Item key="new-dashboard">
<a data-test="CreateDashboardMenuItem" onMouseUp={() => CreateDashboardDialog.showModal()}>
New Dashboard
</a>
</Menu.Item>
)}
{canCreateAlert && (
<Menu.Item key="new-alert">
<Link data-test="CreateAlertMenuItem" href="alerts/new">
New Alert
</Link>
</Menu.Item>
)}
</Menu.SubMenu>
)}
</NavbarSection>
<NavbarSection inlineCollapsed={collapsed}>
<Menu.Item key="help">
<HelpTrigger showTooltip={false} type="HOME">
<QuestionCircleOutlinedIcon />
<span>Help</span>
</HelpTrigger>
</Menu.Item>
{firstSettingsTab && (
<Menu.Item key="settings">
<Link href={firstSettingsTab.path} data-test="SettingsLink">
<SettingOutlinedIcon />
<span>Settings</span>
</Link>
</Menu.Item>
)}
<Menu.Divider />
</NavbarSection>
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-profile-menu">
<Menu.SubMenu
key="profile"
popupClassName="desktop-navbar-submenu"
title={
<span data-test="ProfileDropdown" className="desktop-navbar-profile-menu-title">
<img className="profile__image_thumb" src={currentUser.profile_image_url} alt={currentUser.name} />
<span>{currentUser.name}</span>
</span>
}>
<Menu.Item key="profile">
<Link href="users/me">Profile</Link>
</Menu.Item>
{currentUser.hasPermission("super_admin") && (
<Menu.Item key="status">
<Link href="admin/status">System Status</Link>
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item key="logout">
<a data-test="LogOutButton" onClick={() => Auth.logout()}>
Log out
</a>
</Menu.Item>
<Menu.Divider />
<Menu.Item key="version" disabled className="version-info">
<VersionInfo />
</Menu.Item>
</Menu.SubMenu>
</NavbarSection>
<Button onClick={() => setCollapsed(!collapsed)} className="desktop-navbar-collapse-button">
{collapsed ? <MenuUnfoldOutlinedIcon /> : <MenuFoldOutlinedIcon />}
</Button>
</div>
);
}

View File

@@ -1,12 +1,17 @@
@backgroundColor: #001529;
@dividerColor: rgba(255, 255, 255, 0.5);
@textColor: rgba(255, 255, 255, 0.75);
@brandColor: #ff7964; // Redash logo color
@activeItemColor: @brandColor;
@iconSize: 26px;
.desktop-navbar {
background: @backgroundColor;
display: flex;
flex-direction: column;
height: 100%;
width: 80px;
overflow: hidden;
&-spacer {
flex: 1 1 auto;
@@ -21,12 +26,6 @@
height: 40px;
transition: all 270ms;
}
&.ant-menu-inline-collapsed {
img {
height: 20px;
}
}
}
.help-trigger {
@@ -34,26 +33,19 @@
}
.ant-menu {
&:not(.ant-menu-inline-collapsed) {
width: 170px;
}
&.ant-menu-inline-collapsed > .ant-menu-submenu-title span img + span,
&.ant-menu-inline-collapsed > .ant-menu-item i + span {
display: inline-block;
max-width: 0;
opacity: 0;
}
.ant-menu-item-divider {
background: @dividerColor;
}
.ant-menu-item,
.ant-menu-submenu {
font-weight: 500;
color: @textColor;
&.navbar-active-item {
box-shadow: inset 3px 0 0 @activeItemColor;
.anticon {
color: @activeItemColor;
}
}
&.ant-menu-submenu-open,
&.ant-menu-submenu-active,
&:hover,
@@ -61,6 +53,16 @@
color: #fff;
}
.anticon {
font-size: @iconSize;
margin: 0;
}
.desktop-navbar-label {
margin-top: 4px;
font-size: 11px;
}
a,
span,
.anticon {
@@ -71,21 +73,33 @@
.ant-menu-submenu-arrow {
display: none;
}
}
.ant-btn.desktop-navbar-collapse-button {
background-color: @backgroundColor;
border: 0;
border-radius: 0;
color: @textColor;
&:hover,
&:active {
color: #fff;
.ant-menu-item,
.ant-menu-submenu {
padding: 0;
height: 60px;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
}
&:after {
animation: 0s !important;
.ant-menu-submenu-title {
width: 100%;
padding: 0;
}
a,
&.ant-menu-vertical > .ant-menu-submenu > .ant-menu-submenu-title,
.ant-menu-submenu-title {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
line-height: normal;
height: auto;
background: none;
color: inherit;
}
}
@@ -99,37 +113,8 @@
.profile__image_thumb {
margin: 0;
vertical-align: middle;
}
.profile__image_thumb + span {
flex: 1 1 auto;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-left: 10px;
vertical-align: middle;
display: inline-block;
// styles from Antd
opacity: 1;
transition: opacity 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
margin-left 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), width 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
}
}
&.ant-menu-inline-collapsed {
.ant-menu-submenu-title {
padding-left: 16px !important;
padding-right: 16px !important;
}
.desktop-navbar-profile-menu-title {
.profile__image_thumb + span {
opacity: 0;
max-width: 0;
margin-left: 0;
}
width: @iconSize;
height: @iconSize;
}
}
}

View File

@@ -0,0 +1,150 @@
import React, { useMemo } from "react";
import { first, includes } from "lodash";
import Menu from "antd/lib/menu";
import Link from "@/components/Link";
import HelpTrigger from "@/components/HelpTrigger";
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
import { useCurrentRoute } from "@/components/ApplicationArea/Router";
import { Auth, currentUser } from "@/services/auth";
import settingsMenu from "@/services/settingsMenu";
// @ts-expect-error ts-migrate(2307) FIXME: Cannot find module '@/assets/images/redash_icon_sm... Remove this comment to see the full error message
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 VersionInfo from "./VersionInfo";
import "./DesktopNavbar.less";
function NavbarSection({ children, ...props }: any) {
return (<Menu selectable={false} mode="vertical" theme="dark" {...props}>
{children}
</Menu>);
}
function useNavbarActiveState() {
const currentRoute = useCurrentRoute();
return useMemo(() => ({
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
dashboards: includes(["Dashboards.List", "Dashboards.Favorites", "Dashboards.ViewOrEdit", "Dashboards.LegacyViewOrEdit"], currentRoute.id),
queries: includes([
"Queries.List",
"Queries.Favorites",
"Queries.Archived",
"Queries.My",
"Queries.View",
"Queries.New",
"Queries.Edit",
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
], currentRoute.id),
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
dataSources: includes(["DataSources.List"], currentRoute.id),
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
alerts: includes(["Alerts.List", "Alerts.New", "Alerts.View", "Alerts.Edit"], currentRoute.id),
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
}), [currentRoute.id]);
}
export default function DesktopNavbar() {
const firstSettingsTab = first(settingsMenu.getAvailableItems());
const activeState = useNavbarActiveState();
const canCreateQuery = currentUser.hasPermission("create_query");
const canCreateDashboard = currentUser.hasPermission("create_dashboard");
const canCreateAlert = currentUser.hasPermission("list_alerts");
return (<div className="desktop-navbar">
<NavbarSection className="desktop-navbar-logo">
<div>
<Link href="./">
<img src={logoUrl} alt="Redash"/>
</Link>
</div>
</NavbarSection>
<NavbarSection>
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
{currentUser.hasPermission("list_dashboards") && (<Menu.Item key="dashboards" className={activeState.dashboards ? "navbar-active-item" : null}>
<Link href="dashboards">
<DesktopOutlinedIcon />
<span className="desktop-navbar-label">Dashboards</span>
</Link>
</Menu.Item>)}
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
{currentUser.hasPermission("view_query") && (<Menu.Item key="queries" className={activeState.queries ? "navbar-active-item" : null}>
<Link href="queries">
<CodeOutlinedIcon />
<span className="desktop-navbar-label">Queries</span>
</Link>
</Menu.Item>)}
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
{currentUser.hasPermission("list_alerts") && (<Menu.Item key="alerts" className={activeState.alerts ? "navbar-active-item" : null}>
<Link href="alerts">
<AlertOutlinedIcon />
<span className="desktop-navbar-label">Alerts</span>
</Link>
</Menu.Item>)}
</NavbarSection>
<NavbarSection className="desktop-navbar-spacer">
{(canCreateQuery || canCreateDashboard || canCreateAlert) && (<Menu.SubMenu key="create" popupClassName="desktop-navbar-submenu" data-test="CreateButton" title={<React.Fragment>
<PlusOutlinedIcon />
<span className="desktop-navbar-label">Create</span>
</React.Fragment>}>
{canCreateQuery && (<Menu.Item key="new-query">
<Link href="queries/new" data-test="CreateQueryMenuItem">
New Query
</Link>
</Menu.Item>)}
{canCreateDashboard && (<Menu.Item key="new-dashboard">
{/* @ts-expect-error ts-migrate(2554) FIXME: Expected 1 arguments, but got 0. */}
<a data-test="CreateDashboardMenuItem" onMouseUp={() => CreateDashboardDialog.showModal()}>
New Dashboard
</a>
</Menu.Item>)}
{canCreateAlert && (<Menu.Item key="new-alert">
<Link data-test="CreateAlertMenuItem" href="alerts/new">
New Alert
</Link>
</Menu.Item>)}
</Menu.SubMenu>)}
</NavbarSection>
<NavbarSection>
<Menu.Item key="help">
{/* @ts-expect-error ts-migrate(2746) FIXME: This JSX tag's 'children' prop expects a single ch... Remove this comment to see the full error message */}
<HelpTrigger showTooltip={false} type="HOME">
<QuestionCircleOutlinedIcon />
<span className="desktop-navbar-label">Help</span>
</HelpTrigger>
</Menu.Item>
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
{firstSettingsTab && (<Menu.Item key="settings" className={activeState.dataSources ? "navbar-active-item" : null}>
<Link href={(firstSettingsTab as any).path} data-test="SettingsLink">
<SettingOutlinedIcon />
<span className="desktop-navbar-label">Settings</span>
</Link>
</Menu.Item>)}
</NavbarSection>
<NavbarSection className="desktop-navbar-profile-menu">
<Menu.SubMenu key="profile" popupClassName="desktop-navbar-submenu" title={<span data-test="ProfileDropdown" className="desktop-navbar-profile-menu-title">
<img className="profile__image_thumb" src={(currentUser as any).profile_image_url} alt={(currentUser as any).name}/>
</span>}>
<Menu.Item key="profile">
<Link href="users/me">Profile</Link>
</Menu.Item>
{currentUser.hasPermission("super_admin") && (<Menu.Item key="status">
<Link href="admin/status">System Status</Link>
</Menu.Item>)}
<Menu.Divider />
<Menu.Item key="logout">
<a data-test="LogOutButton" onClick={() => Auth.logout()}>
Log out
</a>
</Menu.Item>
<Menu.Divider />
<Menu.Item key="version" disabled className="version-info">
<VersionInfo />
</Menu.Item>
</Menu.SubMenu>
</NavbarSection>
</div>);
}

View File

@@ -1,6 +1,5 @@
import { first } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import MenuOutlinedIcon from "@ant-design/icons/MenuOutlined";
import Dropdown from "antd/lib/dropdown";
@@ -8,59 +7,46 @@ import Menu from "antd/lib/menu";
import Link from "@/components/Link";
import { Auth, currentUser } from "@/services/auth";
import settingsMenu from "@/services/settingsMenu";
// @ts-expect-error ts-migrate(2307) FIXME: Cannot find module '@/assets/images/redash_icon_sm... Remove this comment to see the full error message
import logoUrl from "@/assets/images/redash_icon_small.png";
import "./MobileNavbar.less";
export default function MobileNavbar({ getPopupContainer }) {
const firstSettingsTab = first(settingsMenu.getAvailableItems());
return (
<div className="mobile-navbar">
type OwnProps = {
getPopupContainer?: (...args: any[]) => any;
};
type Props = OwnProps & typeof MobileNavbar.defaultProps;
export default function MobileNavbar({ getPopupContainer }: Props) {
const firstSettingsTab = first(settingsMenu.getAvailableItems());
return (<div className="mobile-navbar">
<div className="mobile-navbar-logo">
<Link href="./">
<img src={logoUrl} alt="Redash" />
<img src={logoUrl} alt="Redash"/>
</Link>
</div>
<div>
<Dropdown
overlayStyle={{ minWidth: 200 }}
trigger={["click"]}
getPopupContainer={getPopupContainer} // so the overlay menu stays with the fixed header when page scrolls
overlay={
<Menu mode="vertical" theme="dark" selectable={false} className="mobile-navbar-menu">
{currentUser.hasPermission("list_dashboards") && (
<Menu.Item key="dashboards">
<Dropdown overlayStyle={{ minWidth: 200 }} trigger={["click"]} getPopupContainer={getPopupContainer} // so the overlay menu stays with the fixed header when page scrolls
overlay={<Menu mode="vertical" theme="dark" selectable={false} className="mobile-navbar-menu">
{currentUser.hasPermission("list_dashboards") && (<Menu.Item key="dashboards">
<Link href="dashboards">Dashboards</Link>
</Menu.Item>
)}
{currentUser.hasPermission("view_query") && (
<Menu.Item key="queries">
</Menu.Item>)}
{currentUser.hasPermission("view_query") && (<Menu.Item key="queries">
<Link href="queries">Queries</Link>
</Menu.Item>
)}
{currentUser.hasPermission("list_alerts") && (
<Menu.Item key="alerts">
</Menu.Item>)}
{currentUser.hasPermission("list_alerts") && (<Menu.Item key="alerts">
<Link href="alerts">Alerts</Link>
</Menu.Item>
)}
</Menu.Item>)}
<Menu.Item key="profile">
<Link href="users/me">Edit Profile</Link>
</Menu.Item>
<Menu.Divider />
{firstSettingsTab && (
<Menu.Item key="settings">
<Link href={firstSettingsTab.path}>Settings</Link>
</Menu.Item>
)}
{currentUser.hasPermission("super_admin") && (
<Menu.Item key="status">
{firstSettingsTab && (<Menu.Item key="settings">
<Link href={(firstSettingsTab as any).path}>Settings</Link>
</Menu.Item>)}
{currentUser.hasPermission("super_admin") && (<Menu.Item key="status">
<Link href="admin/status">System Status</Link>
</Menu.Item>
)}
</Menu.Item>)}
{currentUser.hasPermission("super_admin") && <Menu.Divider />}
<Menu.Item key="help">
{/* eslint-disable-next-line react/jsx-no-target-blank */}
<Link href="https://redash.io/help" target="_blank" rel="noopener">
Help
</Link>
@@ -68,21 +54,14 @@ export default function MobileNavbar({ getPopupContainer }) {
<Menu.Item key="logout" onClick={() => Auth.logout()}>
Log out
</Menu.Item>
</Menu>
}>
</Menu>}>
<Button className="mobile-navbar-toggle-button" ghost>
<MenuOutlinedIcon />
</Button>
</Dropdown>
</div>
</div>
);
</div>);
}
MobileNavbar.propTypes = {
getPopupContainer: PropTypes.func,
};
MobileNavbar.defaultProps = {
getPopupContainer: null,
getPopupContainer: null,
};

View File

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

View File

@@ -0,0 +1,20 @@
import React from "react";
import Link from "@/components/Link";
import { clientConfig, currentUser } from "@/services/auth";
// @ts-expect-error ts-migrate(7042) FIXME: Module '@/version.json' was resolved to '/Users/el... Remove this comment to see the full error message
import frontendVersion from "@/version.json";
export default function VersionInfo() {
return (<React.Fragment>
<div>
Version: {(clientConfig as any).version}
{frontendVersion !== (clientConfig as any).version && ` (${frontendVersion.substring(0, 8)})`}
</div>
{(clientConfig as any).newVersionAvailable && currentUser.hasPermission("super_admin") && (<div className="m-t-10">
<Link href="https://version.redash.io/" className="update-available" target="_blank" rel="noopener">
Update Available
<i className="fa fa-external-link m-l-5"/>
</Link>
</div>)}
</React.Fragment>);
}

View File

@@ -1,39 +0,0 @@
import React, { useRef, useCallback } from "react";
import PropTypes from "prop-types";
import DynamicComponent from "@/components/DynamicComponent";
import DesktopNavbar from "./DesktopNavbar";
import MobileNavbar from "./MobileNavbar";
import "./index.less";
export default function ApplicationLayout({ children }) {
const mobileNavbarContainerRef = useRef();
const getMobileNavbarPopupContainer = useCallback(() => mobileNavbarContainerRef.current, []);
return (
<React.Fragment>
<div className="application-layout-side-menu">
<DynamicComponent name="ApplicationDesktopNavbar">
<DesktopNavbar />
</DynamicComponent>
</div>
<div className="application-layout-content">
<nav className="application-layout-top-menu" ref={mobileNavbarContainerRef}>
<DynamicComponent name="ApplicationMobileNavbar" getPopupContainer={getMobileNavbarPopupContainer}>
<MobileNavbar getPopupContainer={getMobileNavbarPopupContainer} />
</DynamicComponent>
</nav>
{children}
</div>
</React.Fragment>
);
}
ApplicationLayout.propTypes = {
children: PropTypes.node,
};
ApplicationLayout.defaultProps = {
children: null,
};

View File

@@ -0,0 +1,46 @@
import React, { useRef, useCallback } from "react";
import DynamicComponent from "@/components/DynamicComponent";
import DesktopNavbar from "./DesktopNavbar";
import MobileNavbar from "./MobileNavbar";
import "./index.less";
type OwnProps = {
children?: React.ReactNode;
};
type Props = OwnProps & typeof ApplicationLayout.defaultProps;
export default function ApplicationLayout({ children }: Props) {
const mobileNavbarContainerRef = useRef();
const getMobileNavbarPopupContainer = useCallback(() => mobileNavbarContainerRef.current, []);
return (
<React.Fragment>
{/* @ts-expect-error ts-migrate(2746) FIXME: This JSX tag's 'children' prop expects a single ch... Remove this comment to see the full error message */}
<DynamicComponent name="ApplicationWrapper">
<div className="application-layout-side-menu">
<DynamicComponent name="ApplicationDesktopNavbar">
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
<DesktopNavbar />
</DynamicComponent>
</div>
<div className="application-layout-content">
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'MutableRefObject<undefined>' is not assignab... Remove this comment to see the full error message */}
<nav className="application-layout-top-menu" ref={mobileNavbarContainerRef}>
<DynamicComponent name="ApplicationMobileNavbar" getPopupContainer={getMobileNavbarPopupContainer}>
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
<MobileNavbar getPopupContainer={getMobileNavbarPopupContainer} />
</DynamicComponent>
</nav>
{children}
</div>
</DynamicComponent>
</React.Fragment>
);
}
ApplicationLayout.defaultProps = {
children: null,
};

View File

@@ -1,57 +0,0 @@
import { isObject, get } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import "./ErrorMessage.less";
function getErrorMessageByStatus(status, defaultMessage) {
switch (status) {
case 404:
return "It seems like the page you're looking for cannot be found.";
case 401:
case 403:
return "It seems like you dont have permission to see this page.";
default:
return defaultMessage;
}
}
function getErrorMessage(error) {
const message = "It seems like we encountered an error. Try refreshing this page or contact your administrator.";
if (isObject(error)) {
// HTTP errors
if (error.isAxiosError && isObject(error.response)) {
return getErrorMessageByStatus(error.response.status, get(error, "response.data.message", message));
}
// Router errors
if (error.status) {
return getErrorMessageByStatus(error.status, message);
}
}
return message;
}
export default function ErrorMessage({ error }) {
if (!error) {
return null;
}
console.error(error);
return (
<div className="error-message-container" data-test="ErrorMessage">
<div className="error-state bg-white tiled">
<div className="error-state__icon">
<i className="zmdi zmdi-alert-circle-o" />
</div>
<div className="error-state__details">
<h4>{getErrorMessage(error)}</h4>
</div>
</div>
</div>
);
}
ErrorMessage.propTypes = {
error: PropTypes.object.isRequired,
};

View File

@@ -1,51 +0,0 @@
import React from "react";
import { mount } from "enzyme";
import ErrorMessage from "./ErrorMessage";
const ErrorMessages = {
UNAUTHORIZED: "It seems like you dont have permission to see this page.",
NOT_FOUND: "It seems like the page you're looking for cannot be found.",
GENERIC: "It seems like we encountered an error. Try refreshing this page or contact your administrator.",
};
function mockAxiosError(status = 500, response = {}) {
const error = new Error(`Failed with code ${status}.`);
error.isAxiosError = true;
error.response = { status, ...response };
return error;
}
describe("Error Message", () => {
const spyError = jest.spyOn(console, "error");
beforeEach(() => {
spyError.mockReset();
});
function expectErrorMessageToBe(error, errorMessage) {
const component = mount(<ErrorMessage error={error} />);
expect(component.find(".error-state__details h4").text()).toBe(errorMessage);
expect(spyError).toHaveBeenCalledWith(error);
}
test("displays a generic message on adhoc errors", () => {
expectErrorMessageToBe(new Error("technical information"), ErrorMessages.GENERIC);
});
test("displays a not found message on axios errors with 404 code", () => {
expectErrorMessageToBe(mockAxiosError(404), ErrorMessages.NOT_FOUND);
});
test("displays a unauthorized message on axios errors with 401 code", () => {
expectErrorMessageToBe(mockAxiosError(401), ErrorMessages.UNAUTHORIZED);
});
test("displays a unauthorized message on axios errors with 403 code", () => {
expectErrorMessageToBe(mockAxiosError(403), ErrorMessages.UNAUTHORIZED);
});
test("displays a generic message on axios errors with 500 code", () => {
expectErrorMessageToBe(mockAxiosError(500), ErrorMessages.GENERIC);
});
});

View File

@@ -0,0 +1,40 @@
import React from "react";
import { mount } from "enzyme";
import ErrorMessage from "./ErrorMessage";
const ErrorMessages = {
UNAUTHORIZED: "It seems like you dont have permission to see this page.",
NOT_FOUND: "It seems like the page you're looking for cannot be found.",
GENERIC: "It seems like we encountered an error. Try refreshing this page or contact your administrator.",
};
function mockAxiosError(status = 500, response = {}) {
const error = new Error(`Failed with code ${status}.`);
(error as any).isAxiosError = true;
(error as any).response = { status, ...response };
return error;
}
describe("Error Message", () => {
const spyError = jest.spyOn(console, "error");
beforeEach(() => {
spyError.mockReset();
});
function expectErrorMessageToBe(error: any, errorMessage: any) {
const component = mount(<ErrorMessage error={error}/>);
expect(component.find(".error-state__details h4").text()).toBe(errorMessage);
expect(spyError).toHaveBeenCalledWith(error);
}
test("displays a generic message on adhoc errors", () => {
expectErrorMessageToBe(new Error("technical information"), ErrorMessages.GENERIC);
});
test("displays a not found message on axios errors with 404 code", () => {
expectErrorMessageToBe(mockAxiosError(404), ErrorMessages.NOT_FOUND);
});
test("displays a unauthorized message on axios errors with 401 code", () => {
expectErrorMessageToBe(mockAxiosError(401), ErrorMessages.UNAUTHORIZED);
});
test("displays a unauthorized message on axios errors with 403 code", () => {
expectErrorMessageToBe(mockAxiosError(403), ErrorMessages.UNAUTHORIZED);
});
test("displays a generic message on axios errors with 500 code", () => {
expectErrorMessageToBe(mockAxiosError(500), ErrorMessages.GENERIC);
});
});

View File

@@ -0,0 +1,54 @@
import { get, isObject } from "lodash";
import React from "react";
import "./ErrorMessage.less";
import DynamicComponent from "@/components/DynamicComponent";
import { ErrorMessageDetails } from "@/components/ApplicationArea/ErrorMessageDetails";
function getErrorMessageByStatus(status: any, defaultMessage: any) {
switch (status) {
case 404:
return "It seems like the page you're looking for cannot be found.";
case 401:
case 403:
return "It seems like you dont have permission to see this page.";
default:
return defaultMessage;
}
}
function getErrorMessage(error: any) {
const message = "It seems like we encountered an error. Try refreshing this page or contact your administrator.";
if (isObject(error)) {
// HTTP errors
if ((error as any).isAxiosError && isObject((error as any).response)) {
return getErrorMessageByStatus((error as any).response.status, get(error, "response.data.message", message));
}
// Router errors
if ((error as any).status) {
return getErrorMessageByStatus((error as any).status, message);
}
}
return message;
}
type Props = {
error: any;
message?: string;
};
export default function ErrorMessage({ error, message }: Props) {
if (!error) {
return null;
}
console.error(error);
const errorDetailsProps = {
error,
message: message || getErrorMessage(error),
};
return (<div className="error-message-container" data-test="ErrorMessage" role="alert">
<div className="error-state bg-white tiled">
<div className="error-state__icon">
<i className="zmdi zmdi-alert-circle-o"/>
</div>
<div className="error-state__details">
<DynamicComponent name="ErrorMessageDetails" fallback={<ErrorMessageDetails {...errorDetailsProps}/>} {...errorDetailsProps}/>
</div>
</div>
</div>);
}

View File

@@ -0,0 +1,10 @@
import React from "react";
type Props = {
error: any; // TODO: PropTypes.instanceOf(Error)
message: string;
};
export function ErrorMessageDetails(props: Props) {
return <h4>{props.message}</h4>;
}

View File

@@ -1,145 +0,0 @@
import { isFunction, startsWith, trimStart, trimEnd } from "lodash";
import React, { useState, useEffect, useRef, useContext } from "react";
import PropTypes from "prop-types";
import UniversalRouter from "universal-router";
import ErrorBoundary from "@redash/viz/lib/components/ErrorBoundary";
import location from "@/services/location";
import url from "@/services/url";
import ErrorMessage from "./ErrorMessage";
function generateRouteKey() {
return Math.random()
.toString(32)
.substr(2);
}
export const CurrentRouteContext = React.createContext(null);
export function useCurrentRoute() {
return useContext(CurrentRouteContext);
}
export function stripBase(href) {
// Resolve provided link and '' (root) relative to document's base.
// If provided href is not related to current document (does not
// start with resolved root) - return false. Otherwise
// strip root and return relative url.
const baseHref = trimEnd(url.normalize(""), "/") + "/";
href = url.normalize(href);
if (startsWith(href, baseHref)) {
return "/" + trimStart(href.substr(baseHref.length), "/");
}
return false;
}
export default function Router({ routes, onRouteChange }) {
const [currentRoute, setCurrentRoute] = useState(null);
const currentPathRef = useRef(null);
const errorHandlerRef = useRef();
useEffect(() => {
let isAbandoned = false;
const router = new UniversalRouter(routes, {
resolveRoute({ route }, routeParams) {
if (isFunction(route.render)) {
return { ...route, routeParams };
}
},
});
function resolve(action) {
if (!isAbandoned) {
if (errorHandlerRef.current) {
errorHandlerRef.current.reset();
}
const pathname = stripBase(location.path) || "/";
// This is a optimization for route resolver: if current route was already resolved
// from this path - do nothing. It also prevents router from using outdated route in a case
// when user navigated to another path while current one was still resolving.
// Note: this lock uses only `path` fragment of URL to distinguish routes because currently
// all pages depend only on this fragment and handle search/hash on their own. If router
// should reload page on search/hash change - this fragment (and few checks below) should be updated
if (pathname === currentPathRef.current) {
return;
}
currentPathRef.current = pathname;
// Don't reload controller if URL was replaced
if (action === "REPLACE") {
return;
}
router
.resolve({ pathname })
.then(route => {
if (!isAbandoned && currentPathRef.current === pathname) {
setCurrentRoute({ ...route, key: generateRouteKey() });
}
})
.catch(error => {
if (!isAbandoned && currentPathRef.current === pathname) {
setCurrentRoute({
render: currentRoute => <ErrorMessage {...currentRoute.routeParams} />,
routeParams: { error },
});
}
});
}
}
resolve("PUSH");
const unlisten = location.listen((unused, action) => resolve(action));
return () => {
isAbandoned = true;
currentPathRef.current = null;
unlisten();
};
}, [routes]);
useEffect(() => {
onRouteChange(currentRoute);
}, [currentRoute, onRouteChange]);
if (!currentRoute) {
return null;
}
return (
<CurrentRouteContext.Provider value={currentRoute}>
<ErrorBoundary ref={errorHandlerRef} renderError={error => <ErrorMessage error={error} />}>
{currentRoute.render(currentRoute)}
</ErrorBoundary>
</CurrentRouteContext.Provider>
);
}
Router.propTypes = {
routes: PropTypes.arrayOf(
PropTypes.shape({
path: PropTypes.string.isRequired,
render: PropTypes.func, // (routeParams: PropTypes.object; currentRoute; location) => PropTypes.node
// Additional props to be injected into route component.
// Object keys are props names. Object values will become prop values:
// - if value is a function - it will be called without arguments, and result will be used; otherwise value will be used;
// - after previous step, if value is a promise - router will wait for it to resolve; resolved value then will be used;
// otherwise value will be used directly.
resolve: PropTypes.objectOf(PropTypes.any),
})
),
onRouteChange: PropTypes.func,
};
Router.defaultProps = {
routes: [],
onRouteChange: () => {},
};

View File

@@ -0,0 +1,118 @@
import { isFunction, startsWith, trimStart, trimEnd } from "lodash";
import React, { useState, useEffect, useRef, useContext } from "react";
import UniversalRouter from "universal-router";
import ErrorBoundary from "@redash/viz/lib/components/ErrorBoundary";
import location from "@/services/location";
import url from "@/services/url";
import ErrorMessage from "./ErrorMessage";
function generateRouteKey() {
return Math.random()
.toString(32)
.substr(2);
}
export const CurrentRouteContext = React.createContext(null);
export function useCurrentRoute() {
return useContext(CurrentRouteContext);
}
export function stripBase(href: any) {
// Resolve provided link and '' (root) relative to document's base.
// If provided href is not related to current document (does not
// start with resolved root) - return false. Otherwise
// strip root and return relative url.
const baseHref = trimEnd(url.normalize(""), "/") + "/";
href = url.normalize(href);
if (startsWith(href, baseHref)) {
return "/" + trimStart(href.substr(baseHref.length), "/");
}
return false;
}
type OwnProps = {
routes?: {
path: string;
render?: (...args: any[]) => any;
resolve?: {
[key: string]: any;
};
}[];
onRouteChange?: (...args: any[]) => any;
};
type Props = OwnProps & typeof Router.defaultProps;
export default function Router({ routes, onRouteChange }: Props) {
const [currentRoute, setCurrentRoute] = useState(null);
const currentPathRef = useRef(null);
const errorHandlerRef = useRef();
useEffect(() => {
let isAbandoned = false;
const router = new UniversalRouter(routes, {
resolveRoute({ route }, routeParams) {
if (isFunction((route as any).render)) {
return { ...route, routeParams };
}
},
});
function resolve(action: any) {
if (!isAbandoned) {
if (errorHandlerRef.current) {
// @ts-expect-error ts-migrate(2532) FIXME: Object is possibly 'undefined'.
errorHandlerRef.current.reset();
}
const pathname = stripBase(location.path) || "/";
// This is a optimization for route resolver: if current route was already resolved
// from this path - do nothing. It also prevents router from using outdated route in a case
// when user navigated to another path while current one was still resolving.
// Note: this lock uses only `path` fragment of URL to distinguish routes because currently
// all pages depend only on this fragment and handle search/hash on their own. If router
// should reload page on search/hash change - this fragment (and few checks below) should be updated
if (pathname === currentPathRef.current) {
return;
}
// @ts-expect-error ts-migrate(2322) FIXME: Type 'string' is not assignable to type 'null'.
currentPathRef.current = pathname;
// Don't reload controller if URL was replaced
if (action === "REPLACE") {
return;
}
router
.resolve({ pathname })
.then(route => {
if (!isAbandoned && currentPathRef.current === pathname) {
setCurrentRoute({ ...route, key: generateRouteKey() });
}
})
.catch(error => {
if (!isAbandoned && currentPathRef.current === pathname) {
setCurrentRoute({
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type '{ render: (currentRoute: any) =>... Remove this comment to see the full error message
render: (currentRoute: any) => <ErrorMessage {...currentRoute.routeParams}/>,
routeParams: { error },
});
}
});
}
}
resolve("PUSH");
const unlisten = location.listen((unused: any, action: any) => resolve(action));
return () => {
isAbandoned = true;
currentPathRef.current = null;
unlisten();
};
}, [routes]);
useEffect(() => {
onRouteChange(currentRoute);
}, [currentRoute, onRouteChange]);
if (!currentRoute) {
return null;
}
return (<CurrentRouteContext.Provider value={currentRoute}>
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
<ErrorBoundary ref={errorHandlerRef} renderError={(error: any) => <ErrorMessage error={error}/>}>
{/* @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'. */}
{currentRoute.render(currentRoute)}
</ErrorBoundary>
</CurrentRouteContext.Provider>);
}
Router.defaultProps = {
routes: [],
onRouteChange: () => { },
};

View File

@@ -1,7 +1,7 @@
import { isString } from "lodash";
import navigateTo from "./navigateTo";
export default function handleNavigationIntent(event) {
export default function handleNavigationIntent(event: any) {
let element = event.target;
while (element) {
if (element.tagName === "A") {
@@ -9,7 +9,7 @@ export default function handleNavigationIntent(event) {
}
element = element.parentNode;
}
if (!element || !element.hasAttribute("href") || element.hasAttribute("download")) {
if (!element || !element.hasAttribute("href") || element.hasAttribute("download") || element.dataset.skipRouter) {
return;
}

View File

@@ -9,13 +9,15 @@ export default function ApplicationArea() {
const [unhandledError, setUnhandledError] = useState(null);
useEffect(() => {
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
if (currentRoute && currentRoute.title) {
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
document.title = currentRoute.title;
}
}, [currentRoute]);
useEffect(() => {
function globalErrorHandler(event) {
function globalErrorHandler(event: any) {
event.preventDefault();
setUnhandledError(event.error);
}
@@ -33,5 +35,6 @@ export default function ApplicationArea() {
return <ErrorMessage error={unhandledError} />;
}
// @ts-expect-error ts-migrate(2322) FIXME: Type 'RouteItem[]' is not assignable to type '{ pa... Remove this comment to see the full error message
return <Router routes={routes.items} onRouteChange={setCurrentRoute} />;
}

View File

@@ -4,7 +4,7 @@ import { stripBase } from "./Router";
// When `replace` is set to `true` - it will just replace current URL
// without reloading current page (router will skip this location change)
export default function navigateTo(href, replace = false) {
export default function navigateTo(href: any, replace = false) {
// Allow calling chain to roll up, and then navigate
setTimeout(() => {
const isExternal = stripBase(href) === false;

View File

@@ -1,63 +0,0 @@
import React, { useEffect, useState, useContext } from "react";
import PropTypes from "prop-types";
import { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
import { Auth } from "@/services/auth";
// 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
// - `apiKey` field
function ApiKeySessionWrapper({ apiKey, currentRoute, renderChildren }) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const { handleError } = useContext(ErrorBoundaryContext);
useEffect(() => {
let isCancelled = false;
Auth.setApiKey(apiKey);
Auth.loadConfig()
.then(() => {
if (!isCancelled) {
setIsAuthenticated(true);
}
})
.catch(() => {
if (!isCancelled) {
setIsAuthenticated(false);
}
});
return () => {
isCancelled = true;
};
}, [apiKey]);
if (!isAuthenticated) {
return null;
}
return (
<React.Fragment key={currentRoute.key}>
{renderChildren({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError, apiKey })}
</React.Fragment>
);
}
ApiKeySessionWrapper.propTypes = {
apiKey: PropTypes.string.isRequired,
renderChildren: PropTypes.func,
};
ApiKeySessionWrapper.defaultProps = {
renderChildren: () => null,
};
export default function routeWithApiKeySession({ render, getApiKey, ...rest }) {
return {
...rest,
render: currentRoute => (
<ApiKeySessionWrapper apiKey={getApiKey(currentRoute)} currentRoute={currentRoute} renderChildren={render} />
),
};
}

View File

@@ -0,0 +1,53 @@
import React, { useEffect, useState, useContext } from "react";
import { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
import { Auth, clientConfig } from "@/services/auth";
type OwnProps = {
apiKey: string;
renderChildren?: (...args: any[]) => any;
};
type Props = OwnProps & typeof ApiKeySessionWrapper.defaultProps;
// 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
// - `apiKey` field
// @ts-expect-error ts-migrate(2339) FIXME: Property 'currentRoute' does not exist on type 'Pr... Remove this comment to see the full error message
function ApiKeySessionWrapper({ apiKey, currentRoute, renderChildren }: Props) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const { handleError } = useContext(ErrorBoundaryContext);
useEffect(() => {
let isCancelled = false;
Auth.setApiKey(apiKey);
Auth.loadConfig()
.then(() => {
if (!isCancelled) {
setIsAuthenticated(true);
}
})
.catch(() => {
if (!isCancelled) {
setIsAuthenticated(false);
}
});
return () => {
isCancelled = true;
};
}, [apiKey]);
if (!isAuthenticated || (clientConfig as any).disablePublicUrls) {
return null;
}
return (<React.Fragment key={currentRoute.key}>
{renderChildren({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError, apiKey })}
</React.Fragment>);
}
ApiKeySessionWrapper.defaultProps = {
renderChildren: () => null,
};
export default function routeWithApiKeySession({ render, getApiKey, ...rest }: any) {
return {
...rest,
// @ts-expect-error ts-migrate(2322) FIXME: Type '{ apiKey: any; currentRoute: any; renderChil... Remove this comment to see the full error message
render: (currentRoute: any) => <ApiKeySessionWrapper apiKey={getApiKey(currentRoute)} currentRoute={currentRoute} renderChildren={render}/>,
};
}

View File

@@ -1,82 +0,0 @@
import React, { useEffect, useState } from "react";
import PropTypes from "prop-types";
import ErrorBoundary, { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
import { Auth } from "@/services/auth";
import { policy } from "@/services/policy";
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(), 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 => <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

@@ -0,0 +1,109 @@
import React, { useEffect, useState } from "react";
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>
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'Element' is not assignable to type 'null | u... Remove this comment to see the full error message */}
<React.Fragment key={currentRoute.key}>
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
<ErrorBoundary renderError={(error: Error) => <ErrorMessage error={error} />}>
<ErrorBoundaryContext.Consumer>
{({ handleError } /* : { handleError: UserSessionWrapperRenderChildrenProps<P>["onError"] } FIXME bring back type */) =>
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

@@ -7,47 +7,36 @@ import Link from "@/components/Link";
import HelpTrigger from "@/components/HelpTrigger";
import DynamicComponent from "@/components/DynamicComponent";
import OrgSettings from "@/services/organizationSettings";
const Text = Typography.Text;
function BeaconConsent() {
const [hide, setHide] = useState(false);
if (!clientConfig.showBeaconConsentMessage || hide) {
return null;
}
const hideConsentCard = () => {
clientConfig.showBeaconConsentMessage = false;
setHide(true);
};
const confirmConsent = confirm => {
let message = "🙏 Thank you.";
if (!confirm) {
message = "Settings Saved.";
const [hide, setHide] = useState(false);
if (!(clientConfig as any).showBeaconConsentMessage || hide) {
return null;
}
OrgSettings.save({ beacon_consent: confirm }, message)
// .then(() => {
// // const settings = get(response, 'settings');
// // this.setState({ settings, formValues: { ...settings } });
// })
.finally(hideConsentCard);
};
return (
<DynamicComponent name="BeaconConsent">
const hideConsentCard = () => {
(clientConfig as any).showBeaconConsentMessage = false;
setHide(true);
};
const confirmConsent = (confirm: any) => {
let message = "🙏 Thank you.";
if (!confirm) {
message = "Settings Saved.";
}
OrgSettings.save({ beacon_consent: confirm }, message)
// .then(() => {
// // const settings = get(response, 'settings');
// // this.setState({ settings, formValues: { ...settings } });
// })
.finally(hideConsentCard);
};
return (<DynamicComponent name="BeaconConsent">
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
<div className="m-t-10 tiled">
<Card
title={
<>
<Card title={<>
Would you be ok with sharing anonymous usage data with the Redash team?{" "}
<HelpTrigger type="USAGE_DATA_SHARING" />
</>
}
bordered={false}>
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'string' is not assignable to type 'never'. */}
<HelpTrigger type="USAGE_DATA_SHARING"/>
</>} bordered={false}>
<Text>Help Redash improve by automatically sending anonymous usage data:</Text>
<div className="m-t-5">
<ul>
@@ -72,8 +61,6 @@ function BeaconConsent() {
</div>
</Card>
</div>
</DynamicComponent>
);
</DynamicComponent>);
}
export default BeaconConsent;

View File

@@ -1,7 +1,15 @@
import React from "react";
import PropTypes from "prop-types";
function BigMessage({ message, icon, children, className }) {
type OwnProps = {
message?: string;
icon: string;
children?: React.ReactNode;
className?: string;
};
type Props = OwnProps & typeof BigMessage.defaultProps;
function BigMessage({ message, icon, children, className }: Props) {
return (
<div className={"p-15 text-center " + className}>
<h3 className="m-t-0 m-b-0">
@@ -14,13 +22,6 @@ function BigMessage({ message, icon, children, className }) {
);
}
BigMessage.propTypes = {
message: PropTypes.string,
icon: PropTypes.string.isRequired,
children: PropTypes.node,
className: PropTypes.string,
};
BigMessage.defaultProps = {
message: "",
children: null,

View File

@@ -1,24 +1,30 @@
import React from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import Tooltip from "antd/lib/tooltip";
import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined";
import "./CodeBlock.less";
export default class CodeBlock extends React.Component {
static propTypes = {
copyable: PropTypes.bool,
children: PropTypes.node,
};
type OwnProps = {
copyable?: boolean;
};
type State = any;
type Props = OwnProps & typeof CodeBlock.defaultProps;
export default class CodeBlock extends React.Component<Props, State> {
static defaultProps = {
copyable: false,
children: null,
};
copyFeatureEnabled: any;
ref: any;
resetCopyState: any;
state = { copied: null };
constructor(props) {
constructor(props: Props) {
super(props);
this.ref = React.createRef();
this.copyFeatureEnabled = props.copyable && document.queryCommandSupported("copy");
@@ -33,6 +39,7 @@ export default class CodeBlock extends React.Component {
copy = () => {
// select text
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
window.getSelection().selectAllChildren(this.ref.current);
// copy
@@ -49,6 +56,7 @@ export default class CodeBlock extends React.Component {
}
// reset selection
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
window.getSelection().removeAllRanges();
// reset tooltip

View File

@@ -1,12 +1,20 @@
import React from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import AntCollapse from "antd/lib/collapse";
export default function Collapse({ collapsed, children, className, ...props }) {
type OwnProps = {
collapsed?: boolean;
children?: React.ReactNode;
className?: string;
};
type Props = OwnProps & typeof Collapse.defaultProps;
export default function Collapse({ collapsed, children, className, ...props }: Props) {
return (
<AntCollapse
{...props}
// @ts-expect-error ts-migrate(2322) FIXME: Type 'string | null' is not assignable to type 'st... Remove this comment to see the full error message
activeKey={collapsed ? null : "content"}
className={cx(className, "ant-collapse-headerless")}>
<AntCollapse.Panel key="content" header="">
@@ -16,12 +24,6 @@ export default function Collapse({ collapsed, children, className, ...props }) {
);
}
Collapse.propTypes = {
collapsed: PropTypes.bool,
children: PropTypes.node,
className: PropTypes.string,
};
Collapse.defaultProps = {
collapsed: true,
children: null,

View File

@@ -1,198 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import { isEmpty, toUpper, includes, get } from "lodash";
import Button from "antd/lib/button";
import List from "antd/lib/list";
import Modal from "antd/lib/modal";
import Input from "antd/lib/input";
import Steps from "antd/lib/steps";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import Link from "@/components/Link";
import { PreviewCard } from "@/components/PreviewCard";
import EmptyState from "@/components/items-list/components/EmptyState";
import DynamicForm from "@/components/dynamic-form/DynamicForm";
import helper from "@/components/dynamic-form/dynamicFormHelper";
import HelpTrigger, { TYPES as HELP_TRIGGER_TYPES } from "@/components/HelpTrigger";
const { Step } = Steps;
const { Search } = Input;
const StepEnum = {
SELECT_TYPE: 0,
CONFIGURE_IT: 1,
DONE: 2,
};
class CreateSourceDialog extends React.Component {
static propTypes = {
dialog: DialogPropType.isRequired,
types: PropTypes.arrayOf(PropTypes.object),
sourceType: PropTypes.string.isRequired,
imageFolder: PropTypes.string.isRequired,
helpTriggerPrefix: PropTypes.string,
onCreate: PropTypes.func.isRequired,
};
static defaultProps = {
types: [],
helpTriggerPrefix: null,
};
state = {
searchText: "",
selectedType: null,
savingSource: false,
currentStep: StepEnum.SELECT_TYPE,
};
selectType = selectedType => {
this.setState({ selectedType, currentStep: StepEnum.CONFIGURE_IT });
};
resetType = () => {
if (this.state.currentStep === StepEnum.CONFIGURE_IT) {
this.setState({ searchText: "", selectedType: null, currentStep: StepEnum.SELECT_TYPE });
}
};
createSource = (values, successCallback, errorCallback) => {
const { selectedType, savingSource } = this.state;
if (!savingSource) {
this.setState({ savingSource: true, currentStep: StepEnum.DONE });
this.props
.onCreate(selectedType, values)
.then(data => {
successCallback("Saved.");
this.props.dialog.close({ success: true, data });
})
.catch(error => {
this.setState({ savingSource: false, currentStep: StepEnum.CONFIGURE_IT });
errorCallback(get(error, "response.data.message", "Failed saving."));
});
}
};
renderTypeSelector() {
const { types } = this.props;
const { searchText } = this.state;
const filteredTypes = types.filter(
type => isEmpty(searchText) || includes(type.name.toLowerCase(), searchText.toLowerCase())
);
return (
<div className="m-t-10">
<Search
placeholder="Search..."
onChange={e => this.setState({ searchText: e.target.value })}
autoFocus
data-test="SearchSource"
/>
<div className="scrollbox p-5 m-t-10" style={{ minHeight: "30vh", maxHeight: "40vh" }}>
{isEmpty(filteredTypes) ? (
<EmptyState className="" />
) : (
<List size="small" dataSource={filteredTypes} renderItem={item => this.renderItem(item)} />
)}
</div>
</div>
);
}
renderForm() {
const { imageFolder, helpTriggerPrefix } = this.props;
const { selectedType } = this.state;
const fields = helper.getFields(selectedType);
const helpTriggerType = `${helpTriggerPrefix}${toUpper(selectedType.type)}`;
return (
<div>
<div className="d-flex justify-content-center align-items-center">
<img className="p-5" src={`${imageFolder}/${selectedType.type}.png`} alt={selectedType.name} width="48" />
<h4 className="m-0">{selectedType.name}</h4>
</div>
<div className="text-right">
{HELP_TRIGGER_TYPES[helpTriggerType] && (
<HelpTrigger className="f-13" type={helpTriggerType}>
Setup Instructions <i className="fa fa-question-circle" />
</HelpTrigger>
)}
</div>
<DynamicForm id="sourceForm" fields={fields} onSubmit={this.createSource} feedbackIcons hideSubmitButton />
{selectedType.type === "databricks" && (
<small>
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">
Driver Download Terms and Conditions
</Link>
.
</small>
)}
</div>
);
}
renderItem(item) {
const { imageFolder } = this.props;
return (
<List.Item className="p-l-10 p-r-10 clickable" onClick={() => this.selectType(item)}>
<PreviewCard
title={item.name}
imageUrl={`${imageFolder}/${item.type}.png`}
roundedImage={false}
data-test="PreviewItem"
data-test-type={item.type}>
<i className="fa fa-angle-double-right" />
</PreviewCard>
</List.Item>
);
}
render() {
const { currentStep, savingSource } = this.state;
const { dialog, sourceType } = this.props;
return (
<Modal
{...dialog.props}
title={`Create a New ${sourceType}`}
footer={
currentStep === StepEnum.SELECT_TYPE
? [
<Button key="cancel" onClick={() => dialog.dismiss()} data-test="CreateSourceCancelButton">
Cancel
</Button>,
<Button key="submit" type="primary" disabled>
Create
</Button>,
]
: [
<Button key="previous" onClick={this.resetType}>
Previous
</Button>,
<Button
key="submit"
htmlType="submit"
form="sourceForm"
type="primary"
loading={savingSource}
data-test="CreateSourceSaveButton">
Create
</Button>,
]
}>
<div data-test="CreateSourceDialog">
<Steps className="hidden-xs m-b-10" size="small" current={currentStep} progressDot>
{currentStep === StepEnum.CONFIGURE_IT ? (
<Step title={<a>Type Selection</a>} className="clickable" onClick={this.resetType} />
) : (
<Step title="Type Selection" />
)}
<Step title="Configuration" />
<Step title="Done" />
</Steps>
{currentStep === StepEnum.SELECT_TYPE && this.renderTypeSelector()}
{currentStep !== StepEnum.SELECT_TYPE && this.renderForm()}
</div>
</Modal>
);
}
}
export default wrapDialog(CreateSourceDialog);

View File

@@ -0,0 +1,152 @@
import React from "react";
import { isEmpty, toUpper, includes, get } from "lodash";
import Button from "antd/lib/button";
import List from "antd/lib/list";
import Modal from "antd/lib/modal";
import Input from "antd/lib/input";
import Steps from "antd/lib/steps";
// @ts-expect-error ts-migrate(6133) FIXME: 'DialogPropType' is declared but its value is neve... Remove this comment to see the full error message
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import Link from "@/components/Link";
import { PreviewCard } from "@/components/PreviewCard";
import EmptyState from "@/components/items-list/components/EmptyState";
import DynamicForm from "@/components/dynamic-form/DynamicForm";
import helper from "@/components/dynamic-form/dynamicFormHelper";
import HelpTrigger, { TYPES as HELP_TRIGGER_TYPES } from "@/components/HelpTrigger";
const { Step } = Steps;
const { Search } = Input;
const StepEnum = {
SELECT_TYPE: 0,
CONFIGURE_IT: 1,
DONE: 2,
};
type OwnProps = {
// @ts-expect-error ts-migrate(2749) FIXME: 'DialogPropType' refers to a value, but is being u... Remove this comment to see the full error message
dialog: DialogPropType;
types?: any[];
sourceType: string;
imageFolder: string;
helpTriggerPrefix?: string;
onCreate: (...args: any[]) => any;
};
type State = any;
type Props = OwnProps & typeof CreateSourceDialog.defaultProps;
class CreateSourceDialog extends React.Component<Props, State> {
static defaultProps = {
types: [],
helpTriggerPrefix: null,
};
state = {
searchText: "",
selectedType: null,
savingSource: false,
currentStep: StepEnum.SELECT_TYPE,
};
selectType = (selectedType: any) => {
this.setState({ selectedType, currentStep: StepEnum.CONFIGURE_IT });
};
resetType = () => {
if (this.state.currentStep === StepEnum.CONFIGURE_IT) {
this.setState({ searchText: "", selectedType: null, currentStep: StepEnum.SELECT_TYPE });
}
};
createSource = (values: any, successCallback: any, errorCallback: any) => {
const { selectedType, savingSource } = this.state;
if (!savingSource) {
this.setState({ savingSource: true, currentStep: StepEnum.DONE });
(this.props as any).onCreate(selectedType, values)
.then((data: any) => {
successCallback("Saved.");
(this.props as any).dialog.close({ success: true, data });
})
.catch((error: any) => {
this.setState({ savingSource: false, currentStep: StepEnum.CONFIGURE_IT });
errorCallback(get(error, "response.data.message", "Failed saving."));
});
}
};
renderTypeSelector() {
const { types } = this.props;
const { searchText } = this.state;
const filteredTypes = (types as any).filter((type: any) => isEmpty(searchText) || includes(type.name.toLowerCase(), searchText.toLowerCase()));
return (<div className="m-t-10">
<Search placeholder="Search..." onChange={e => this.setState({ searchText: e.target.value })} autoFocus data-test="SearchSource"/>
<div className="scrollbox p-5 m-t-10" style={{ minHeight: "30vh", maxHeight: "40vh" }}>
{isEmpty(filteredTypes) ? (<EmptyState className=""/>) : (<List size="small" dataSource={filteredTypes} renderItem={item => this.renderItem(item)}/>)}
</div>
</div>);
}
renderForm() {
const { imageFolder, helpTriggerPrefix } = this.props;
const { selectedType } = this.state;
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'null' is not assignable to param... Remove this comment to see the full error message
const fields = helper.getFields(selectedType);
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
const helpTriggerType = `${helpTriggerPrefix}${toUpper(selectedType.type)}`;
return (<div>
<div className="d-flex justify-content-center align-items-center">
{/* @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'. */}
<img className="p-5" src={`${imageFolder}/${selectedType.type}.png`} alt={selectedType.name} width="48"/>
{/* @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'. */}
<h4 className="m-0">{selectedType.name}</h4>
</div>
<div className="text-right">
{/* @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message */}
{HELP_TRIGGER_TYPES[helpTriggerType] && (<HelpTrigger className="f-13" type={helpTriggerType}>
Setup Instructions <i className="fa fa-question-circle"/>
</HelpTrigger>)}
</div>
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'string' is not assignable to type 'never'. */}
<DynamicForm id="sourceForm" fields={fields} onSubmit={this.createSource} feedbackIcons hideSubmitButton/>
{/* @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'. */}
{selectedType.type === "databricks" && (<small>
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">
Driver Download Terms and Conditions
</Link>
.
</small>)}
</div>);
}
renderItem(item: any) {
const { imageFolder } = this.props;
return (<List.Item className="p-l-10 p-r-10 clickable" onClick={() => this.selectType(item)}>
<PreviewCard title={item.name} imageUrl={`${imageFolder}/${item.type}.png`} roundedImage={false} data-test="PreviewItem" data-test-type={item.type}>
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'Element' is not assignable to type 'null | u... Remove this comment to see the full error message */}
<i className="fa fa-angle-double-right"/>
</PreviewCard>
</List.Item>);
}
render() {
const { currentStep, savingSource } = this.state;
const { dialog, sourceType } = this.props;
return (<Modal {...(dialog as any).props} title={`Create a New ${sourceType}`} footer={currentStep === StepEnum.SELECT_TYPE
? [
<Button key="cancel" onClick={() => (dialog as any).dismiss()} data-test="CreateSourceCancelButton">
Cancel
</Button>,
<Button key="submit" type="primary" disabled>
Create
</Button>,
]
: [
<Button key="previous" onClick={this.resetType}>
Previous
</Button>,
<Button key="submit" htmlType="submit" form="sourceForm" type="primary" loading={savingSource} data-test="CreateSourceSaveButton">
Create
</Button>,
]}>
<div data-test="CreateSourceDialog">
<Steps className="hidden-xs m-b-10" size="small" current={currentStep} progressDot>
{currentStep === StepEnum.CONFIGURE_IT ? (<Step title={<a>Type Selection</a>} className="clickable" onClick={this.resetType}/>) : (<Step title="Type Selection"/>)}
<Step title="Configuration"/>
<Step title="Done"/>
</Steps>
{currentStep === StepEnum.SELECT_TYPE && this.renderTypeSelector()}
{currentStep !== StepEnum.SELECT_TYPE && this.renderForm()}
</div>
</Modal>);
}
}
export default wrapDialog(CreateSourceDialog);

View File

@@ -1,43 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import DatePicker from "antd/lib/date-picker";
import { clientConfig } from "@/services/auth";
import { Moment } from "@/components/proptypes";
const DateInput = React.forwardRef(({ defaultValue, value, onSelect, className, ...props }, ref) => {
const format = clientConfig.dateFormat || "YYYY-MM-DD";
const additionalAttributes = {};
if (defaultValue && defaultValue.isValid()) {
additionalAttributes.defaultValue = defaultValue;
}
if (value === null || (value && value.isValid())) {
additionalAttributes.value = value;
}
return (
<DatePicker
ref={ref}
className={className}
{...additionalAttributes}
format={format}
placeholder="Select Date"
onChange={onSelect}
{...props}
/>
);
});
DateInput.propTypes = {
defaultValue: Moment,
value: Moment,
onSelect: PropTypes.func,
className: PropTypes.string,
};
DateInput.defaultProps = {
defaultValue: null,
value: undefined,
onSelect: () => {},
className: "",
};
export default DateInput;

View File

@@ -0,0 +1,31 @@
import React from "react";
import DatePicker from "antd/lib/date-picker";
import { clientConfig } from "@/services/auth";
// @ts-expect-error ts-migrate(6133) FIXME: 'Moment' is declared but its value is never read.
import { Moment } from "@/components/proptypes";
type Props = {
// @ts-expect-error ts-migrate(2749) FIXME: 'Moment' refers to a value, but is being used as a... Remove this comment to see the full error message
defaultValue?: Moment;
// @ts-expect-error ts-migrate(2749) FIXME: 'Moment' refers to a value, but is being used as a... Remove this comment to see the full error message
value?: Moment;
onSelect?: (...args: any[]) => any;
className?: string;
};
const DateInput = React.forwardRef<any, Props>(({ defaultValue, value, onSelect, className, ...props }, ref) => {
const format = (clientConfig as any).dateFormat || "YYYY-MM-DD";
const additionalAttributes = {};
if (defaultValue && defaultValue.isValid()) {
(additionalAttributes as any).defaultValue = defaultValue;
}
if (value === null || (value && value.isValid())) {
(additionalAttributes as any).value = value;
}
return (<DatePicker ref={ref} className={className} {...additionalAttributes} format={format} placeholder="Select Date" onChange={onSelect} {...props}/>);
});
DateInput.defaultProps = {
defaultValue: null,
value: undefined,
onSelect: () => { },
className: "",
};
export default DateInput;

View File

@@ -1,45 +0,0 @@
import { isArray } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import DatePicker from "antd/lib/date-picker";
import { clientConfig } from "@/services/auth";
import { Moment } from "@/components/proptypes";
const { RangePicker } = DatePicker;
const DateRangeInput = React.forwardRef(({ defaultValue, value, onSelect, className, ...props }, ref) => {
const format = clientConfig.dateFormat || "YYYY-MM-DD";
const additionalAttributes = {};
if (isArray(defaultValue) && defaultValue[0].isValid() && defaultValue[1].isValid()) {
additionalAttributes.defaultValue = defaultValue;
}
if (value === null || (isArray(value) && value[0].isValid() && value[1].isValid())) {
additionalAttributes.value = value;
}
return (
<RangePicker
ref={ref}
className={className}
{...additionalAttributes}
format={format}
onChange={onSelect}
{...props}
/>
);
});
DateRangeInput.propTypes = {
defaultValue: PropTypes.arrayOf(Moment),
value: PropTypes.arrayOf(Moment),
onSelect: PropTypes.func,
className: PropTypes.string,
};
DateRangeInput.defaultProps = {
defaultValue: null,
value: undefined,
onSelect: () => {},
className: "",
};
export default DateRangeInput;

View File

@@ -0,0 +1,34 @@
import { isArray } from "lodash";
import React from "react";
import DatePicker from "antd/lib/date-picker";
import { clientConfig } from "@/services/auth";
// @ts-expect-error ts-migrate(6133) FIXME: 'Moment' is declared but its value is never read.
import { Moment } from "@/components/proptypes";
const { RangePicker } = DatePicker;
type Props = {
// @ts-expect-error ts-migrate(2749) FIXME: 'Moment' refers to a value, but is being used as a... Remove this comment to see the full error message
defaultValue?: Moment[];
// @ts-expect-error ts-migrate(2749) FIXME: 'Moment' refers to a value, but is being used as a... Remove this comment to see the full error message
value?: Moment[];
onSelect?: (...args: any[]) => any;
className?: string;
};
const DateRangeInput = React.forwardRef<any, Props>(({ defaultValue, value, onSelect, className, ...props }, ref) => {
const format = (clientConfig as any).dateFormat || "YYYY-MM-DD";
const additionalAttributes = {};
if (isArray(defaultValue) && defaultValue[0].isValid() && defaultValue[1].isValid()) {
(additionalAttributes as any).defaultValue = defaultValue;
}
if (value === null || (isArray(value) && value[0].isValid() && value[1].isValid())) {
(additionalAttributes as any).value = value;
}
return (<RangePicker ref={ref} className={className} {...additionalAttributes} format={format} onChange={onSelect} {...props}/>);
});
DateRangeInput.defaultProps = {
// @ts-expect-error ts-migrate(2322) FIXME: Type 'null' is not assignable to type 'any[] | und... Remove this comment to see the full error message
defaultValue: null,
value: undefined,
onSelect: () => { },
className: "",
};
export default DateRangeInput;

View File

@@ -1,46 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import DatePicker from "antd/lib/date-picker";
import { clientConfig } from "@/services/auth";
import { Moment } from "@/components/proptypes";
const DateTimeInput = React.forwardRef(({ defaultValue, value, withSeconds, onSelect, className, ...props }, ref) => {
const format = (clientConfig.dateFormat || "YYYY-MM-DD") + (withSeconds ? " HH:mm:ss" : " HH:mm");
const additionalAttributes = {};
if (defaultValue && defaultValue.isValid()) {
additionalAttributes.defaultValue = defaultValue;
}
if (value === null || (value && value.isValid())) {
additionalAttributes.value = value;
}
return (
<DatePicker
ref={ref}
className={className}
showTime
{...additionalAttributes}
format={format}
placeholder="Select Date and Time"
onChange={onSelect}
{...props}
/>
);
});
DateTimeInput.propTypes = {
defaultValue: Moment,
value: Moment,
withSeconds: PropTypes.bool,
onSelect: PropTypes.func,
className: PropTypes.string,
};
DateTimeInput.defaultProps = {
defaultValue: null,
value: undefined,
withSeconds: false,
onSelect: () => {},
className: "",
};
export default DateTimeInput;

View File

@@ -0,0 +1,33 @@
import React from "react";
import DatePicker from "antd/lib/date-picker";
import { clientConfig } from "@/services/auth";
// @ts-expect-error ts-migrate(6133) FIXME: 'Moment' is declared but its value is never read.
import { Moment } from "@/components/proptypes";
type Props = {
// @ts-expect-error ts-migrate(2749) FIXME: 'Moment' refers to a value, but is being used as a... Remove this comment to see the full error message
defaultValue?: Moment;
// @ts-expect-error ts-migrate(2749) FIXME: 'Moment' refers to a value, but is being used as a... Remove this comment to see the full error message
value?: Moment;
withSeconds?: boolean;
onSelect?: (...args: any[]) => any;
className?: string;
};
const DateTimeInput = React.forwardRef<any, Props>(({ defaultValue, value, withSeconds, onSelect, className, ...props }, ref) => {
const format = ((clientConfig as any).dateFormat || "YYYY-MM-DD") + (withSeconds ? " HH:mm:ss" : " HH:mm");
const additionalAttributes = {};
if (defaultValue && defaultValue.isValid()) {
(additionalAttributes as any).defaultValue = defaultValue;
}
if (value === null || (value && value.isValid())) {
(additionalAttributes as any).value = value;
}
return (<DatePicker ref={ref} className={className} showTime {...additionalAttributes} format={format} placeholder="Select Date and Time" onChange={onSelect} {...props}/>);
});
DateTimeInput.defaultProps = {
defaultValue: null,
value: undefined,
withSeconds: false,
onSelect: () => { },
className: "",
};
export default DateTimeInput;

View File

@@ -1,50 +0,0 @@
import { isArray } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import DatePicker from "antd/lib/date-picker";
import { clientConfig } from "@/services/auth";
import { Moment } from "@/components/proptypes";
const { RangePicker } = DatePicker;
const DateTimeRangeInput = React.forwardRef(
({ defaultValue, value, withSeconds, onSelect, className, ...props }, ref) => {
const format = (clientConfig.dateFormat || "YYYY-MM-DD") + (withSeconds ? " HH:mm:ss" : " HH:mm");
const additionalAttributes = {};
if (isArray(defaultValue) && defaultValue[0].isValid() && defaultValue[1].isValid()) {
additionalAttributes.defaultValue = defaultValue;
}
if (value === null || (isArray(value) && value[0].isValid() && value[1].isValid())) {
additionalAttributes.value = value;
}
return (
<RangePicker
ref={ref}
className={className}
showTime
{...additionalAttributes}
format={format}
onChange={onSelect}
{...props}
/>
);
}
);
DateTimeRangeInput.propTypes = {
defaultValue: PropTypes.arrayOf(Moment),
value: PropTypes.arrayOf(Moment),
withSeconds: PropTypes.bool,
onSelect: PropTypes.func,
className: PropTypes.string,
};
DateTimeRangeInput.defaultProps = {
defaultValue: null,
value: undefined,
withSeconds: false,
onSelect: () => {},
className: "",
};
export default DateTimeRangeInput;

View File

@@ -0,0 +1,36 @@
import { isArray } from "lodash";
import React from "react";
import DatePicker from "antd/lib/date-picker";
import { clientConfig } from "@/services/auth";
// @ts-expect-error ts-migrate(6133) FIXME: 'Moment' is declared but its value is never read.
import { Moment } from "@/components/proptypes";
const { RangePicker } = DatePicker;
type Props = {
// @ts-expect-error ts-migrate(2749) FIXME: 'Moment' refers to a value, but is being used as a... Remove this comment to see the full error message
defaultValue?: Moment[];
// @ts-expect-error ts-migrate(2749) FIXME: 'Moment' refers to a value, but is being used as a... Remove this comment to see the full error message
value?: Moment[];
withSeconds?: boolean;
onSelect?: (...args: any[]) => any;
className?: string;
};
const DateTimeRangeInput = React.forwardRef<any, Props>(({ defaultValue, value, withSeconds, onSelect, className, ...props }, ref) => {
const format = ((clientConfig as any).dateFormat || "YYYY-MM-DD") + (withSeconds ? " HH:mm:ss" : " HH:mm");
const additionalAttributes = {};
if (isArray(defaultValue) && defaultValue[0].isValid() && defaultValue[1].isValid()) {
(additionalAttributes as any).defaultValue = defaultValue;
}
if (value === null || (isArray(value) && value[0].isValid() && value[1].isValid())) {
(additionalAttributes as any).value = value;
}
return (<RangePicker ref={ref} className={className} showTime {...additionalAttributes} format={format} onChange={onSelect} {...props}/>);
});
DateTimeRangeInput.defaultProps = {
// @ts-expect-error ts-migrate(2322) FIXME: Type 'null' is not assignable to type 'any[] | und... Remove this comment to see the full error message
defaultValue: null,
value: undefined,
withSeconds: false,
onSelect: () => { },
className: "",
};
export default DateTimeRangeInput;

View File

@@ -22,8 +22,8 @@ export function wrap<ROk = void, P = {}, RCancel = void>(
props?: P
) => {
update: (props: P) => void;
onClose: (handler: (result: ROk) => Promise<void>) => void;
onDismiss: (handler: (result: RCancel) => Promise<void>) => 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,227 +0,0 @@
import { isFunction } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import ReactDOM from "react-dom";
/**
Wrapper for dialogs based on Ant's <Modal> component.
Using wrapped dialogs
=====================
Wrapped component is an object with two fields:
{
showModal: (dialogProps) => object({
close: (result) => void,
dismiss: (reason) => void,
onClose: (handler) => this,
onDismiss: (handler) => this,
}),
Component: React.Component, // wrapped dialog component
}
To open dialog, use `showModal` method; optionally you can pass additional properties that
will be expanded on wrapped component:
const dialog = SomeWrappedDialog.showModal()
const dialog = SomeWrappedDialog.showModal({ greeting: 'Hello' })
To get result of modal, use `onClose`/`onDismiss` setters:
dialog
.onClose(result => { ... }) // pressed OK button or used `close` method
.onDismiss(result => { ... }) // pressed Cancel button or used `dismiss` method
If `onClose`/`onDismiss` returns a promise - dialog wrapper will stop handling further close/dismiss
requests and will show loader on a corresponding button until that promise is fulfilled (either resolved or
rejected). If that promise will be rejected - dialog close/dismiss will be abandoned. Use promise returned
from `close`/`dismiss` methods to handle errors (if needed).
Also, dialog has `close` and `dismiss` methods that allows to close dialog by caller. Passed arguments
will be passed to a corresponding handler. Both methods will return the promise returned from `onClose` and
`onDismiss` callbacks. `update` method allows to pass new properties to dialog.
Creating a dialog
================
1. Add imports:
import { wrap as wrapDialog, DialogPropType } from 'path/to/DialogWrapper';
2. define a `dialog` property on your component:
propTypes = {
dialog: DialogPropType.isRequired,
};
`dialog` property is an object:
{
props: object, // properties for <Modal> component;
close: (result) => void, // method to confirm dialog; `result` will be returned to caller
dismiss: (reason) => void, // method to reject dialog; `reason` will be returned to caller
}
3. expand additional properties on <Modal> component:
render() {
const { dialog } = this.props;
return (
<Modal {...dialog.props}>
);
}
4. wrap your component and export it:
export default wrapDialog(YourComponent).
Your component is ready to use. Wrapper will manage <Modal>'s visibility and events.
If you want to override behavior of `onOk`/`onCancel` - don't forget to close dialog:
customOkHandler() {
this.saveData().then(() => {
this.props.dialog.close({ success: true }); // or dismiss();
});
}
render() {
const { dialog } = this.props;
return (
<Modal {...dialog.props} onOk={() => this.customOkHandler()}>
);
}
*/
export const DialogPropType = PropTypes.shape({
props: PropTypes.shape({
visible: PropTypes.bool,
onOk: PropTypes.func,
onCancel: PropTypes.func,
afterClose: PropTypes.func,
}).isRequired,
close: PropTypes.func.isRequired,
dismiss: PropTypes.func.isRequired,
});
function openDialog(DialogComponent, props) {
const dialog = {
props: {
visible: true,
okButtonProps: {},
cancelButtonProps: {},
onOk: () => {},
onCancel: () => {},
afterClose: () => {},
},
close: () => {},
dismiss: () => {},
};
let pendingCloseTask = null;
const handlers = {
onClose: () => {},
onDismiss: () => {},
};
const container = document.createElement("div");
document.body.appendChild(container);
function render() {
ReactDOM.render(<DialogComponent {...props} dialog={dialog} />, container);
}
function destroyDialog() {
// Allow calling chain to roll up, and then destroy component
setTimeout(() => {
ReactDOM.unmountComponentAtNode(container);
document.body.removeChild(container);
}, 10);
}
function processDialogClose(result, setAdditionalDialogProps) {
dialog.props.okButtonProps = { disabled: true };
dialog.props.cancelButtonProps = { disabled: true };
setAdditionalDialogProps();
render();
return Promise.resolve(result)
.then(() => {
dialog.props.visible = false;
})
.finally(() => {
dialog.props.okButtonProps = {};
dialog.props.cancelButtonProps = {};
render();
});
}
function closeDialog(result) {
if (!pendingCloseTask) {
pendingCloseTask = processDialogClose(handlers.onClose(result), () => {
dialog.props.okButtonProps.loading = true;
}).finally(() => {
pendingCloseTask = null;
});
}
return pendingCloseTask;
}
function dismissDialog(result) {
if (!pendingCloseTask) {
pendingCloseTask = processDialogClose(handlers.onDismiss(result), () => {
dialog.props.cancelButtonProps.loading = true;
}).finally(() => {
pendingCloseTask = null;
});
}
return pendingCloseTask;
}
dialog.props.onOk = closeDialog;
dialog.props.onCancel = dismissDialog;
dialog.props.afterClose = destroyDialog;
dialog.close = closeDialog;
dialog.dismiss = dismissDialog;
const result = {
close: closeDialog,
dismiss: dismissDialog,
update: newProps => {
props = { ...props, ...newProps };
render();
},
onClose: handler => {
if (isFunction(handler)) {
handlers.onClose = handler;
}
return result;
},
onDismiss: handler => {
if (isFunction(handler)) {
handlers.onDismiss = handler;
}
return result;
},
};
render(); // show it only when all structures initialized to avoid unnecessary re-rendering
return result;
}
export function wrap(DialogComponent) {
return {
Component: DialogComponent,
showModal: props => openDialog(DialogComponent, props),
};
}
export default {
DialogPropType,
wrap,
};

View File

@@ -0,0 +1,223 @@
import { isFunction } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import ReactDOM from "react-dom";
type DialogPropType = {
props: {
visible?: boolean;
onOk?: (...args: any[]) => any;
onCancel?: (...args: any[]) => any;
afterClose?: (...args: any[]) => any;
};
close: (...args: any[]) => any;
dismiss: (...args: any[]) => any;
};
/**
Wrapper for dialogs based on Ant's <Modal> component.
Using wrapped dialogs
=====================
Wrapped component is an object with two fields:
{
showModal: (dialogProps) => object({
close: (result) => void,
dismiss: (reason) => void,
onClose: (handler) => this,
onDismiss: (handler) => this,
}),
Component: React.Component, // wrapped dialog component
}
To open dialog, use `showModal` method; optionally you can pass additional properties that
will be expanded on wrapped component:
const dialog = SomeWrappedDialog.showModal()
const dialog = SomeWrappedDialog.showModal({ greeting: 'Hello' })
To get result of modal, use `onClose`/`onDismiss` setters:
dialog
.onClose(result => { ... }) // pressed OK button or used `close` method
.onDismiss(result => { ... }) // pressed Cancel button or used `dismiss` method
If `onClose`/`onDismiss` returns a promise - dialog wrapper will stop handling further close/dismiss
requests and will show loader on a corresponding button until that promise is fulfilled (either resolved or
rejected). If that promise will be rejected - dialog close/dismiss will be abandoned. Use promise returned
from `close`/`dismiss` methods to handle errors (if needed).
Also, dialog has `close` and `dismiss` methods that allows to close dialog by caller. Passed arguments
will be passed to a corresponding handler. Both methods will return the promise returned from `onClose` and
`onDismiss` callbacks. `update` method allows to pass new properties to dialog.
Creating a dialog
================
1. Add imports:
import { wrap as wrapDialog, DialogPropType } from 'path/to/DialogWrapper';
2. define a `dialog` property on your component:
propTypes = {
dialog: DialogPropType.isRequired,
};
`dialog` property is an object:
{
props: object, // properties for <Modal> component;
close: (result) => void, // method to confirm dialog; `result` will be returned to caller
dismiss: (reason) => void, // method to reject dialog; `reason` will be returned to caller
}
3. expand additional properties on <Modal> component:
render() {
const { dialog } = this.props;
return (
<Modal {...dialog.props}>
);
}
4. wrap your component and it:
export default wrapDialog(YourComponent).
Your component is ready to use. Wrapper will manage <Modal>'s visibility and events.
If you want to override behavior of `onOk`/`onCancel` - don't forget to close dialog:
customOkHandler() {
this.saveData().then(() => {
this.props.dialog.close({ success: true }); // or dismiss();
});
}
render() {
const { dialog } = this.props;
return (
<Modal {...dialog.props} onOk={() => this.customOkHandler()}>
);
}
*/
// @ts-expect-error ts-migrate(2322) FIXME: Type 'Requireable<InferProps<{ props: Validator<In... Remove this comment to see the full error message
export const DialogPropType: PropTypes.Requireable<DialogPropType> = PropTypes.shape({
props: PropTypes.shape({
visible: PropTypes.bool,
onOk: PropTypes.func,
onCancel: PropTypes.func,
afterClose: PropTypes.func,
}).isRequired,
close: PropTypes.func.isRequired,
dismiss: PropTypes.func.isRequired,
});
function openDialog(DialogComponent: any, props: any) {
const dialog = {
props: {
visible: true,
okButtonProps: {},
cancelButtonProps: {},
onOk: () => { },
onCancel: () => { },
afterClose: () => { },
},
close: () => { },
dismiss: () => { },
};
let pendingCloseTask: any = null;
const handlers = {
onClose: () => { },
onDismiss: () => { },
};
const container = document.createElement("div");
document.body.appendChild(container);
function render() {
ReactDOM.render(<DialogComponent {...props} dialog={dialog}/>, container);
}
function destroyDialog() {
// Allow calling chain to roll up, and then destroy component
setTimeout(() => {
ReactDOM.unmountComponentAtNode(container);
document.body.removeChild(container);
}, 10);
}
function processDialogClose(result: any, setAdditionalDialogProps: any) {
dialog.props.okButtonProps = { disabled: true };
dialog.props.cancelButtonProps = { disabled: true };
setAdditionalDialogProps();
render();
return Promise.resolve(result)
.then(() => {
dialog.props.visible = false;
})
.finally(() => {
dialog.props.okButtonProps = {};
dialog.props.cancelButtonProps = {};
render();
});
}
function closeDialog(result: any) {
if (!pendingCloseTask) {
// @ts-expect-error ts-migrate(2554) FIXME: Expected 0 arguments, but got 1.
pendingCloseTask = processDialogClose(handlers.onClose(result), () => {
(dialog.props.okButtonProps as any).loading = true;
}).finally(() => {
pendingCloseTask = null;
});
}
return pendingCloseTask;
}
function dismissDialog(result: any) {
if (!pendingCloseTask) {
// @ts-expect-error ts-migrate(2554) FIXME: Expected 0 arguments, but got 1.
pendingCloseTask = processDialogClose(handlers.onDismiss(result), () => {
(dialog.props.cancelButtonProps as any).loading = true;
}).finally(() => {
pendingCloseTask = null;
});
}
return pendingCloseTask;
}
// @ts-expect-error ts-migrate(2322) FIXME: Type '(result: any) => any' is not assignable to t... Remove this comment to see the full error message
dialog.props.onOk = closeDialog;
// @ts-expect-error ts-migrate(2322) FIXME: Type '(result: any) => any' is not assignable to t... Remove this comment to see the full error message
dialog.props.onCancel = dismissDialog;
dialog.props.afterClose = destroyDialog;
// @ts-expect-error ts-migrate(2322) FIXME: Type '(result: any) => any' is not assignable to t... Remove this comment to see the full error message
dialog.close = closeDialog;
// @ts-expect-error ts-migrate(2322) FIXME: Type '(result: any) => any' is not assignable to t... Remove this comment to see the full error message
dialog.dismiss = dismissDialog;
const result = {
close: closeDialog,
dismiss: dismissDialog,
update: (newProps: any) => {
props = { ...props, ...newProps };
render();
},
onClose: (handler: any) => {
if (isFunction(handler)) {
handlers.onClose = handler;
}
return result;
},
onDismiss: (handler: any) => {
if (isFunction(handler)) {
handlers.onDismiss = handler;
}
return result;
},
};
render(); // show it only when all structures initialized to avoid unnecessary re-rendering
return result;
}
export function wrap(DialogComponent: any) {
return {
Component: DialogComponent,
showModal: (props: any) => openDialog(DialogComponent, props),
};
}

View File

@@ -1,31 +1,35 @@
import { isFunction, isString } from "lodash";
import { isFunction, isString, isUndefined } from "lodash";
import React from "react";
import PropTypes from "prop-types";
const componentsRegistry = new Map();
const activeInstances = new Set();
export function registerComponent(name, component) {
export function registerComponent(name: any, component: any) {
if (isString(name) && name !== "") {
componentsRegistry.set(name, isFunction(component) ? component : null);
// Refresh active DynamicComponent instances which use this component
activeInstances.forEach(dynamicComponent => {
// @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'.
if (dynamicComponent.props.name === name) {
// @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'.
dynamicComponent.forceUpdate();
}
});
}
}
export function unregisterComponent(name) {
export function unregisterComponent(name: any) {
registerComponent(name, null);
}
export default class DynamicComponent extends React.Component {
static propTypes = {
name: PropTypes.string.isRequired,
children: PropTypes.node,
};
type OwnProps = {
name: string;
fallback?: React.ReactNode;
};
type Props = OwnProps & typeof DynamicComponent.defaultProps;
export default class DynamicComponent extends React.Component<Props> {
static defaultProps = {
children: null,
@@ -40,10 +44,11 @@ export default class DynamicComponent extends React.Component {
}
render() {
const { name, children, ...props } = this.props;
const { name, children, fallback, ...props } = this.props;
const RealComponent = componentsRegistry.get(name);
if (!RealComponent) {
return children;
// return fallback if any, otherwise return children
return isUndefined(fallback) ? children : fallback;
}
return <RealComponent {...props}>{children}</RealComponent>;
}

View File

@@ -1,104 +0,0 @@
import { trim } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import Input from "antd/lib/input";
export default class EditInPlace extends React.Component {
static propTypes = {
ignoreBlanks: PropTypes.bool,
isEditable: PropTypes.bool,
placeholder: PropTypes.string,
value: PropTypes.string,
onDone: PropTypes.func.isRequired,
onStopEditing: PropTypes.func,
multiline: PropTypes.bool,
editorProps: PropTypes.object,
defaultEditing: PropTypes.bool,
};
static defaultProps = {
ignoreBlanks: false,
isEditable: true,
placeholder: "",
value: "",
onStopEditing: () => {},
multiline: false,
editorProps: {},
defaultEditing: false,
};
constructor(props) {
super(props);
this.state = {
editing: props.defaultEditing,
};
}
componentDidUpdate(_, prevState) {
if (!this.state.editing && prevState.editing) {
this.props.onStopEditing();
}
}
startEditing = () => {
if (this.props.isEditable) {
this.setState({ editing: true });
}
};
stopEditing = currentValue => {
const newValue = trim(currentValue);
const ignorableBlank = this.props.ignoreBlanks && newValue === "";
if (!ignorableBlank && newValue !== this.props.value) {
this.props.onDone(newValue);
}
this.setState({ editing: false });
};
handleKeyDown = event => {
if (event.keyCode === 13 && !event.shiftKey) {
event.preventDefault();
this.stopEditing(event.target.value);
} else if (event.keyCode === 27) {
this.setState({ editing: false });
}
};
renderNormal = () =>
this.props.value ? (
<span
role="presentation"
onFocus={this.startEditing}
onClick={this.startEditing}
className={this.props.isEditable ? "editable" : ""}>
{this.props.value}
</span>
) : (
<a className="clickable" onClick={this.startEditing}>
{this.props.placeholder}
</a>
);
renderEdit = () => {
const { multiline, value, editorProps } = this.props;
const InputComponent = multiline ? Input.TextArea : Input;
return (
<InputComponent
defaultValue={value}
onBlur={e => this.stopEditing(e.target.value)}
onKeyDown={this.handleKeyDown}
autoFocus
{...editorProps}
/>
);
};
render() {
return (
<span className={cx("edit-in-place", { active: this.state.editing }, this.props.className)}>
{this.state.editing ? this.renderEdit() : this.renderNormal()}
</span>
);
}
}

View File

@@ -0,0 +1,77 @@
import { trim } from "lodash";
import React from "react";
import cx from "classnames";
import Input from "antd/lib/input";
type OwnProps = {
ignoreBlanks?: boolean;
isEditable?: boolean;
placeholder?: string;
value?: string;
onDone: (...args: any[]) => any;
onStopEditing?: (...args: any[]) => any;
multiline?: boolean;
editorProps?: any;
defaultEditing?: boolean;
};
type State = any;
type Props = OwnProps & typeof EditInPlace.defaultProps;
export default class EditInPlace extends React.Component<Props, State> {
static defaultProps = {
ignoreBlanks: false,
isEditable: true,
placeholder: "",
value: "",
onStopEditing: () => { },
multiline: false,
editorProps: {},
defaultEditing: false,
};
constructor(props: Props) {
super(props);
this.state = {
editing: props.defaultEditing,
};
}
componentDidUpdate(_: Props, prevState: State) {
if (!this.state.editing && prevState.editing) {
this.props.onStopEditing();
}
}
startEditing = () => {
if (this.props.isEditable) {
this.setState({ editing: true });
}
};
stopEditing = (currentValue: any) => {
const newValue = trim(currentValue);
const ignorableBlank = this.props.ignoreBlanks && newValue === "";
if (!ignorableBlank && newValue !== this.props.value) {
this.props.onDone(newValue);
}
this.setState({ editing: false });
};
handleKeyDown = (event: any) => {
if (event.keyCode === 13 && !event.shiftKey) {
event.preventDefault();
this.stopEditing(event.target.value);
}
else if (event.keyCode === 27) {
this.setState({ editing: false });
}
};
renderNormal = () => this.props.value ? (<span role="presentation" onFocus={this.startEditing} onClick={this.startEditing} className={this.props.isEditable ? "editable" : ""}>
{this.props.value}
</span>) : (<a className="clickable" onClick={this.startEditing}>
{this.props.placeholder}
</a>);
renderEdit = () => {
const { multiline, value, editorProps } = this.props;
const InputComponent = multiline ? Input.TextArea : Input;
return (<InputComponent defaultValue={value} onBlur={(e: any) => this.stopEditing(e.target.value)} onKeyDown={this.handleKeyDown} autoFocus {...editorProps}/>);
};
render() {
return (<span className={cx("edit-in-place", { active: this.state.editing }, (this.props as any).className)}>
{this.state.editing ? this.renderEdit() : this.renderNormal()}
</span>);
}
}

View File

@@ -1,264 +0,0 @@
import { includes, words, capitalize, clone, isNull } from "lodash";
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
import Checkbox from "antd/lib/checkbox";
import Modal from "antd/lib/modal";
import Form from "antd/lib/form";
import Button from "antd/lib/button";
import Select from "antd/lib/select";
import Input from "antd/lib/input";
import Divider from "antd/lib/divider";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import QuerySelector from "@/components/QuerySelector";
import { Query } from "@/services/query";
const { Option } = Select;
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
function getDefaultTitle(text) {
return capitalize(words(text).join(" ")); // humanize
}
function isTypeDateRange(type) {
return /-range/.test(type);
}
function joinExampleList(multiValuesOptions) {
const { prefix, suffix } = multiValuesOptions;
return ["value1", "value2", "value3"].map(value => `${prefix}${value}${suffix}`).join(",");
}
function NameInput({ name, type, onChange, existingNames, setValidation }) {
let helpText = "";
let validateStatus = "";
if (!name) {
helpText = "Choose a keyword for this parameter";
setValidation(false);
} else if (includes(existingNames, name)) {
helpText = "Parameter with this name already exists";
setValidation(false);
validateStatus = "error";
} else {
if (isTypeDateRange(type)) {
helpText = (
<React.Fragment>
Appears in query as{" "}
<code style={{ display: "inline-block", color: "inherit" }}>{`{{${name}.start}} {{${name}.end}}`}</code>
</React.Fragment>
);
}
setValidation(true);
}
return (
<Form.Item required label="Keyword" help={helpText} validateStatus={validateStatus} {...formItemProps}>
<Input onChange={e => onChange(e.target.value)} autoFocus />
</Form.Item>
);
}
NameInput.propTypes = {
name: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
existingNames: PropTypes.arrayOf(PropTypes.string).isRequired,
setValidation: PropTypes.func.isRequired,
type: PropTypes.string.isRequired,
};
function EditParameterSettingsDialog(props) {
const [param, setParam] = useState(clone(props.parameter));
const [isNameValid, setIsNameValid] = useState(true);
const [initialQuery, setInitialQuery] = useState();
const isNew = !props.parameter.name;
// fetch query by id
useEffect(() => {
const queryId = props.parameter.queryId;
if (queryId) {
Query.get({ id: queryId }).then(setInitialQuery);
}
}, [props.parameter.queryId]);
function isFulfilled() {
// name
if (!isNameValid) {
return false;
}
// title
if (param.title === "") {
return false;
}
// query
if (param.type === "query" && !param.queryId) {
return false;
}
return true;
}
function onConfirm() {
// update title to default
if (!param.title) {
// forced to do this cause param won't update in time for save
param.title = getDefaultTitle(param.name);
setParam(param);
}
props.dialog.close(param);
}
return (
<Modal
{...props.dialog.props}
title={isNew ? "Add Parameter" : param.name}
width={600}
footer={[
<Button key="cancel" onClick={props.dialog.dismiss}>
Cancel
</Button>,
<Button
key="submit"
htmlType="submit"
disabled={!isFulfilled()}
type="primary"
form="paramForm"
data-test="SaveParameterSettings">
{isNew ? "Add Parameter" : "OK"}
</Button>,
]}>
<Form layout="horizontal" onFinish={onConfirm} id="paramForm">
{isNew && (
<NameInput
name={param.name}
onChange={name => setParam({ ...param, name })}
setValidation={setIsNameValid}
existingNames={props.existingParams}
type={param.type}
/>
)}
<Form.Item label="Title" {...formItemProps}>
<Input
value={isNull(param.title) ? getDefaultTitle(param.name) : param.title}
onChange={e => setParam({ ...param, title: e.target.value })}
data-test="ParameterTitleInput"
/>
</Form.Item>
<Form.Item label="Type" {...formItemProps}>
<Select value={param.type} onChange={type => setParam({ ...param, type })} data-test="ParameterTypeSelect">
<Option value="text" data-test="TextParameterTypeOption">
Text
</Option>
<Option value="number" data-test="NumberParameterTypeOption">
Number
</Option>
<Option value="enum">Dropdown List</Option>
<Option value="query">Query Based Dropdown List</Option>
<Option disabled key="dv1">
<Divider className="select-option-divider" />
</Option>
<Option value="date" data-test="DateParameterTypeOption">
Date
</Option>
<Option value="datetime-local" data-test="DateTimeParameterTypeOption">
Date and Time
</Option>
<Option value="datetime-with-seconds">Date and Time (with seconds)</Option>
<Option disabled key="dv2">
<Divider className="select-option-divider" />
</Option>
<Option value="date-range" data-test="DateRangeParameterTypeOption">
Date Range
</Option>
<Option value="datetime-range">Date and Time Range</Option>
<Option value="datetime-range-with-seconds">Date and Time Range (with seconds)</Option>
</Select>
</Form.Item>
{param.type === "enum" && (
<Form.Item label="Values" help="Dropdown list values (newline delimited)" {...formItemProps}>
<Input.TextArea
rows={3}
value={param.enumOptions}
onChange={e => setParam({ ...param, enumOptions: e.target.value })}
/>
</Form.Item>
)}
{param.type === "query" && (
<Form.Item label="Query" help="Select query to load dropdown values from" {...formItemProps}>
<QuerySelector
selectedQuery={initialQuery}
onChange={q => setParam({ ...param, queryId: q && q.id })}
type="select"
/>
</Form.Item>
)}
{(param.type === "enum" || param.type === "query") && (
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
<Checkbox
defaultChecked={!!param.multiValuesOptions}
onChange={e =>
setParam({
...param,
multiValuesOptions: e.target.checked
? {
prefix: "",
suffix: "",
separator: ",",
}
: null,
})
}
data-test="AllowMultipleValuesCheckbox">
Allow multiple values
</Checkbox>
</Form.Item>
)}
{(param.type === "enum" || param.type === "query") && param.multiValuesOptions && (
<Form.Item
label="Quotation"
help={
<React.Fragment>
Placed in query as: <code>{joinExampleList(param.multiValuesOptions)}</code>
</React.Fragment>
}
{...formItemProps}>
<Select
value={param.multiValuesOptions.prefix}
onChange={quoteOption =>
setParam({
...param,
multiValuesOptions: {
...param.multiValuesOptions,
prefix: quoteOption,
suffix: quoteOption,
},
})
}
data-test="QuotationSelect">
<Option value="">None (default)</Option>
<Option value="'">Single Quotation Mark</Option>
<Option value={'"'} data-test="DoubleQuotationMarkOption">
Double Quotation Mark
</Option>
</Select>
</Form.Item>
)}
</Form>
</Modal>
);
}
EditParameterSettingsDialog.propTypes = {
parameter: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
dialog: DialogPropType.isRequired,
existingParams: PropTypes.arrayOf(PropTypes.string),
};
EditParameterSettingsDialog.defaultProps = {
existingParams: [],
};
export default wrapDialog(EditParameterSettingsDialog);

View File

@@ -0,0 +1,193 @@
import { includes, words, capitalize, clone, isNull } from "lodash";
import React, { useState, useEffect } from "react";
import Checkbox from "antd/lib/checkbox";
import Modal from "antd/lib/modal";
import Form from "antd/lib/form";
import Button from "antd/lib/button";
import Select from "antd/lib/select";
import Input from "antd/lib/input";
import Divider from "antd/lib/divider";
// @ts-expect-error ts-migrate(6133) FIXME: 'DialogPropType' is declared but its value is neve... Remove this comment to see the full error message
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import QuerySelector from "@/components/QuerySelector";
import { Query } from "@/services/query";
const { Option } = Select;
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
function getDefaultTitle(text: any) {
return capitalize(words(text).join(" ")); // humanize
}
function isTypeDateRange(type: any) {
return /-range/.test(type);
}
function joinExampleList(multiValuesOptions: any) {
const { prefix, suffix } = multiValuesOptions;
return ["value1", "value2", "value3"].map(value => `${prefix}${value}${suffix}`).join(",");
}
type NameInputProps = {
name: string;
onChange: (...args: any[]) => any;
existingNames: string[];
setValidation: (...args: any[]) => any;
type: string;
};
function NameInput({ name, type, onChange, existingNames, setValidation }: NameInputProps) {
let helpText = "";
let validateStatus = "";
if (!name) {
helpText = "Choose a keyword for this parameter";
setValidation(false);
}
else if (includes(existingNames, name)) {
helpText = "Parameter with this name already exists";
setValidation(false);
validateStatus = "error";
}
else {
if (isTypeDateRange(type)) {
// @ts-expect-error ts-migrate(2322) FIXME: Type 'Element' is not assignable to type 'string'.
helpText = (<React.Fragment>
Appears in query as{" "}
<code style={{ display: "inline-block", color: "inherit" }}>{`{{${name}.start}} {{${name}.end}}`}</code>
</React.Fragment>);
}
setValidation(true);
}
// @ts-expect-error ts-migrate(2322) FIXME: Type 'string' is not assignable to type '"" | "err... Remove this comment to see the full error message
return (<Form.Item required label="Keyword" help={helpText} validateStatus={validateStatus} {...formItemProps}>
<Input onChange={e => onChange(e.target.value)} autoFocus/>
</Form.Item>);
}
type OwnEditParameterSettingsDialogProps = {
parameter: any;
// @ts-expect-error ts-migrate(2749) FIXME: 'DialogPropType' refers to a value, but is being u... Remove this comment to see the full error message
dialog: DialogPropType;
existingParams?: string[];
};
type EditParameterSettingsDialogProps = OwnEditParameterSettingsDialogProps & typeof EditParameterSettingsDialog.defaultProps;
function EditParameterSettingsDialog(props: EditParameterSettingsDialogProps) {
const [param, setParam] = useState(clone(props.parameter));
const [isNameValid, setIsNameValid] = useState(true);
const [initialQuery, setInitialQuery] = useState();
const isNew = !props.parameter.name;
// fetch query by id
useEffect(() => {
const queryId = props.parameter.queryId;
if (queryId) {
(Query as any).get({ id: queryId }).then(setInitialQuery);
}
}, [props.parameter.queryId]);
function isFulfilled() {
// name
if (!isNameValid) {
return false;
}
// title
if (param.title === "") {
return false;
}
// query
if (param.type === "query" && !param.queryId) {
return false;
}
return true;
}
function onConfirm() {
// update title to default
if (!param.title) {
// forced to do this cause param won't update in time for save
param.title = getDefaultTitle(param.name);
setParam(param);
}
props.dialog.close(param);
}
return (<Modal {...props.dialog.props} title={isNew ? "Add Parameter" : param.name} width={600} footer={[
<Button key="cancel" onClick={props.dialog.dismiss}>
Cancel
</Button>,
<Button key="submit" htmlType="submit" disabled={!isFulfilled()} type="primary" form="paramForm" data-test="SaveParameterSettings">
{isNew ? "Add Parameter" : "OK"}
</Button>,
]}>
<Form layout="horizontal" onFinish={onConfirm} id="paramForm">
{isNew && (<NameInput name={param.name} onChange={name => setParam({ ...param, name })} setValidation={setIsNameValid} existingNames={props.existingParams} type={param.type}/>)}
<Form.Item required label="Title" {...formItemProps}>
<Input value={isNull(param.title) ? getDefaultTitle(param.name) : param.title} onChange={e => setParam({ ...param, title: e.target.value })} data-test="ParameterTitleInput"/>
</Form.Item>
<Form.Item label="Type" {...formItemProps}>
<Select value={param.type} onChange={type => setParam({ ...param, type })} data-test="ParameterTypeSelect">
<Option value="text" data-test="TextParameterTypeOption">
Text
</Option>
<Option value="number" data-test="NumberParameterTypeOption">
Number
</Option>
<Option value="enum">Dropdown List</Option>
<Option value="query">Query Based Dropdown List</Option>
{/* @ts-expect-error ts-migrate(2741) FIXME: Property 'value' is missing in type '{ children: E... Remove this comment to see the full error message */}
<Option disabled key="dv1">
<Divider className="select-option-divider"/>
</Option>
<Option value="date" data-test="DateParameterTypeOption">
Date
</Option>
<Option value="datetime-local" data-test="DateTimeParameterTypeOption">
Date and Time
</Option>
<Option value="datetime-with-seconds">Date and Time (with seconds)</Option>
{/* @ts-expect-error ts-migrate(2741) FIXME: Property 'value' is missing in type '{ children: E... Remove this comment to see the full error message */}
<Option disabled key="dv2">
<Divider className="select-option-divider"/>
</Option>
<Option value="date-range" data-test="DateRangeParameterTypeOption">
Date Range
</Option>
<Option value="datetime-range">Date and Time Range</Option>
<Option value="datetime-range-with-seconds">Date and Time Range (with seconds)</Option>
</Select>
</Form.Item>
{param.type === "enum" && (<Form.Item label="Values" help="Dropdown list values (newline delimited)" {...formItemProps}>
<Input.TextArea rows={3} value={param.enumOptions} onChange={e => setParam({ ...param, enumOptions: e.target.value })}/>
</Form.Item>)}
{param.type === "query" && (<Form.Item label="Query" help="Select query to load dropdown values from" {...formItemProps}>
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'undefined' is not assignable to type 'never'... Remove this comment to see the full error message */}
<QuerySelector selectedQuery={initialQuery} onChange={(q: any) => setParam({ ...param, queryId: q && q.id })} type="select"/>
</Form.Item>)}
{(param.type === "enum" || param.type === "query") && (<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
<Checkbox defaultChecked={!!param.multiValuesOptions} onChange={e => setParam({
...param,
multiValuesOptions: e.target.checked
? {
prefix: "",
suffix: "",
separator: ",",
}
: null,
})} data-test="AllowMultipleValuesCheckbox">
Allow multiple values
</Checkbox>
</Form.Item>)}
{(param.type === "enum" || param.type === "query") && param.multiValuesOptions && (<Form.Item label="Quotation" help={<React.Fragment>
Placed in query as: <code>{joinExampleList(param.multiValuesOptions)}</code>
</React.Fragment>} {...formItemProps}>
<Select value={param.multiValuesOptions.prefix} onChange={quoteOption => setParam({
...param,
multiValuesOptions: {
...param.multiValuesOptions,
prefix: quoteOption,
suffix: quoteOption,
},
})} data-test="QuotationSelect">
<Option value="">None (default)</Option>
<Option value="'">Single Quotation Mark</Option>
<Option value={'"'} data-test="DoubleQuotationMarkOption">
Double Quotation Mark
</Option>
</Select>
</Form.Item>)}
</Form>
</Modal>);
}
EditParameterSettingsDialog.defaultProps = {
existingParams: [],
};
export default wrapDialog(EditParameterSettingsDialog);

View File

@@ -1,93 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import Dropdown from "antd/lib/dropdown";
import Menu from "antd/lib/menu";
import Button from "antd/lib/button";
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";
export default function QueryControlDropdown(props) {
const menu = (
<Menu>
{!props.query.isNew() && (!props.query.is_draft || !props.query.is_archived) && (
<Menu.Item>
<a target="_self" onClick={() => props.openAddToDashboardForm(props.selectedTab)}>
<PlusCircleFilledIcon /> Add to Dashboard
</a>
</Menu.Item>
)}
{!props.query.isNew() && (
<Menu.Item>
<a onClick={() => props.showEmbedDialog(props.query, props.selectedTab)} data-test="ShowEmbedDialogButton">
<ShareAltOutlinedIcon /> Embed Elsewhere
</a>
</Menu.Item>
)}
<Menu.Item>
<QueryResultsLink
fileType="csv"
disabled={props.queryExecuting || !props.queryResult.getData || !props.queryResult.getData()}
query={props.query}
queryResult={props.queryResult}
embed={props.embed}
apiKey={props.apiKey}>
<FileOutlinedIcon /> Download as CSV File
</QueryResultsLink>
</Menu.Item>
<Menu.Item>
<QueryResultsLink
fileType="tsv"
disabled={props.queryExecuting || !props.queryResult.getData || !props.queryResult.getData()}
query={props.query}
queryResult={props.queryResult}
embed={props.embed}
apiKey={props.apiKey}>
<FileOutlinedIcon /> Download as TSV File
</QueryResultsLink>
</Menu.Item>
<Menu.Item>
<QueryResultsLink
fileType="xlsx"
disabled={props.queryExecuting || !props.queryResult.getData || !props.queryResult.getData()}
query={props.query}
queryResult={props.queryResult}
embed={props.embed}
apiKey={props.apiKey}>
<FileExcelOutlinedIcon /> Download as Excel File
</QueryResultsLink>
</Menu.Item>
</Menu>
);
return (
<Dropdown trigger={["click"]} overlay={menu} overlayClassName="query-control-dropdown-overlay">
<Button data-test="QueryControlDropdownButton">
<EllipsisOutlinedIcon rotate={90} />
</Button>
</Dropdown>
);
}
QueryControlDropdown.propTypes = {
query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
queryResult: PropTypes.object, // eslint-disable-line react/forbid-prop-types
queryExecuting: PropTypes.bool.isRequired,
showEmbedDialog: PropTypes.func.isRequired,
embed: PropTypes.bool,
apiKey: PropTypes.string,
selectedTab: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
openAddToDashboardForm: PropTypes.func.isRequired,
};
QueryControlDropdown.defaultProps = {
queryResult: {},
embed: false,
apiKey: "",
selectedTab: "",
};

View File

@@ -0,0 +1,62 @@
import React from "react";
import Dropdown from "antd/lib/dropdown";
import Menu from "antd/lib/menu";
import Button from "antd/lib/button";
import { clientConfig } from "@/services/auth";
import PlusCircleFilledIcon from "@ant-design/icons/PlusCircleFilled";
import ShareAltOutlinedIcon from "@ant-design/icons/ShareAltOutlined";
import FileOutlinedIcon from "@ant-design/icons/FileOutlined";
import FileExcelOutlinedIcon from "@ant-design/icons/FileExcelOutlined";
import EllipsisOutlinedIcon from "@ant-design/icons/EllipsisOutlined";
import QueryResultsLink from "./QueryResultsLink";
type OwnProps = {
query: any;
queryResult?: any;
queryExecuting: boolean;
showEmbedDialog: (...args: any[]) => any;
embed?: boolean;
apiKey?: string;
selectedTab?: string | number;
openAddToDashboardForm: (...args: any[]) => any;
};
type Props = OwnProps & typeof QueryControlDropdown.defaultProps;
export default function QueryControlDropdown(props: Props) {
const menu = (<Menu>
{!props.query.isNew() && (!props.query.is_draft || !props.query.is_archived) && (<Menu.Item>
<a target="_self" onClick={() => props.openAddToDashboardForm(props.selectedTab)}>
<PlusCircleFilledIcon /> Add to Dashboard
</a>
</Menu.Item>)}
{!(clientConfig as any).disablePublicUrls && !props.query.isNew() && (<Menu.Item>
<a onClick={() => props.showEmbedDialog(props.query, props.selectedTab)} data-test="ShowEmbedDialogButton">
<ShareAltOutlinedIcon /> Embed Elsewhere
</a>
</Menu.Item>)}
<Menu.Item>
<QueryResultsLink fileType="csv" disabled={props.queryExecuting || !props.queryResult.getData || !props.queryResult.getData()} query={props.query} queryResult={props.queryResult} embed={props.embed} apiKey={props.apiKey}>
<FileOutlinedIcon /> Download as CSV File
</QueryResultsLink>
</Menu.Item>
<Menu.Item>
<QueryResultsLink fileType="tsv" disabled={props.queryExecuting || !props.queryResult.getData || !props.queryResult.getData()} query={props.query} queryResult={props.queryResult} embed={props.embed} apiKey={props.apiKey}>
<FileOutlinedIcon /> Download as TSV File
</QueryResultsLink>
</Menu.Item>
<Menu.Item>
<QueryResultsLink fileType="xlsx" disabled={props.queryExecuting || !props.queryResult.getData || !props.queryResult.getData()} query={props.query} queryResult={props.queryResult} embed={props.embed} apiKey={props.apiKey}>
<FileExcelOutlinedIcon /> Download as Excel File
</QueryResultsLink>
</Menu.Item>
</Menu>);
return (<Dropdown trigger={["click"]} overlay={menu} overlayClassName="query-control-dropdown-overlay">
<Button data-test="QueryControlDropdownButton">
<EllipsisOutlinedIcon rotate={90}/>
</Button>
</Dropdown>);
}
QueryControlDropdown.defaultProps = {
queryResult: {},
embed: false,
apiKey: "",
selectedTab: "",
};

View File

@@ -1,8 +1,19 @@
import React from "react";
import PropTypes from "prop-types";
import Link from "@/components/Link";
export default function QueryResultsLink(props) {
type OwnProps = {
query: any;
queryResult?: any;
fileType?: string;
disabled: boolean;
embed?: boolean;
apiKey?: string;
children: React.ReactNode[] | React.ReactNode;
};
type Props = OwnProps & typeof QueryResultsLink.defaultProps;
export default function QueryResultsLink(props: Props) {
let href = "";
const { query, queryResult, fileType } = props;
@@ -24,16 +35,6 @@ export default function QueryResultsLink(props) {
);
}
QueryResultsLink.propTypes = {
query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
queryResult: PropTypes.object, // eslint-disable-line react/forbid-prop-types
fileType: PropTypes.string,
disabled: PropTypes.bool.isRequired,
embed: PropTypes.bool,
apiKey: PropTypes.string,
children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired,
};
QueryResultsLink.defaultProps = {
queryResult: {},
fileType: "csv",

View File

@@ -1,9 +1,15 @@
import React from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import FormOutlinedIcon from "@ant-design/icons/FormOutlined";
export default function EditVisualizationButton(props) {
type OwnProps = {
openVisualizationEditor: (...args: any[]) => any;
selectedTab?: string | number;
};
type Props = OwnProps & typeof EditVisualizationButton.defaultProps;
export default function EditVisualizationButton(props: Props) {
return (
<Button
data-test="EditVisualization"
@@ -15,11 +21,6 @@ export default function EditVisualizationButton(props) {
);
}
EditVisualizationButton.propTypes = {
openVisualizationEditor: PropTypes.func.isRequired,
selectedTab: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
};
EditVisualizationButton.defaultProps = {
selectedTab: "",
};

View File

@@ -1,47 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import { clientConfig, currentUser } from "@/services/auth";
import Tooltip from "antd/lib/tooltip";
import Alert from "antd/lib/alert";
import HelpTrigger from "@/components/HelpTrigger";
export default function EmailSettingsWarning({ featureName, className, mode, adminOnly }) {
if (!clientConfig.mailSettingsMissing) {
return null;
}
if (adminOnly && !currentUser.isAdmin) {
return null;
}
const message = (
<span>
Your mail server isn&apos;t configured correctly, and is needed for {featureName} to work.{" "}
<HelpTrigger type="MAIL_CONFIG" className="f-inherit" />
</span>
);
if (mode === "icon") {
return (
<Tooltip title={message}>
<i className={cx("fa fa-exclamation-triangle", className)} />
</Tooltip>
);
}
return <Alert message={message} type="error" className={className} />;
}
EmailSettingsWarning.propTypes = {
featureName: PropTypes.string.isRequired,
className: PropTypes.string,
mode: PropTypes.oneOf(["alert", "icon"]),
adminOnly: PropTypes.bool,
};
EmailSettingsWarning.defaultProps = {
className: null,
mode: "alert",
adminOnly: false,
};

View File

@@ -0,0 +1,37 @@
import React from "react";
import cx from "classnames";
import { clientConfig, currentUser } from "@/services/auth";
import Tooltip from "antd/lib/tooltip";
import Alert from "antd/lib/alert";
import HelpTrigger from "@/components/HelpTrigger";
type OwnProps = {
featureName: string;
className?: string;
mode?: "alert" | "icon";
adminOnly?: boolean;
};
type Props = OwnProps & typeof EmailSettingsWarning.defaultProps;
export default function EmailSettingsWarning({ featureName, className, mode, adminOnly }: Props) {
if (!(clientConfig as any).mailSettingsMissing) {
return null;
}
if (adminOnly && !currentUser.isAdmin) {
return null;
}
const message = (<span>
Your mail server isn&apos;t configured correctly, and is needed for {featureName} to work.{" "}
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'string' is not assignable to type 'never'. */}
<HelpTrigger type="MAIL_CONFIG" className="f-inherit"/>
</span>);
if (mode === "icon") {
return (<Tooltip title={message}>
<i className={cx("fa fa-exclamation-triangle", className)}/>
</Tooltip>);
}
return <Alert message={message} type="error" className={className}/>;
}
EmailSettingsWarning.defaultProps = {
className: null,
mode: "alert",
adminOnly: false,
};

View File

@@ -1,19 +1,21 @@
import React from "react";
import PropTypes from "prop-types";
export default class FavoritesControl extends React.Component {
static propTypes = {
item: PropTypes.shape({
is_favorite: PropTypes.bool.isRequired,
}).isRequired,
onChange: PropTypes.func,
};
type OwnProps = {
item: {
is_favorite: boolean;
};
onChange?: (...args: any[]) => any;
};
type Props = OwnProps & typeof FavoritesControl.defaultProps;
export default class FavoritesControl extends React.Component<Props> {
static defaultProps = {
onChange: () => {},
};
toggleItem(event, item, callback) {
toggleItem(event: any, item: any, callback: any) {
const action = item.is_favorite ? item.unfavorite.bind(item) : item.favorite.bind(item);
const savedIsFavorite = item.is_favorite;

View File

@@ -1,146 +0,0 @@
import { isArray, indexOf, get, map, includes, every, some, toNumber } from "lodash";
import moment from "moment";
import React from "react";
import PropTypes from "prop-types";
import Select from "antd/lib/select";
import { formatColumnValue } from "@/lib/utils";
const ALL_VALUES = "###Redash::Filters::SelectAll###";
const NONE_VALUES = "###Redash::Filters::Clear###";
export const FilterType = PropTypes.shape({
name: PropTypes.string.isRequired,
friendlyName: PropTypes.string.isRequired,
multiple: PropTypes.bool,
current: PropTypes.oneOfType([PropTypes.any, PropTypes.arrayOf(PropTypes.any)]),
values: PropTypes.arrayOf(PropTypes.any).isRequired,
});
export const FiltersType = PropTypes.arrayOf(FilterType);
function createFilterChangeHandler(filters, onChange) {
return (filter, values) => {
if (isArray(values)) {
values = map(values, value => filter.values[toNumber(value.key)] || value.key);
} else {
const _values = filter.values[toNumber(values.key)];
values = _values !== undefined ? _values : values.key;
}
if (filter.multiple && includes(values, ALL_VALUES)) {
values = [...filter.values];
}
if (filter.multiple && includes(values, NONE_VALUES)) {
values = [];
}
filters = map(filters, f => (f.name === filter.name ? { ...filter, current: values } : f));
onChange(filters);
};
}
export function filterData(rows, filters = []) {
if (!isArray(rows)) {
return [];
}
let result = rows;
if (isArray(filters) && filters.length > 0) {
// "every" field's value should match "some" of corresponding filter's values
result = result.filter(row =>
every(filters, filter => {
const rowValue = row[filter.name];
const filterValues = isArray(filter.current) ? filter.current : [filter.current];
return some(filterValues, filterValue => {
if (moment.isMoment(rowValue)) {
return rowValue.isSame(filterValue);
}
// We compare with either the value or the String representation of the value,
// because Select2 casts true/false to "true"/"false".
return filterValue === rowValue || String(rowValue) === filterValue;
});
})
);
}
return result;
}
function Filters({ filters, onChange }) {
if (filters.length === 0) {
return null;
}
onChange = createFilterChangeHandler(filters, onChange);
return (
<div className="filters-wrapper" data-test="Filters">
<div className="container bg-white">
<div className="row">
{map(filters, filter => {
const options = map(filter.values, (value, index) => (
<Select.Option key={index}>{formatColumnValue(value, get(filter, "column.type"))}</Select.Option>
));
return (
<div
key={filter.name}
className="col-sm-6 p-l-0 filter-container"
data-test={`FilterName-${filter.name}`}>
<label>{filter.friendlyName}</label>
{options.length === 0 && <Select className="w-100" disabled value="No values" />}
{options.length > 0 && (
<Select
labelInValue
className="w-100"
mode={filter.multiple ? "multiple" : "default"}
value={
isArray(filter.current)
? map(filter.current, value => ({
key: `${indexOf(filter.values, value)}`,
label: formatColumnValue(value),
}))
: { key: `${indexOf(filter.values, filter.current)}`, label: formatColumnValue(filter.current) }
}
allowClear={filter.multiple}
optionFilterProp="children"
showSearch
maxTagCount={3}
maxTagTextLength={10}
maxTagPlaceholder={num => `+${num.length} more`}
onChange={values => onChange(filter, values)}>
{!filter.multiple && options}
{filter.multiple && [
<Select.Option key={NONE_VALUES} data-test="ClearOption">
<i className="fa fa-square-o m-r-5" />
Clear
</Select.Option>,
<Select.Option key={ALL_VALUES} data-test="SelectAllOption">
<i className="fa fa-check-square-o m-r-5" />
Select All
</Select.Option>,
<Select.OptGroup key="Values" title="Values">
{options}
</Select.OptGroup>,
]}
</Select>
)}
</div>
);
})}
</div>
</div>
</div>
);
}
Filters.propTypes = {
filters: FiltersType.isRequired,
onChange: PropTypes.func, // (name, value) => void
};
Filters.defaultProps = {
onChange: () => {},
};
export default Filters;

View File

@@ -0,0 +1,121 @@
import { isArray, indexOf, get, map, includes, every, some, toNumber } from "lodash";
import moment from "moment";
import React from "react";
import PropTypes from "prop-types";
import Select from "antd/lib/select";
import { formatColumnValue } from "@/lib/utils";
const ALL_VALUES = "###Redash::Filters::SelectAll###";
const NONE_VALUES = "###Redash::Filters::Clear###";
type FilterType = {
name: string;
friendlyName: string;
multiple?: boolean;
current?: any | any[];
values: any[];
};
// @ts-expect-error ts-migrate(2322) FIXME: Type 'Requireable<InferProps<{ name: Validator<str... Remove this comment to see the full error message
const FilterType: PropTypes.Requireable<FilterType> = PropTypes.shape({
name: PropTypes.string.isRequired,
friendlyName: PropTypes.string.isRequired,
multiple: PropTypes.bool,
current: PropTypes.oneOfType([PropTypes.any, PropTypes.arrayOf(PropTypes.any)]),
values: PropTypes.arrayOf(PropTypes.any).isRequired,
});
export { FilterType };
export const FiltersType = PropTypes.arrayOf(FilterType);
function createFilterChangeHandler(filters: any, onChange: any) {
return (filter: any, values: any) => {
if (isArray(values)) {
values = map(values, value => filter.values[toNumber(value.key)] || value.key);
}
else {
const _values = filter.values[toNumber(values.key)];
values = _values !== undefined ? _values : values.key;
}
if (filter.multiple && includes(values, ALL_VALUES)) {
values = [...filter.values];
}
if (filter.multiple && includes(values, NONE_VALUES)) {
values = [];
}
filters = map(filters, f => (f.name === filter.name ? { ...filter, current: values } : f));
onChange(filters);
};
}
export function filterData(rows: any, filters = []) {
if (!isArray(rows)) {
return [];
}
let result = rows;
if (isArray(filters) && filters.length > 0) {
// "every" field's value should match "some" of corresponding filter's values
result = result.filter(row => every(filters, filter => {
const rowValue = row[(filter as any).name];
const filterValues = isArray((filter as any).current) ? (filter as any).current : [(filter as any).current];
return some(filterValues, filterValue => {
if (moment.isMoment(rowValue)) {
return rowValue.isSame(filterValue);
}
// We compare with either the value or the String representation of the value,
// because Select2 casts true/false to "true"/"false".
return filterValue === rowValue || String(rowValue) === filterValue;
});
}));
}
return result;
}
type OwnProps = {
// @ts-expect-error ts-migrate(2749) FIXME: 'FiltersType' refers to a value, but is being used... Remove this comment to see the full error message
filters: FiltersType;
onChange?: (...args: any[]) => any;
};
type Props = OwnProps & typeof Filters.defaultProps;
function Filters({ filters, onChange }: Props) {
if (filters.length === 0) {
return null;
}
// @ts-expect-error ts-migrate(2322) FIXME: Type '(filter: any, values: any) => void' is not a... Remove this comment to see the full error message
onChange = createFilterChangeHandler(filters, onChange);
return (<div className="filters-wrapper" data-test="Filters">
<div className="container bg-white">
<div className="row">
{map(filters, filter => {
// @ts-expect-error ts-migrate(2741) FIXME: Property 'value' is missing in type '{ children: a... Remove this comment to see the full error message
const options = map(filter.values, (value, index) => (<Select.Option key={index}>{formatColumnValue(value, get(filter, "column.type"))}</Select.Option>));
return (<div key={filter.name} className="col-sm-6 p-l-0 filter-container" data-test={`FilterName-${filter.name}`}>
<label>{filter.friendlyName}</label>
{options.length === 0 && <Select className="w-100" disabled value="No values"/>}
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
{options.length > 0 && (<Select labelInValue className="w-100" mode={filter.multiple ? "multiple" : "default"} value={isArray(filter.current)
? map(filter.current, value => ({
key: `${indexOf(filter.values, value)}`,
label: formatColumnValue(value),
}))
: { key: `${indexOf(filter.values, filter.current)}`, label: formatColumnValue(filter.current) }} allowClear={filter.multiple} optionFilterProp="children" showSearch maxTagCount={3} maxTagTextLength={10} maxTagPlaceholder={num => `+${num.length} more`} onChange={values => onChange(filter, values)}>
{!filter.multiple && options}
{filter.multiple && [
// @ts-expect-error ts-migrate(2741) FIXME: Property 'value' is missing in type '{ children: (... Remove this comment to see the full error message
<Select.Option key={NONE_VALUES} data-test="ClearOption">
<i className="fa fa-square-o m-r-5"/>
Clear
</Select.Option>,
// @ts-expect-error ts-migrate(2741) FIXME: Property 'value' is missing in type '{ children: (... Remove this comment to see the full error message
<Select.Option key={ALL_VALUES} data-test="SelectAllOption">
<i className="fa fa-check-square-o m-r-5"/>
Select All
</Select.Option>,
<Select.OptGroup key="Values" title="Values">
{options}
</Select.OptGroup>,
]}
</Select>)}
</div>);
})}
</div>
</div>
</div>);
}
Filters.defaultProps = {
onChange: () => { },
};
export default Filters;

View File

@@ -1,219 +0,0 @@
import { startsWith, get } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import Tooltip from "antd/lib/tooltip";
import Drawer from "antd/lib/drawer";
import Link from "@/components/Link";
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
import BigMessage from "@/components/BigMessage";
import DynamicComponent from "@/components/DynamicComponent";
import "./HelpTrigger.less";
const DOMAIN = "https://redash.io";
const HELP_PATH = "/help";
const IFRAME_TIMEOUT = 20000;
const IFRAME_URL_UPDATE_MESSAGE = "iframe_url";
export const TYPES = {
HOME: ["", "Help"],
VALUE_SOURCE_OPTIONS: ["/user-guide/querying/query-parameters#Value-Source-Options", "Guide: Value Source Options"],
SHARE_DASHBOARD: ["/user-guide/dashboards/sharing-dashboards", "Guide: Sharing and Embedding Dashboards"],
AUTHENTICATION_OPTIONS: ["/user-guide/users/authentication-options", "Guide: Authentication Options"],
USAGE_DATA_SHARING: ["/open-source/admin-guide/usage-data", "Help: Anonymous Usage Data Sharing"],
DS_ATHENA: ["/data-sources/amazon-athena-setup", "Guide: Help Setting up Amazon Athena"],
DS_BIGQUERY: ["/data-sources/bigquery-setup", "Guide: Help Setting up BigQuery"],
DS_URL: ["/data-sources/querying-urls", "Guide: Help Setting up URL"],
DS_MONGODB: ["/data-sources/mongodb-setup", "Guide: Help Setting up MongoDB"],
DS_GOOGLE_SPREADSHEETS: ["/data-sources/querying-a-google-spreadsheet", "Guide: Help Setting up Google Spreadsheets"],
DS_GOOGLE_ANALYTICS: ["/data-sources/google-analytics-setup", "Guide: Help Setting up Google Analytics"],
DS_AXIBASETSD: ["/data-sources/axibase-time-series-database", "Guide: Help Setting up Axibase Time Series"],
DS_RESULTS: ["/user-guide/querying/query-results-data-source", "Guide: Help Setting up Query Results"],
ALERT_SETUP: ["/user-guide/alerts/setting-up-an-alert", "Guide: Setting Up a New Alert"],
MAIL_CONFIG: ["/open-source/setup/#Mail-Configuration", "Guide: Mail Configuration"],
ALERT_NOTIF_TEMPLATE_GUIDE: ["/user-guide/alerts/custom-alert-notifications", "Guide: Custom Alerts Notifications"],
FAVORITES: ["/user-guide/querying/favorites-tagging/#Favorites", "Guide: Favorites"],
MANAGE_PERMISSIONS: [
"/user-guide/querying/writing-queries#Managing-Query-Permissions",
"Guide: Managing Query Permissions",
],
NUMBER_FORMAT_SPECS: ["/user-guide/visualizations/formatting-numbers", "Formatting Numbers"],
};
export default class HelpTrigger extends React.Component {
static propTypes = {
type: PropTypes.oneOf(Object.keys(TYPES)),
href: PropTypes.string,
title: PropTypes.node,
className: PropTypes.string,
showTooltip: PropTypes.bool,
children: PropTypes.node,
};
static defaultProps = {
type: null,
href: null,
title: null,
className: null,
showTooltip: true,
children: <i className="fa fa-question-circle" />,
};
iframeRef = React.createRef();
iframeLoadingTimeout = null;
state = {
visible: false,
loading: false,
error: false,
currentUrl: null,
};
componentDidMount() {
window.addEventListener("message", this.onPostMessageReceived, false);
}
componentWillUnmount() {
window.removeEventListener("message", this.onPostMessageReceived);
clearTimeout(this.iframeLoadingTimeout);
}
loadIframe = url => {
clearTimeout(this.iframeLoadingTimeout);
this.setState({ loading: true, error: false });
this.iframeRef.current.src = url;
this.iframeLoadingTimeout = setTimeout(() => {
this.setState({ error: url, loading: false });
}, IFRAME_TIMEOUT); // safety
};
onIframeLoaded = () => {
this.setState({ loading: false });
clearTimeout(this.iframeLoadingTimeout);
};
onPostMessageReceived = event => {
if (!startsWith(event.origin, DOMAIN)) {
return;
}
const { type, message: currentUrl } = event.data || {};
if (type !== IFRAME_URL_UPDATE_MESSAGE) {
return;
}
this.setState({ currentUrl });
};
getUrl = () => {
const helpTriggerType = get(TYPES, this.props.type);
return helpTriggerType ? DOMAIN + HELP_PATH + helpTriggerType[0] : this.props.href;
};
openDrawer = () => {
this.setState({ visible: true });
// wait for drawer animation to complete so there's no animation jank
setTimeout(() => this.loadIframe(this.getUrl()), 300);
};
closeDrawer = event => {
if (event) {
event.preventDefault();
}
this.setState({ visible: false });
this.setState({ visible: false, currentUrl: null });
};
render() {
const tooltip = get(TYPES, `${this.props.type}[1]`, this.props.title);
const className = cx("help-trigger", this.props.className);
const url = this.state.currentUrl;
const isAllowedDomain = startsWith(url || this.getUrl(), DOMAIN);
return (
<React.Fragment>
<Tooltip
title={
this.props.showTooltip ? (
<>
{tooltip}
{!isAllowedDomain && <i className="fa fa-external-link" style={{ marginLeft: 5 }} />}
</>
) : null
}>
{isAllowedDomain ? (
<a onClick={this.openDrawer} className={className}>
{this.props.children}
</a>
) : (
<Link href={url || this.getUrl()} className={className} rel="noopener noreferrer" target="_blank">
{this.props.children}
</Link>
)}
</Tooltip>
<Drawer
placement="right"
closable={false}
onClose={this.closeDrawer}
visible={this.state.visible}
className="help-drawer"
destroyOnClose
width={400}>
<div className="drawer-wrapper">
<div className="drawer-menu">
{url && (
<Tooltip title="Open page in a new window" placement="left">
{/* eslint-disable-next-line react/jsx-no-target-blank */}
<Link href={url} target="_blank">
<i className="fa fa-external-link" />
</Link>
</Tooltip>
)}
<Tooltip title="Close" placement="bottom">
<a onClick={this.closeDrawer}>
<CloseOutlinedIcon />
</a>
</Tooltip>
</div>
{/* iframe */}
{!this.state.error && (
<iframe
ref={this.iframeRef}
title="Redash Help"
src="about:blank"
className={cx({ ready: !this.state.loading })}
onLoad={this.onIframeLoaded}
/>
)}
{/* loading indicator */}
{this.state.loading && (
<BigMessage icon="fa-spinner fa-2x fa-pulse" message="Loading..." className="help-message" />
)}
{/* error message */}
{this.state.error && (
<BigMessage icon="fa-exclamation-circle" className="help-message">
Something went wrong.
<br />
{/* eslint-disable-next-line react/jsx-no-target-blank */}
<Link href={this.state.error} target="_blank" rel="noopener">
Click here
</Link>{" "}
to open the page in a new window.
</BigMessage>
)}
</div>
{/* extra content */}
<DynamicComponent name="HelpDrawerExtraContent" onLeave={this.closeDrawer} openPageUrl={this.loadIframe} />
</Drawer>
</React.Fragment>
);
}
}

View File

@@ -0,0 +1,213 @@
import { startsWith, get, some, mapValues } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import Tooltip from "antd/lib/tooltip";
import Drawer from "antd/lib/drawer";
import Link from "@/components/Link";
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
import BigMessage from "@/components/BigMessage";
import DynamicComponent, { registerComponent } from "@/components/DynamicComponent";
import "./HelpTrigger.less";
const DOMAIN = "https://redash.io";
const HELP_PATH = "/help";
const IFRAME_TIMEOUT = 20000;
const IFRAME_URL_UPDATE_MESSAGE = "iframe_url";
export const TYPES = mapValues({
HOME: ["", "Help"],
VALUE_SOURCE_OPTIONS: ["/user-guide/querying/query-parameters#Value-Source-Options", "Guide: Value Source Options"],
SHARE_DASHBOARD: ["/user-guide/dashboards/sharing-dashboards", "Guide: Sharing and Embedding Dashboards"],
AUTHENTICATION_OPTIONS: ["/user-guide/users/authentication-options", "Guide: Authentication Options"],
USAGE_DATA_SHARING: ["/open-source/admin-guide/usage-data", "Help: Anonymous Usage Data Sharing"],
DS_ATHENA: ["/data-sources/amazon-athena-setup", "Guide: Help Setting up Amazon Athena"],
DS_BIGQUERY: ["/data-sources/bigquery-setup", "Guide: Help Setting up BigQuery"],
DS_URL: ["/data-sources/querying-urls", "Guide: Help Setting up URL"],
DS_MONGODB: ["/data-sources/mongodb-setup", "Guide: Help Setting up MongoDB"],
DS_GOOGLE_SPREADSHEETS: [
"/data-sources/querying-a-google-spreadsheet",
"Guide: Help Setting up Google Spreadsheets",
],
DS_GOOGLE_ANALYTICS: ["/data-sources/google-analytics-setup", "Guide: Help Setting up Google Analytics"],
DS_AXIBASETSD: ["/data-sources/axibase-time-series-database", "Guide: Help Setting up Axibase Time Series"],
DS_RESULTS: ["/user-guide/querying/query-results-data-source", "Guide: Help Setting up Query Results"],
ALERT_SETUP: ["/user-guide/alerts/setting-up-an-alert", "Guide: Setting Up a New Alert"],
MAIL_CONFIG: ["/open-source/setup/#Mail-Configuration", "Guide: Mail Configuration"],
ALERT_NOTIF_TEMPLATE_GUIDE: ["/user-guide/alerts/custom-alert-notifications", "Guide: Custom Alerts Notifications"],
FAVORITES: ["/user-guide/querying/favorites-tagging/#Favorites", "Guide: Favorites"],
MANAGE_PERMISSIONS: [
"/user-guide/querying/writing-queries#Managing-Query-Permissions",
"Guide: Managing Query Permissions",
],
NUMBER_FORMAT_SPECS: ["/user-guide/visualizations/formatting-numbers", "Formatting Numbers"],
GETTING_STARTED: ["/user-guide/getting-started", "Guide: Getting Started"],
DASHBOARDS: ["/user-guide/dashboards", "Guide: Dashboards"],
QUERIES: ["/help/user-guide/querying", "Guide: Queries"],
ALERTS: ["/user-guide/alerts", "Guide: Alerts"],
}, ([url, title]) => [DOMAIN + HELP_PATH + url, title]);
type OwnProps = {
type?: string;
href?: string;
title?: React.ReactNode;
className?: string;
showTooltip?: boolean;
renderAsLink?: boolean;
children?: React.ReactNode;
};
const HelpTriggerPropTypes = {
type: PropTypes.string,
href: PropTypes.string,
title: PropTypes.node,
className: PropTypes.string,
showTooltip: PropTypes.bool,
renderAsLink: PropTypes.bool,
children: PropTypes.node,
};
const HelpTriggerDefaultProps = {
type: null,
href: null,
title: null,
className: null,
showTooltip: true,
renderAsLink: false,
children: <i className="fa fa-question-circle"/>,
};
export function helpTriggerWithTypes(types: any, allowedDomains = [], drawerClassName = null) {
return class HelpTrigger extends React.Component {
static propTypes = {
...HelpTriggerPropTypes,
type: PropTypes.oneOf(Object.keys(types)),
};
static defaultProps = HelpTriggerDefaultProps;
iframeRef = React.createRef();
iframeLoadingTimeout = null;
state = {
visible: false,
loading: false,
error: false,
currentUrl: null,
};
componentDidMount() {
window.addEventListener("message", this.onPostMessageReceived, false);
}
componentWillUnmount() {
window.removeEventListener("message", this.onPostMessageReceived);
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
clearTimeout(this.iframeLoadingTimeout);
}
loadIframe = (url: any) => {
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
clearTimeout(this.iframeLoadingTimeout);
this.setState({ loading: true, error: false });
// @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'.
this.iframeRef.current.src = url;
// @ts-expect-error ts-migrate(2322) FIXME: Type 'number' is not assignable to type 'null'.
this.iframeLoadingTimeout = setTimeout(() => {
this.setState({ error: url, loading: false });
}, IFRAME_TIMEOUT); // safety
};
onIframeLoaded = () => {
this.setState({ loading: false });
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
clearTimeout(this.iframeLoadingTimeout);
};
onPostMessageReceived = (event: any) => {
if (!some(allowedDomains, domain => startsWith(event.origin, domain))) {
return;
}
const { type, message: currentUrl } = event.data || {};
if (type !== IFRAME_URL_UPDATE_MESSAGE) {
return;
}
this.setState({ currentUrl });
};
getUrl = () => {
const helpTriggerType = get(types, (this.props as any).type);
return helpTriggerType ? helpTriggerType[0] : (this.props as any).href;
};
openDrawer = (e: any) => {
// keep "open in new tab" behavior
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
this.setState({ visible: true });
// wait for drawer animation to complete so there's no animation jank
setTimeout(() => this.loadIframe(this.getUrl()), 300);
}
};
closeDrawer = (event: any) => {
if (event) {
event.preventDefault();
}
this.setState({ visible: false });
this.setState({ visible: false, currentUrl: null });
};
render() {
const targetUrl = this.getUrl();
if (!targetUrl) {
return null;
}
const tooltip = get(types, `${(this.props as any).type}[1]`, (this.props as any).title);
const className = cx("help-trigger", (this.props as any).className);
const url = this.state.currentUrl;
const isAllowedDomain = some(allowedDomains, domain => startsWith(url || targetUrl, domain));
const shouldRenderAsLink = (this.props as any).renderAsLink || !isAllowedDomain;
return (<React.Fragment>
<Tooltip title={(this.props as any).showTooltip ? (<>
{tooltip}
{shouldRenderAsLink && <i className="fa fa-external-link" style={{ marginLeft: 5 }}/>}
</>) : null}>
<Link href={url || this.getUrl()} className={className} rel="noopener noreferrer" target="_blank" onClick={shouldRenderAsLink ? () => { } : this.openDrawer}>
{this.props.children}
</Link>
</Tooltip>
<Drawer placement="right" closable={false} onClose={this.closeDrawer} visible={this.state.visible} className={cx("help-drawer", drawerClassName)} destroyOnClose width={400}>
<div className="drawer-wrapper">
<div className="drawer-menu">
{url && (<Tooltip title="Open page in a new window" placement="left">
<Link href={url} target="_blank">
<i className="fa fa-external-link"/>
</Link>
</Tooltip>)}
<Tooltip title="Close" placement="bottom">
<a onClick={this.closeDrawer}>
<CloseOutlinedIcon />
</a>
</Tooltip>
</div>
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'RefObject<unknown>' is not assignable to typ... Remove this comment to see the full error message */}
{!this.state.error && (<iframe ref={this.iframeRef} title="Usage Help" src="about:blank" className={cx({ ready: !this.state.loading })} onLoad={this.onIframeLoaded}/>)}
{this.state.loading && (<BigMessage icon="fa-spinner fa-2x fa-pulse" message="Loading..." className="help-message"/>)}
{/* @ts-expect-error ts-migrate(2746) FIXME: This JSX tag's 'children' prop expects a single ch... Remove this comment to see the full error message */}
{this.state.error && (<BigMessage icon="fa-exclamation-circle" className="help-message">
Something went wrong.
<br />
<Link href={this.state.error} target="_blank" rel="noopener">
Click here
</Link>{" "}
to open the page in a new window.
</BigMessage>)}
</div>
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
<DynamicComponent name="HelpDrawerExtraContent" onLeave={this.closeDrawer} openPageUrl={this.loadIframe}/>
</Drawer>
</React.Fragment>);
}
};
}
// @ts-expect-error ts-migrate(2322) FIXME: Type 'string' is not assignable to type 'never'.
registerComponent("HelpTrigger", helpTriggerWithTypes(TYPES, [DOMAIN]));
type Props = OwnProps & typeof HelpTriggerDefaultProps;
export default function HelpTrigger(props: Props) {
return <DynamicComponent {...props} name="HelpTrigger"/>;
}
HelpTrigger.defaultProps = HelpTriggerDefaultProps;

View File

@@ -3,8 +3,13 @@ import Input from "antd/lib/input";
import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined";
import Tooltip from "antd/lib/tooltip";
export default class InputWithCopy extends React.Component {
constructor(props) {
type State = any;
export default class InputWithCopy extends React.Component<{}, State> {
copyFeatureSupported: any;
ref: any;
resetCopyState: any;
constructor(props: {}) {
super(props);
this.state = { copied: null };
this.ref = React.createRef();

View File

@@ -1,21 +1,21 @@
import React from "react";
import Button from "antd/lib/button";
function DefaultLinkComponent(props) {
function DefaultLinkComponent(props: any) {
return <a {...props} />; // eslint-disable-line jsx-a11y/anchor-has-content
}
function Link(props) {
function Link(props: any) {
return <Link.Component {...props} />;
}
Link.Component = DefaultLinkComponent;
function DefaultButtonLinkComponent(props) {
return <Button {...props} />;
function DefaultButtonLinkComponent(props: any) {
return <Button role="button" {...props} />;
}
function ButtonLink(props) {
function ButtonLink(props: any) {
return <ButtonLink.Component {...props} />;
}

View File

@@ -1,18 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import BigMessage from "@/components/BigMessage";
import { TagsControl } from "@/components/tags-control/TagsControl";
export default function NoTaggedObjectsFound({ objectType, tags }) {
return (
<BigMessage icon="fa-tags">
No {objectType} found tagged with&nbsp;
<TagsControl className="inline-tags-control" tags={Array.from(tags)} tagSeparator={"+"} />.
</BigMessage>
);
}
NoTaggedObjectsFound.propTypes = {
objectType: PropTypes.string.isRequired,
tags: PropTypes.oneOfType([PropTypes.array, PropTypes.objectOf(Set)]).isRequired,
};

View File

@@ -0,0 +1,22 @@
import React from "react";
import BigMessage from "@/components/BigMessage";
import { TagsControl } from "@/components/tags-control/TagsControl";
type Props = {
objectType: string;
tags: any[] | {
// @ts-expect-error ts-migrate(2314) FIXME: Generic type 'Set<T>' requires 1 type argument(s).
[key: string]: Set;
};
};
export default function NoTaggedObjectsFound({ objectType, tags }: Props) {
return (
// @ts-expect-error ts-migrate(2746) FIXME: This JSX tag's 'children' prop expects a single ch... Remove this comment to see the full error message
<BigMessage icon="fa-tags">
No {objectType} found tagged with&nbsp;
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
<TagsControl className="inline-tags-control" tags={Array.from(tags)} tagSeparator={"+"} />.
</BigMessage>
);
}

View File

@@ -1,9 +1,15 @@
import React from "react";
import PropTypes from "prop-types";
import "./index.less";
export default function PageHeader({ title, actions }) {
type OwnProps = {
title?: string;
actions?: React.ReactNode;
};
type Props = OwnProps & typeof PageHeader.defaultProps;
export default function PageHeader({ title, actions }: Props) {
return (
<div className="page-header-wrapper">
<h3>{title}</h3>
@@ -12,11 +18,6 @@ export default function PageHeader({ title, actions }) {
);
}
PageHeader.propTypes = {
title: PropTypes.string,
actions: PropTypes.node,
};
PageHeader.defaultProps = {
title: "",
actions: null,

View File

@@ -1,10 +1,20 @@
import React from "react";
import PropTypes from "prop-types";
import Pagination from "antd/lib/pagination";
const MIN_ITEMS_PER_PAGE = 5;
export default function Paginator({ page, showPageSizeSelect, pageSize, onPageSizeChange, totalCount, onChange }) {
type OwnProps = {
page: number;
showPageSizeSelect?: boolean;
pageSize: number;
totalCount: number;
onPageSizeChange?: (...args: any[]) => any;
onChange?: (...args: any[]) => any;
};
type Props = OwnProps & typeof Paginator.defaultProps;
export default function Paginator({ page, showPageSizeSelect, pageSize, onPageSizeChange, totalCount, onChange }: Props) {
if (totalCount <= (showPageSizeSelect ? MIN_ITEMS_PER_PAGE : pageSize)) {
return null;
}
@@ -23,15 +33,6 @@ export default function Paginator({ page, showPageSizeSelect, pageSize, onPageSi
);
}
Paginator.propTypes = {
page: PropTypes.number.isRequired,
showPageSizeSelect: PropTypes.bool,
pageSize: PropTypes.number.isRequired,
totalCount: PropTypes.number.isRequired,
onPageSizeChange: PropTypes.func,
onChange: PropTypes.func,
};
Paginator.defaultProps = {
showPageSizeSelect: false,
onChange: () => {},

View File

@@ -1,11 +1,15 @@
import React from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import Badge from "antd/lib/badge";
import Tooltip from "antd/lib/tooltip";
import KeyboardShortcuts from "@/services/KeyboardShortcuts";
function ParameterApplyButton({ paramCount, onClick }) {
type Props = {
onClick: (...args: any[]) => any;
paramCount: number;
};
function ParameterApplyButton({ paramCount, onClick }: Props) {
// show spinner when count is empty so the fade out is consistent
const icon = !paramCount ? "spinner fa-pulse" : "check";
@@ -24,9 +28,4 @@ function ParameterApplyButton({ paramCount, onClick }) {
);
}
ParameterApplyButton.propTypes = {
onClick: PropTypes.func.isRequired,
paramCount: PropTypes.number.isRequired,
};
export default ParameterApplyButton;

View File

@@ -1,634 +0,0 @@
/* eslint-disable react/no-multi-comp */
import { isString, extend, each, has, map, includes, findIndex, find, fromPairs, clone, isEmpty } from "lodash";
import React, { Fragment } from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import Select from "antd/lib/select";
import Table from "antd/lib/table";
import Popover from "antd/lib/popover";
import Button from "antd/lib/button";
import Tag from "antd/lib/tag";
import Input from "antd/lib/input";
import Radio from "antd/lib/radio";
import Form from "antd/lib/form";
import Tooltip from "antd/lib/tooltip";
import ParameterValueInput from "@/components/ParameterValueInput";
import { ParameterMappingType } from "@/services/widget";
import { Parameter, cloneParameter } from "@/services/parameters";
import HelpTrigger from "@/components/HelpTrigger";
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
import CheckOutlinedIcon from "@ant-design/icons/CheckOutlined";
import "./ParameterMappingInput.less";
const { Option } = Select;
export const MappingType = {
DashboardAddNew: "dashboard-add-new",
DashboardMapToExisting: "dashboard-map-to-existing",
WidgetLevel: "widget-level",
StaticValue: "static-value",
};
export function parameterMappingsToEditableMappings(mappings, parameters, existingParameterNames = []) {
return map(mappings, mapping => {
const result = extend({}, mapping);
const alreadyExists = includes(existingParameterNames, mapping.mapTo);
result.param = find(parameters, p => p.name === mapping.name);
switch (mapping.type) {
case ParameterMappingType.DashboardLevel:
result.type = alreadyExists ? MappingType.DashboardMapToExisting : MappingType.DashboardAddNew;
result.value = null;
break;
case ParameterMappingType.StaticValue:
result.type = MappingType.StaticValue;
result.param = cloneParameter(result.param);
result.param.setValue(result.value);
break;
case ParameterMappingType.WidgetLevel:
result.type = MappingType.WidgetLevel;
result.value = null;
break;
// no default
}
return result;
});
}
export function editableMappingsToParameterMappings(mappings) {
return fromPairs(
map(
// convert to map
mappings,
mapping => {
const result = extend({}, mapping);
switch (mapping.type) {
case MappingType.DashboardAddNew:
result.type = ParameterMappingType.DashboardLevel;
result.value = null;
break;
case MappingType.DashboardMapToExisting:
result.type = ParameterMappingType.DashboardLevel;
result.value = null;
break;
case MappingType.StaticValue:
result.type = ParameterMappingType.StaticValue;
result.param = cloneParameter(mapping.param);
result.param.setValue(result.value);
result.value = result.param.value;
break;
case MappingType.WidgetLevel:
result.type = ParameterMappingType.WidgetLevel;
result.value = null;
break;
// no default
}
delete result.param;
return [result.name, result];
}
)
);
}
export function synchronizeWidgetTitles(sourceMappings, widgets) {
const affectedWidgets = [];
each(sourceMappings, sourceMapping => {
if (sourceMapping.type === ParameterMappingType.DashboardLevel) {
each(widgets, widget => {
const widgetMappings = widget.options.parameterMappings;
each(widgetMappings, widgetMapping => {
// check if mapped to the same dashboard-level parameter
if (
widgetMapping.type === ParameterMappingType.DashboardLevel &&
widgetMapping.mapTo === sourceMapping.mapTo
) {
// dirty check - update only when needed
if (widgetMapping.title !== sourceMapping.title) {
widgetMapping.title = sourceMapping.title;
affectedWidgets.push(widget);
}
}
});
});
}
});
return affectedWidgets;
}
export class ParameterMappingInput extends React.Component {
static propTypes = {
mapping: PropTypes.object, // eslint-disable-line react/forbid-prop-types
existingParamNames: PropTypes.arrayOf(PropTypes.string),
onChange: PropTypes.func,
inputError: PropTypes.string,
};
static defaultProps = {
mapping: {},
existingParamNames: [],
onChange: () => {},
inputError: null,
};
formItemProps = {
labelCol: { span: 5 },
wrapperCol: { span: 16 },
className: "form-item",
};
updateSourceType = type => {
let {
mapping: { mapTo },
} = this.props;
const { existingParamNames } = this.props;
// if mapped name doesn't already exists
// default to first select option
if (type === MappingType.DashboardMapToExisting && !includes(existingParamNames, mapTo)) {
mapTo = existingParamNames[0];
}
this.updateParamMapping({ type, mapTo });
};
updateParamMapping = update => {
const { onChange, mapping } = this.props;
const newMapping = extend({}, mapping, update);
if (newMapping.value !== mapping.value) {
newMapping.param = cloneParameter(newMapping.param);
newMapping.param.setValue(newMapping.value);
}
if (has(update, "type")) {
if (update.type === MappingType.StaticValue) {
newMapping.value = newMapping.param.value;
} else {
newMapping.value = null;
}
}
onChange(newMapping);
};
renderMappingTypeSelector() {
const noExisting = isEmpty(this.props.existingParamNames);
return (
<Radio.Group value={this.props.mapping.type} onChange={e => this.updateSourceType(e.target.value)}>
<Radio className="radio" value={MappingType.DashboardAddNew} data-test="NewDashboardParameterOption">
New dashboard parameter
</Radio>
<Radio className="radio" value={MappingType.DashboardMapToExisting} disabled={noExisting}>
Existing dashboard parameter{" "}
{noExisting ? (
<Tooltip title="There are no dashboard parameters corresponding to this data type">
<QuestionCircleFilledIcon />
</Tooltip>
) : null}
</Radio>
<Radio className="radio" value={MappingType.WidgetLevel} data-test="WidgetParameterOption">
Widget parameter
</Radio>
<Radio className="radio" value={MappingType.StaticValue} data-test="StaticValueOption">
Static value
</Radio>
</Radio.Group>
);
}
renderDashboardAddNew() {
const {
mapping: { mapTo },
} = this.props;
return <Input value={mapTo} onChange={e => this.updateParamMapping({ mapTo: e.target.value })} />;
}
renderDashboardMapToExisting() {
const { mapping, existingParamNames } = this.props;
return (
<Select
value={mapping.mapTo}
onChange={mapTo => this.updateParamMapping({ mapTo })}
dropdownMatchSelectWidth={false}>
{map(existingParamNames, name => (
<Option value={name} key={name}>
{name}
</Option>
))}
</Select>
);
}
renderStaticValue() {
const { mapping } = this.props;
return (
<ParameterValueInput
type={mapping.param.type}
value={mapping.param.normalizedValue}
enumOptions={mapping.param.enumOptions}
queryId={mapping.param.queryId}
parameter={mapping.param}
onSelect={value => this.updateParamMapping({ value })}
/>
);
}
renderInputBlock() {
const { mapping } = this.props;
switch (mapping.type) {
case MappingType.DashboardAddNew:
return ["Key", "Enter a new parameter keyword", this.renderDashboardAddNew()];
case MappingType.DashboardMapToExisting:
return ["Key", "Select from a list of existing parameters", this.renderDashboardMapToExisting()];
case MappingType.StaticValue:
return ["Value", null, this.renderStaticValue()];
default:
return [];
}
}
render() {
const { inputError } = this.props;
const [label, help, input] = this.renderInputBlock();
return (
<Form layout="horizontal">
<Form.Item label="Source" {...this.formItemProps}>
{this.renderMappingTypeSelector()}
</Form.Item>
<Form.Item
style={{ height: 60, visibility: input ? "visible" : "hidden" }}
label={label}
{...this.formItemProps}
validateStatus={inputError ? "error" : ""}
help={inputError || help} // empty space so line doesn't collapse
>
{input}
</Form.Item>
</Form>
);
}
}
class MappingEditor extends React.Component {
static propTypes = {
mapping: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
existingParamNames: PropTypes.arrayOf(PropTypes.string).isRequired,
onChange: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
this.state = {
visible: false,
mapping: clone(this.props.mapping),
inputError: null,
};
}
onVisibleChange = visible => {
if (visible) this.show();
else this.hide();
};
onChange = mapping => {
let inputError = null;
if (mapping.type === MappingType.DashboardAddNew) {
if (isEmpty(mapping.mapTo)) {
inputError = "Keyword must have a value";
} else if (includes(this.props.existingParamNames, mapping.mapTo)) {
inputError = "A parameter with this name already exists";
}
}
this.setState({ mapping, inputError });
};
save = () => {
this.props.onChange(this.props.mapping, this.state.mapping);
this.hide();
};
show = () => {
this.setState({
visible: true,
mapping: clone(this.props.mapping), // restore original state
});
};
hide = () => {
this.setState({ visible: false });
};
renderContent() {
const { mapping, inputError } = this.state;
return (
<div className="parameter-mapping-editor" data-test="EditParamMappingPopover">
<header>
Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" />
</header>
<ParameterMappingInput
mapping={mapping}
existingParamNames={this.props.existingParamNames}
onChange={this.onChange}
inputError={inputError}
/>
<footer>
<Button onClick={this.hide}>Cancel</Button>
<Button onClick={this.save} disabled={!!inputError} type="primary">
OK
</Button>
</footer>
</div>
);
}
render() {
const { visible, mapping } = this.state;
return (
<Popover
placement="left"
trigger="click"
content={this.renderContent()}
visible={visible}
onVisibleChange={this.onVisibleChange}>
<Button size="small" type="dashed" data-test={`EditParamMappingButon-${mapping.param.name}`}>
<EditOutlinedIcon />
</Button>
</Popover>
);
}
}
class TitleEditor extends React.Component {
static propTypes = {
existingParams: PropTypes.arrayOf(PropTypes.object),
mapping: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
onChange: PropTypes.func.isRequired,
};
static defaultProps = {
existingParams: [],
};
state = {
showPopup: false,
title: "", // will be set on editing
};
onPopupVisibleChange = showPopup => {
this.setState({
showPopup,
title: showPopup ? this.getMappingTitle() : "",
});
};
onEditingTitleChange = event => {
this.setState({ title: event.target.value });
};
getMappingTitle() {
let { mapping } = this.props;
if (isString(mapping.title) && mapping.title !== "") {
return mapping.title;
}
// if mapped to dashboard, find source param and return it's title
if (mapping.type === MappingType.DashboardMapToExisting) {
const source = find(this.props.existingParams, { name: mapping.mapTo });
if (source) {
mapping = source;
}
}
return mapping.title || mapping.param.title;
}
save = () => {
const newMapping = extend({}, this.props.mapping, { title: this.state.title });
this.props.onChange(newMapping);
this.hide();
};
hide = () => {
this.setState({ showPopup: false });
};
renderPopover() {
const {
param: { title: paramTitle },
} = this.props.mapping;
return (
<div className="parameter-mapping-title-editor">
<Input
size="small"
value={this.state.title}
placeholder={paramTitle}
onChange={this.onEditingTitleChange}
onPressEnter={this.save}
maxLength={100}
autoFocus
/>
<Button size="small" type="dashed" onClick={this.hide}>
<CloseOutlinedIcon />
</Button>
<Button size="small" type="dashed" onClick={this.save}>
<CheckOutlinedIcon />
</Button>
</div>
);
}
renderEditButton() {
const { mapping } = this.props;
if (mapping.type === MappingType.StaticValue) {
return (
<Tooltip placement="right" title="Titles for static values don't appear in widgets">
<i className="fa fa-eye-slash" />
</Tooltip>
);
}
return (
<Popover
placement="right"
trigger="click"
content={this.renderPopover()}
visible={this.state.showPopup}
onVisibleChange={this.onPopupVisibleChange}>
<Button size="small" type="dashed">
<EditOutlinedIcon />
</Button>
</Popover>
);
}
render() {
const { mapping } = this.props;
// static value are non-editable hence disabled
const disabled = mapping.type === MappingType.StaticValue;
return (
<div className={classNames("parameter-mapping-title", { disabled })}>
<span className="text">{this.getMappingTitle()}</span>
{this.renderEditButton()}
</div>
);
}
}
export class ParameterMappingListInput extends React.Component {
static propTypes = {
mappings: PropTypes.arrayOf(PropTypes.object),
existingParams: PropTypes.arrayOf(PropTypes.object),
onChange: PropTypes.func,
};
static defaultProps = {
mappings: [],
existingParams: [],
onChange: () => {},
};
static getStringValue(value) {
// null
if (!value) {
return "";
}
// range
if (value instanceof Object && "start" in value && "end" in value) {
return `${value.start} ~ ${value.end}`;
}
// just to be safe, array or object
if (typeof value === "object") {
return map(value, v => this.getStringValue(v)).join(", ");
}
// rest
return value.toString();
}
static getDefaultValue(mapping, existingParams) {
const { type, mapTo, name } = mapping;
let { param } = mapping;
// if mapped to another param, swap 'em
if (type === MappingType.DashboardMapToExisting && mapTo !== name) {
const mappedTo = find(existingParams, { name: mapTo });
if (mappedTo) {
// just being safe
param = mappedTo;
}
// static type is different since it's fed param.normalizedValue
} else if (type === MappingType.StaticValue) {
param = cloneParameter(param).setValue(mapping.value);
}
let value = Parameter.getExecutionValue(param);
// in case of dynamic value display the name instead of value
if (param.hasDynamicValue) {
value = param.normalizedValue.name;
}
return this.getStringValue(value);
}
static getSourceTypeLabel({ type, mapTo }) {
switch (type) {
case MappingType.DashboardAddNew:
case MappingType.DashboardMapToExisting:
return (
<Fragment>
Dashboard <Tag className="tag">{mapTo}</Tag>
</Fragment>
);
case MappingType.WidgetLevel:
return "Widget parameter";
case MappingType.StaticValue:
return "Static value";
default:
return ""; // won't happen (typescript-ftw)
}
}
updateParamMapping(oldMapping, newMapping) {
const mappings = [...this.props.mappings];
const index = findIndex(mappings, oldMapping);
if (index >= 0) {
// This should be the only possible case, but need to handle `else` too
mappings[index] = newMapping;
} else {
mappings.push(newMapping);
}
this.props.onChange(mappings);
}
render() {
const { existingParams } = this.props; // eslint-disable-line react/prop-types
const dataSource = this.props.mappings.map(mapping => ({ mapping }));
return (
<div className="parameters-mapping-list">
<Table dataSource={dataSource} size="middle" pagination={false} rowKey={(record, idx) => `row${idx}`}>
<Table.Column
title="Title"
dataIndex="mapping"
key="title"
render={mapping => (
<TitleEditor
existingParams={existingParams}
mapping={mapping}
onChange={newMapping => this.updateParamMapping(mapping, newMapping)}
/>
)}
/>
<Table.Column
title="Keyword"
dataIndex="mapping"
key="keyword"
className="keyword"
render={mapping => <code>{`{{ ${mapping.name} }}`}</code>}
/>
<Table.Column
title="Default Value"
dataIndex="mapping"
key="value"
render={mapping => this.constructor.getDefaultValue(mapping, this.props.existingParams)}
/>
<Table.Column
title="Value Source"
dataIndex="mapping"
key="source"
render={mapping => {
const existingParamsNames = existingParams
.filter(({ type }) => type === mapping.param.type) // exclude mismatching param types
.map(({ name }) => name); // keep names only
return (
<Fragment>
{this.constructor.getSourceTypeLabel(mapping)}{" "}
<MappingEditor
mapping={mapping}
existingParamNames={existingParamsNames}
onChange={(oldMapping, newMapping) => this.updateParamMapping(oldMapping, newMapping)}
/>
</Fragment>
);
}}
/>
</Table>
</div>
);
}
}

View File

@@ -0,0 +1,467 @@
/* eslint-disable react/no-multi-comp */
import { isString, extend, each, has, map, includes, findIndex, find, fromPairs, clone, isEmpty } from "lodash";
import React, { Fragment } from "react";
import classNames from "classnames";
import Select from "antd/lib/select";
import Table from "antd/lib/table";
import Popover from "antd/lib/popover";
import Button from "antd/lib/button";
import Tag from "antd/lib/tag";
import Input from "antd/lib/input";
import Radio from "antd/lib/radio";
import Form from "antd/lib/form";
import Tooltip from "antd/lib/tooltip";
import ParameterValueInput from "@/components/ParameterValueInput";
import { ParameterMappingType } from "@/services/widget";
import { Parameter, cloneParameter } from "@/services/parameters";
import HelpTrigger from "@/components/HelpTrigger";
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";
export const MappingType = {
DashboardAddNew: "dashboard-add-new",
DashboardMapToExisting: "dashboard-map-to-existing",
WidgetLevel: "widget-level",
StaticValue: "static-value",
};
export function parameterMappingsToEditableMappings(mappings: any, parameters: any, existingParameterNames = []) {
return map(mappings, mapping => {
const result = extend({}, mapping);
const alreadyExists = includes(existingParameterNames, mapping.mapTo);
result.param = find(parameters, p => p.name === mapping.name);
switch (mapping.type) {
case ParameterMappingType.DashboardLevel:
result.type = alreadyExists ? MappingType.DashboardMapToExisting : MappingType.DashboardAddNew;
result.value = null;
break;
case ParameterMappingType.StaticValue:
result.type = MappingType.StaticValue;
result.param = cloneParameter(result.param);
result.param.setValue(result.value);
break;
case ParameterMappingType.WidgetLevel:
result.type = MappingType.WidgetLevel;
result.value = null;
break;
// no default
}
return result;
});
}
export function editableMappingsToParameterMappings(mappings: any) {
return fromPairs(map(
// convert to map
mappings, mapping => {
const result = extend({}, mapping);
switch (mapping.type) {
case MappingType.DashboardAddNew:
result.type = ParameterMappingType.DashboardLevel;
result.value = null;
break;
case MappingType.DashboardMapToExisting:
result.type = ParameterMappingType.DashboardLevel;
result.value = null;
break;
case MappingType.StaticValue:
result.type = ParameterMappingType.StaticValue;
result.param = cloneParameter(mapping.param);
result.param.setValue(result.value);
result.value = result.param.value;
break;
case MappingType.WidgetLevel:
result.type = ParameterMappingType.WidgetLevel;
result.value = null;
break;
// no default
}
delete result.param;
return [result.name, result];
}));
}
export function synchronizeWidgetTitles(sourceMappings: any, widgets: any) {
const affectedWidgets: any = [];
each(sourceMappings, sourceMapping => {
if (sourceMapping.type === ParameterMappingType.DashboardLevel) {
each(widgets, widget => {
const widgetMappings = widget.options.parameterMappings;
each(widgetMappings, widgetMapping => {
// check if mapped to the same dashboard-level parameter
if (widgetMapping.type === ParameterMappingType.DashboardLevel &&
widgetMapping.mapTo === sourceMapping.mapTo) {
// dirty check - update only when needed
if (widgetMapping.title !== sourceMapping.title) {
widgetMapping.title = sourceMapping.title;
affectedWidgets.push(widget);
}
}
});
});
}
});
return affectedWidgets;
}
type OwnParameterMappingInputProps = {
mapping?: any;
existingParamNames?: string[];
onChange?: (...args: any[]) => any;
inputError?: string;
};
type ParameterMappingInputProps = OwnParameterMappingInputProps & typeof ParameterMappingInput.defaultProps;
export class ParameterMappingInput extends React.Component<ParameterMappingInputProps> {
static defaultProps = {
mapping: {},
existingParamNames: [],
onChange: () => { },
inputError: null,
};
formItemProps = {
labelCol: { span: 5 },
wrapperCol: { span: 16 },
className: "form-item",
};
updateSourceType = (type: any) => {
let { mapping: { mapTo }, } = this.props;
const { existingParamNames } = this.props;
// if mapped name doesn't already exists
// default to first select option
if (type === MappingType.DashboardMapToExisting && !includes(existingParamNames, mapTo)) {
mapTo = existingParamNames[0];
}
this.updateParamMapping({ type, mapTo });
};
updateParamMapping = (update: any) => {
const { onChange, mapping } = this.props;
const newMapping = extend({}, mapping, update);
if ((newMapping as any).value !== (mapping as any).value) {
(newMapping as any).param = cloneParameter((newMapping as any).param);
(newMapping as any).param.setValue((newMapping as any).value);
}
if (has(update, "type")) {
if (update.type === MappingType.StaticValue) {
(newMapping as any).value = (newMapping as any).param.value;
}
else {
(newMapping as any).value = null;
}
}
// @ts-expect-error ts-migrate(2349) FIXME: This expression is not callable.
onChange(newMapping);
};
renderMappingTypeSelector() {
const noExisting = isEmpty((this.props as any).existingParamNames);
return (<Radio.Group value={(this.props as any).mapping.type} onChange={e => this.updateSourceType(e.target.value)}>
<Radio className="radio" value={MappingType.DashboardAddNew} data-test="NewDashboardParameterOption">
New dashboard parameter
</Radio>
<Radio className="radio" value={MappingType.DashboardMapToExisting} disabled={noExisting}>
Existing dashboard parameter{" "}
{noExisting ? (<Tooltip title="There are no dashboard parameters corresponding to this data type">
<QuestionCircleFilledIcon />
</Tooltip>) : null}
</Radio>
<Radio className="radio" value={MappingType.WidgetLevel} data-test="WidgetParameterOption">
Widget parameter
</Radio>
<Radio className="radio" value={MappingType.StaticValue} data-test="StaticValueOption">
Static value
</Radio>
</Radio.Group>);
}
renderDashboardAddNew() {
const { mapping: { mapTo }, } = this.props;
return <Input value={mapTo} onChange={e => this.updateParamMapping({ mapTo: e.target.value })}/>;
}
renderDashboardMapToExisting() {
const { mapping, existingParamNames } = this.props;
const options = map(existingParamNames, paramName => ({ label: paramName, value: paramName }));
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
return <Select value={(mapping as any).mapTo} onChange={mapTo => this.updateParamMapping({ mapTo })} options={options}/>;
}
renderStaticValue() {
const { mapping } = this.props;
// @ts-expect-error ts-migrate(2322) FIXME: Type 'any' is not assignable to type 'never'.
return (<ParameterValueInput type={(mapping as any).param.type} value={(mapping as any).param.normalizedValue} enumOptions={(mapping as any).param.enumOptions} queryId={(mapping as any).param.queryId} parameter={(mapping as any).param} onSelect={(value: any) => this.updateParamMapping({ value })}/>);
}
renderInputBlock() {
const { mapping } = this.props;
switch ((mapping as any).type) {
case MappingType.DashboardAddNew:
return ["Key", "Enter a new parameter keyword", this.renderDashboardAddNew()];
case MappingType.DashboardMapToExisting:
return ["Key", "Select from a list of existing parameters", this.renderDashboardMapToExisting()];
case MappingType.StaticValue:
return ["Value", null, this.renderStaticValue()];
default:
return [];
}
}
render() {
const { inputError } = this.props;
const [label, help, input] = this.renderInputBlock();
return (<Form layout="horizontal">
<Form.Item label="Source" {...this.formItemProps}>
{this.renderMappingTypeSelector()}
</Form.Item>
<Form.Item style={{ height: 60, visibility: input ? "visible" : "hidden" }} label={label} {...this.formItemProps} validateStatus={inputError ? "error" : ""} help={inputError || help} // empty space so line doesn't collapse
>
{input}
</Form.Item>
</Form>);
}
}
type MappingEditorProps = {
mapping: any;
existingParamNames: string[];
onChange: (...args: any[]) => any;
};
type MappingEditorState = any;
class MappingEditor extends React.Component<MappingEditorProps, MappingEditorState> {
constructor(props: MappingEditorProps) {
super(props);
this.state = {
visible: false,
mapping: clone(this.props.mapping),
inputError: null,
};
}
onVisibleChange = (visible: any) => {
if (visible)
this.show();
else
this.hide();
};
onChange = (mapping: any) => {
let inputError = null;
if (mapping.type === MappingType.DashboardAddNew) {
if (isEmpty(mapping.mapTo)) {
inputError = "Keyword must have a value";
}
else if (includes(this.props.existingParamNames, mapping.mapTo)) {
inputError = "A parameter with this name already exists";
}
}
this.setState({ mapping, inputError });
};
save = () => {
this.props.onChange(this.props.mapping, this.state.mapping);
this.hide();
};
show = () => {
this.setState({
visible: true,
mapping: clone(this.props.mapping),
});
};
hide = () => {
this.setState({ visible: false });
};
renderContent() {
const { mapping, inputError } = this.state;
return (<div className="parameter-mapping-editor" data-test="EditParamMappingPopover">
<header>
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'string' is not assignable to type 'never'. */}
Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS"/>
</header>
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
<ParameterMappingInput mapping={mapping} existingParamNames={this.props.existingParamNames} onChange={this.onChange} inputError={inputError}/>
<footer>
<Button onClick={this.hide}>Cancel</Button>
<Button onClick={this.save} disabled={!!inputError} type="primary">
OK
</Button>
</footer>
</div>);
}
render() {
const { visible, mapping } = this.state;
return (<Popover placement="left" trigger="click" content={this.renderContent()} visible={visible} onVisibleChange={this.onVisibleChange}>
<Button size="small" type="dashed" data-test={`EditParamMappingButton-${mapping.param.name}`}>
<EditOutlinedIcon />
</Button>
</Popover>);
}
}
type OwnTitleEditorProps = {
existingParams?: any[];
mapping: any;
onChange: (...args: any[]) => any;
};
type TitleEditorState = any;
type TitleEditorProps = OwnTitleEditorProps & typeof TitleEditor.defaultProps;
class TitleEditor extends React.Component<TitleEditorProps, TitleEditorState> {
static defaultProps = {
existingParams: [],
};
state = {
showPopup: false,
title: "",
};
onPopupVisibleChange = (showPopup: any) => {
this.setState({
showPopup,
title: showPopup ? this.getMappingTitle() : "",
});
};
onEditingTitleChange = (event: any) => {
this.setState({ title: event.target.value });
};
getMappingTitle() {
let { mapping } = this.props;
if (isString(mapping.title) && mapping.title !== "") {
return mapping.title;
}
// if mapped to dashboard, find source param and return it's title
if (mapping.type === MappingType.DashboardMapToExisting) {
const source = find(this.props.existingParams, { name: mapping.mapTo });
if (source) {
mapping = source;
}
}
return mapping.title || mapping.param.title;
}
save = () => {
const newMapping = extend({}, this.props.mapping, { title: this.state.title });
this.props.onChange(newMapping);
this.hide();
};
hide = () => {
this.setState({ showPopup: false });
};
renderPopover() {
const { param: { title: paramTitle }, } = this.props.mapping;
return (<div className="parameter-mapping-title-editor">
<Input size="small" value={this.state.title} placeholder={paramTitle} onChange={this.onEditingTitleChange} onPressEnter={this.save} maxLength={100} autoFocus/>
<Button size="small" type="dashed" onClick={this.hide}>
<CloseOutlinedIcon />
</Button>
<Button size="small" type="dashed" onClick={this.save}>
<CheckOutlinedIcon />
</Button>
</div>);
}
renderEditButton() {
const { mapping } = this.props;
if (mapping.type === MappingType.StaticValue) {
return (<Tooltip placement="right" title="Titles for static values don't appear in widgets">
<i className="fa fa-eye-slash"/>
</Tooltip>);
}
return (<Popover placement="right" trigger="click" content={this.renderPopover()} visible={this.state.showPopup} onVisibleChange={this.onPopupVisibleChange}>
<Button size="small" type="dashed">
<EditOutlinedIcon />
</Button>
</Popover>);
}
render() {
const { mapping } = this.props;
// static value are non-editable hence disabled
const disabled = mapping.type === MappingType.StaticValue;
return (<div className={classNames("parameter-mapping-title", { disabled })}>
<span className="text">{this.getMappingTitle()}</span>
{this.renderEditButton()}
</div>);
}
}
type OwnParameterMappingListInputProps = {
mappings?: any[];
existingParams?: any[];
onChange?: (...args: any[]) => any;
};
type ParameterMappingListInputProps = OwnParameterMappingListInputProps & typeof ParameterMappingListInput.defaultProps;
export class ParameterMappingListInput extends React.Component<ParameterMappingListInputProps> {
static defaultProps = {
mappings: [],
existingParams: [],
onChange: () => { },
};
// @ts-expect-error ts-migrate(7023) FIXME: 'getStringValue' implicitly has return type 'any' ... Remove this comment to see the full error message
static getStringValue(value: any) {
// null
if (!value) {
return "";
}
// range
if (value instanceof Object && "start" in value && "end" in value) {
return `${value.start} ~ ${value.end}`;
}
// just to be safe, array or object
if (typeof value === "object") {
return map(value, v => this.getStringValue(v)).join(", ");
}
// rest
return value.toString();
}
static getDefaultValue(mapping: any, existingParams: any) {
const { type, mapTo, name } = mapping;
let { param } = mapping;
// if mapped to another param, swap 'em
if (type === MappingType.DashboardMapToExisting && mapTo !== name) {
const mappedTo = find(existingParams, { name: mapTo });
if (mappedTo) {
// just being safe
param = mappedTo;
}
// static type is different since it's fed param.normalizedValue
}
else if (type === MappingType.StaticValue) {
param = cloneParameter(param).setValue(mapping.value);
}
let value = Parameter.getExecutionValue(param);
// in case of dynamic value display the name instead of value
if (param.hasDynamicValue) {
value = param.normalizedValue.name;
}
return this.getStringValue(value);
}
static getSourceTypeLabel({ type, mapTo }: any) {
switch (type) {
case MappingType.DashboardAddNew:
case MappingType.DashboardMapToExisting:
return (<Fragment>
Dashboard <Tag className="tag">{mapTo}</Tag>
</Fragment>);
case MappingType.WidgetLevel:
return "Widget parameter";
case MappingType.StaticValue:
return "Static value";
default:
return ""; // won't happen (typescript-ftw)
}
}
updateParamMapping(oldMapping: any, newMapping: any) {
const mappings = [...this.props.mappings];
const index = findIndex(mappings, oldMapping);
if (index >= 0) {
// This should be the only possible case, but need to handle `else` too
// @ts-expect-error ts-migrate(2322) FIXME: Type 'any' is not assignable to type 'never'.
mappings[index] = newMapping;
}
else {
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'any' is not assignable to parame... Remove this comment to see the full error message
mappings.push(newMapping);
}
this.props.onChange(mappings);
}
render() {
const { existingParams } = this.props; // eslint-disable-line react/prop-types
const dataSource = this.props.mappings.map(mapping => ({ mapping }));
return (<div className="parameters-mapping-list">
<Table dataSource={dataSource} size="middle" pagination={false} rowKey={(record, idx) => `row${idx}`}>
<Table.Column title="Title" dataIndex="mapping" key="title" render={mapping => (<TitleEditor existingParams={existingParams} mapping={mapping} onChange={newMapping => this.updateParamMapping(mapping, newMapping)}/>)}/>
<Table.Column title="Keyword" dataIndex="mapping" key="keyword" className="keyword" render={mapping => <code>{`{{ ${mapping.name} }}`}</code>}/>
<Table.Column title="Default Value" dataIndex="mapping" key="value" render={mapping => (this.constructor as any).getDefaultValue(mapping, this.props.existingParams)}/>
<Table.Column title="Value Source" dataIndex="mapping" key="source" render={mapping => {
const existingParamsNames = existingParams
.filter(({ type }) => type === mapping.param.type) // exclude mismatching param types
.map(({ name }) => name); // keep names only
return (<Fragment>
{(this.constructor as any).getSourceTypeLabel(mapping)}{" "}
<MappingEditor mapping={mapping} existingParamNames={existingParamsNames} onChange={(oldMapping, newMapping) => this.updateParamMapping(oldMapping, newMapping)}/>
</Fragment>);
}}/>
</Table>
</div>);
}
}

View File

@@ -1,199 +0,0 @@
import { isEqual, isEmpty } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Select from "antd/lib/select";
import Input from "antd/lib/input";
import InputNumber from "antd/lib/input-number";
import DateParameter from "@/components/dynamic-parameters/DateParameter";
import DateRangeParameter from "@/components/dynamic-parameters/DateRangeParameter";
import QueryBasedParameterInput from "./QueryBasedParameterInput";
import "./ParameterValueInput.less";
const { Option } = Select;
const multipleValuesProps = {
maxTagCount: 3,
maxTagTextLength: 10,
maxTagPlaceholder: num => `+${num.length} more`,
};
class ParameterValueInput extends React.Component {
static propTypes = {
type: PropTypes.string,
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
enumOptions: PropTypes.string,
queryId: PropTypes.number,
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
onSelect: PropTypes.func,
className: PropTypes.string,
};
static defaultProps = {
type: "text",
value: null,
enumOptions: "",
queryId: null,
parameter: null,
onSelect: () => {},
className: "",
};
constructor(props) {
super(props);
this.state = {
value: props.parameter.hasPendingValue ? props.parameter.pendingValue : props.value,
isDirty: props.parameter.hasPendingValue,
};
}
componentDidUpdate = prevProps => {
const { value, parameter } = this.props;
// if value prop updated, reset dirty state
if (prevProps.value !== value || prevProps.parameter !== parameter) {
this.setState({
value: parameter.hasPendingValue ? parameter.pendingValue : value,
isDirty: parameter.hasPendingValue,
});
}
};
onSelect = value => {
const isDirty = !isEqual(value, this.props.value);
this.setState({ value, isDirty });
this.props.onSelect(value, isDirty);
};
renderDateParameter() {
const { type, parameter } = this.props;
const { value } = this.state;
return (
<DateParameter
type={type}
className={this.props.className}
value={value}
parameter={parameter}
onSelect={this.onSelect}
/>
);
}
renderDateRangeParameter() {
const { type, parameter } = this.props;
const { value } = this.state;
return (
<DateRangeParameter
type={type}
className={this.props.className}
value={value}
parameter={parameter}
onSelect={this.onSelect}
/>
);
}
renderEnumInput() {
const { enumOptions, parameter } = this.props;
const { value } = this.state;
const enumOptionsArray = enumOptions.split("\n").filter(v => v !== "");
// Antd Select doesn't handle null in multiple mode
const normalize = val => (parameter.multiValuesOptions && val === null ? [] : val);
return (
<Select
className={this.props.className}
mode={parameter.multiValuesOptions ? "multiple" : "default"}
optionFilterProp="children"
value={normalize(value)}
onChange={this.onSelect}
dropdownMatchSelectWidth={false}
showSearch
showArrow
style={{ minWidth: 60 }}
notFoundContent={isEmpty(enumOptionsArray) ? "No options available" : null}
{...multipleValuesProps}>
{enumOptionsArray.map(option => (
<Option key={option} value={option}>
{option}
</Option>
))}
</Select>
);
}
renderQueryBasedInput() {
const { queryId, parameter } = this.props;
const { value } = this.state;
return (
<QueryBasedParameterInput
className={this.props.className}
mode={parameter.multiValuesOptions ? "multiple" : "default"}
optionFilterProp="children"
parameter={parameter}
value={value}
queryId={queryId}
onSelect={this.onSelect}
style={{ minWidth: 60 }}
{...multipleValuesProps}
/>
);
}
renderNumberInput() {
const { className } = this.props;
const { value } = this.state;
const normalize = val => (isNaN(val) ? undefined : val);
return (
<InputNumber className={className} value={normalize(value)} onChange={val => this.onSelect(normalize(val))} />
);
}
renderTextInput() {
const { className } = this.props;
const { value } = this.state;
return (
<Input
className={className}
value={value}
data-test="TextParamInput"
onChange={e => this.onSelect(e.target.value)}
/>
);
}
renderInput() {
const { type } = this.props;
switch (type) {
case "datetime-with-seconds":
case "datetime-local":
case "date":
return this.renderDateParameter();
case "datetime-range-with-seconds":
case "datetime-range":
case "date-range":
return this.renderDateRangeParameter();
case "enum":
return this.renderEnumInput();
case "query":
return this.renderQueryBasedInput();
case "number":
return this.renderNumberInput();
default:
return this.renderTextInput();
}
}
render() {
const { isDirty } = this.state;
return (
<div className="parameter-input" data-dirty={isDirty || null} data-test="ParameterValueInput">
{this.renderInput()}
</div>
);
}
}
export default ParameterValueInput;

View File

@@ -0,0 +1,124 @@
import { isEqual, isEmpty, map } from "lodash";
import React from "react";
import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll";
import Input from "antd/lib/input";
import InputNumber from "antd/lib/input-number";
import DateParameter from "@/components/dynamic-parameters/DateParameter";
import DateRangeParameter from "@/components/dynamic-parameters/DateRangeParameter";
import QueryBasedParameterInput from "./QueryBasedParameterInput";
import "./ParameterValueInput.less";
const multipleValuesProps = {
maxTagCount: 3,
maxTagTextLength: 10,
maxTagPlaceholder: (num: any) => `+${num.length} more`,
};
type OwnProps = {
type?: string;
value?: any;
enumOptions?: string;
queryId?: number;
parameter?: any;
onSelect?: (...args: any[]) => any;
className?: string;
};
type State = any;
type Props = OwnProps & typeof ParameterValueInput.defaultProps;
class ParameterValueInput extends React.Component<Props, State> {
static defaultProps = {
type: "text",
value: null,
enumOptions: "",
queryId: null,
parameter: null,
onSelect: () => { },
className: "",
};
constructor(props: Props) {
super(props);
this.state = {
value: (props as any).parameter.hasPendingValue ? (props as any).parameter.pendingValue : (props as any).value,
isDirty: (props as any).parameter.hasPendingValue,
};
}
componentDidUpdate = (prevProps: any) => {
const { value, parameter } = this.props;
// if value prop updated, reset dirty state
if (prevProps.value !== value || prevProps.parameter !== parameter) {
this.setState({
value: (parameter as any).hasPendingValue ? (parameter as any).pendingValue : value,
isDirty: (parameter as any).hasPendingValue,
});
}
};
onSelect = (value: any) => {
const isDirty = !isEqual(value, (this.props as any).value);
this.setState({ value, isDirty });
(this.props as any).onSelect(value, isDirty);
};
renderDateParameter() {
const { type, parameter } = this.props;
const { value } = this.state;
// @ts-expect-error ts-migrate(2322) FIXME: Type '(value: any) => void' is not assignable to t... Remove this comment to see the full error message
return (<DateParameter type={type} className={(this.props as any).className} value={value} parameter={parameter} onSelect={this.onSelect}/>);
}
renderDateRangeParameter() {
const { type, parameter } = this.props;
const { value } = this.state;
// @ts-expect-error ts-migrate(2322) FIXME: Type '(value: any) => void' is not assignable to t... Remove this comment to see the full error message
return (<DateRangeParameter type={type} className={(this.props as any).className} value={value} parameter={parameter} onSelect={this.onSelect}/>);
}
renderEnumInput() {
const { enumOptions, parameter } = this.props;
const { value } = this.state;
const enumOptionsArray = (enumOptions as any).split("\n").filter((v: any) => v !== "");
// Antd Select doesn't handle null in multiple mode
const normalize = (val: any) => (parameter as any).multiValuesOptions && val === null ? [] : val;
// @ts-expect-error ts-migrate(2322) FIXME: Type '"multiple" | "default"' is not assignable to... Remove this comment to see the full error message
return (<SelectWithVirtualScroll className={(this.props as any).className} mode={(parameter as any).multiValuesOptions ? "multiple" : "default"} optionFilterProp="children" value={normalize(value)} onChange={this.onSelect} options={map(enumOptionsArray, opt => ({ label: String(opt), value: opt }))} showSearch showArrow notFoundContent={isEmpty(enumOptionsArray) ? "No options available" : null} {...multipleValuesProps}/>);
}
renderQueryBasedInput() {
const { queryId, parameter } = this.props;
const { value } = this.state;
// @ts-expect-error ts-migrate(2322) FIXME: Type 'any' is not assignable to type 'never'.
return (<QueryBasedParameterInput className={(this.props as any).className} mode={(parameter as any).multiValuesOptions ? "multiple" : "default"} optionFilterProp="children" parameter={parameter} value={value} queryId={queryId} onSelect={this.onSelect} style={{ minWidth: 60 }} {...multipleValuesProps}/>);
}
renderNumberInput() {
const { className } = this.props;
const { value } = this.state;
const normalize = (val: any) => isNaN(val) ? undefined : val;
return (<InputNumber className={className} value={normalize(value)} onChange={val => this.onSelect(normalize(val))}/>);
}
renderTextInput() {
const { className } = this.props;
const { value } = this.state;
return (<Input className={className} value={value} data-test="TextParamInput" onChange={e => this.onSelect(e.target.value)}/>);
}
renderInput() {
const { type } = this.props;
switch (type) {
case "datetime-with-seconds":
case "datetime-local":
case "date":
return this.renderDateParameter();
case "datetime-range-with-seconds":
case "datetime-range":
case "date-range":
return this.renderDateRangeParameter();
case "enum":
return this.renderEnumInput();
case "query":
return this.renderQueryBasedInput();
case "number":
return this.renderNumberInput();
default:
return this.renderTextInput();
}
}
render() {
const { isDirty } = this.state;
return (<div className="parameter-input" data-dirty={isDirty || null} data-test="ParameterValueInput">
{this.renderInput()}
</div>);
}
}
export default ParameterValueInput;

View File

@@ -1,9 +1,8 @@
import { size, filter, forEach, extend } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import { SortableContainer, SortableElement, DragHandle } from "@redash/viz/lib/components/sortable";
import location from "@/services/location";
import { Parameter, createParameter } from "@/services/parameters";
import { createParameter } from "@/services/parameters";
import ParameterApplyButton from "@/components/ParameterApplyButton";
import ParameterValueInput from "@/components/ParameterValueInput";
import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
@@ -11,24 +10,28 @@ import { toHuman } from "@/lib/utils";
import "./Parameters.less";
function updateUrl(parameters) {
function updateUrl(parameters: any) {
const params = extend({}, location.search);
parameters.forEach(param => {
parameters.forEach((param: any) => {
extend(params, param.toUrlParams());
});
location.setSearch(params, true);
}
export default class Parameters extends React.Component {
static propTypes = {
parameters: PropTypes.arrayOf(PropTypes.instanceOf(Parameter)),
editable: PropTypes.bool,
disableUrlUpdate: PropTypes.bool,
onValuesChange: PropTypes.func,
onPendingValuesChange: PropTypes.func,
onParametersEdit: PropTypes.func,
};
type OwnProps = {
parameters?: any[]; // TODO: PropTypes.instanceOf(Parameter)
editable?: boolean;
disableUrlUpdate?: boolean;
onValuesChange?: (...args: any[]) => any;
onPendingValuesChange?: (...args: any[]) => any;
onParametersEdit?: (...args: any[]) => any;
};
type State = any;
type Props = OwnProps & typeof Parameters.defaultProps;
export default class Parameters extends React.Component<Props, State> {
static defaultProps = {
parameters: [],
editable: false,
@@ -38,7 +41,9 @@ export default class Parameters extends React.Component {
onParametersEdit: () => {},
};
constructor(props) {
onBeforeSortStart: any;
constructor(props: Props) {
super(props);
const { parameters } = props;
this.state = { parameters };
@@ -47,7 +52,7 @@ export default class Parameters extends React.Component {
}
}
componentDidUpdate = prevProps => {
componentDidUpdate = (prevProps: any) => {
const { parameters, disableUrlUpdate } = this.props;
const parametersChanged = prevProps.parameters !== parameters;
const disableUrlUpdateChanged = prevProps.disableUrlUpdate !== disableUrlUpdate;
@@ -59,7 +64,7 @@ export default class Parameters extends React.Component {
}
};
handleKeyDown = e => {
handleKeyDown = (e: any) => {
// Cmd/Ctrl/Alt + Enter
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey || e.altKey)) {
e.stopPropagation();
@@ -67,9 +72,11 @@ export default class Parameters extends React.Component {
}
};
setPendingValue = (param, value, isDirty) => {
setPendingValue = (param: any, value: any, isDirty: any) => {
const { onPendingValuesChange } = this.props;
this.setState(({ parameters }) => {
this.setState(({
parameters
}: any) => {
if (isDirty) {
param.setPendingValue(value);
} else {
@@ -80,10 +87,15 @@ export default class Parameters extends React.Component {
});
};
moveParameter = ({ oldIndex, newIndex }) => {
moveParameter = ({
oldIndex,
newIndex
}: any) => {
const { onParametersEdit } = this.props;
if (oldIndex !== newIndex) {
this.setState(({ parameters }) => {
this.setState(({
parameters
}: any) => {
parameters.splice(newIndex, 0, parameters.splice(oldIndex, 1)[0]);
onParametersEdit();
return { parameters };
@@ -93,8 +105,10 @@ export default class Parameters extends React.Component {
applyChanges = () => {
const { onValuesChange, disableUrlUpdate } = this.props;
this.setState(({ parameters }) => {
const parametersWithPendingValues = parameters.filter(p => p.hasPendingValue);
this.setState(({
parameters
}: any) => {
const parametersWithPendingValues = parameters.filter((p: any) => p.hasPendingValue);
forEach(parameters, p => p.applyPendingValue());
if (!disableUrlUpdate) {
updateUrl(parameters);
@@ -104,10 +118,12 @@ export default class Parameters extends React.Component {
});
};
showParameterSettings = (parameter, index) => {
showParameterSettings = (parameter: any, index: any) => {
const { onParametersEdit } = this.props;
EditParameterSettingsDialog.showModal({ parameter }).onClose(updated => {
this.setState(({ parameters }) => {
EditParameterSettingsDialog.showModal({ parameter }).onClose((updated: any) => {
this.setState(({
parameters
}: any) => {
const updatedParameter = extend(parameter, updated);
parameters[index] = createParameter(updatedParameter, updatedParameter.parentQueryId);
onParametersEdit();
@@ -116,7 +132,7 @@ export default class Parameters extends React.Component {
});
};
renderParameter(param, index) {
renderParameter(param: any, index: any) {
const { editable } = this.props;
return (
<div key={param.name} className="di-block" data-test={`ParameterName-${param.name}`}>
@@ -133,12 +149,18 @@ export default class Parameters extends React.Component {
)}
</div>
<ParameterValueInput
// @ts-expect-error ts-migrate(2322) FIXME: Type 'any' is not assignable to type 'never'.
type={param.type}
// @ts-expect-error ts-migrate(2322) FIXME: Type 'any' is not assignable to type 'never'.
value={param.normalizedValue}
// @ts-expect-error ts-migrate(2322) FIXME: Type 'any' is not assignable to type 'never'.
parameter={param}
// @ts-expect-error ts-migrate(2322) FIXME: Type 'any' is not assignable to type 'never'.
enumOptions={param.enumOptions}
// @ts-expect-error ts-migrate(2322) FIXME: Type 'any' is not assignable to type 'never'.
queryId={param.queryId}
onSelect={(value, isDirty) => this.setPendingValue(param, value, isDirty)}
// @ts-expect-error ts-migrate(2322) FIXME: Type '(value: any, isDirty: any) => void' is not a... Remove this comment to see the full error message
onSelect={(value: any, isDirty: any) => this.setPendingValue(param, value, isDirty)}
/>
</div>
);
@@ -149,6 +171,7 @@ export default class Parameters extends React.Component {
const { editable } = this.props;
const dirtyParamCount = size(filter(parameters, "hasPendingValue"));
return (
// @ts-expect-error ts-migrate(2746) FIXME: This JSX tag's 'children' prop expects a single ch... Remove this comment to see the full error message
<SortableContainer
disabled={!editable}
axis="xy"
@@ -161,7 +184,7 @@ export default class Parameters extends React.Component {
className: "parameter-container",
onKeyDown: dirtyParamCount ? this.handleKeyDown : null,
}}>
{parameters.map((param, index) => (
{parameters.map((param: any, index: any) => (
<SortableElement key={param.name} index={index}>
<div className="parameter-block" data-editable={editable || null}>
{editable && <DragHandle data-test={`DragHandle-${param.name}`} />}

View File

@@ -1,196 +0,0 @@
import React, { useState, useEffect, useCallback } from "react";
import { axios } from "@/services/axios";
import PropTypes from "prop-types";
import { each, debounce, get, find } from "lodash";
import Button from "antd/lib/button";
import List from "antd/lib/list";
import Modal from "antd/lib/modal";
import Select from "antd/lib/select";
import Tag from "antd/lib/tag";
import Tooltip from "antd/lib/tooltip";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import { toHuman } from "@/lib/utils";
import HelpTrigger from "@/components/HelpTrigger";
import { UserPreviewCard } from "@/components/PreviewCard";
import notification from "@/services/notification";
import User from "@/services/user";
import "./index.less";
const { Option } = Select;
const DEBOUNCE_SEARCH_DURATION = 200;
function useGrantees(url) {
const loadGrantees = useCallback(
() =>
axios.get(url).then(data => {
const resultGrantees = [];
each(data, (grantees, accessType) => {
grantees.forEach(grantee => {
grantee.accessType = toHuman(accessType);
resultGrantees.push(grantee);
});
});
return resultGrantees;
}),
[url]
);
const addPermission = useCallback(
(userId, accessType = "modify") =>
axios
.post(url, { access_type: accessType, user_id: userId })
.catch(() => notification.error("Could not grant permission to the user")),
[url]
);
const removePermission = useCallback(
(userId, accessType = "modify") =>
axios
.delete(url, { data: { access_type: accessType, user_id: userId } })
.catch(() => notification.error("Could not remove permission from the user")),
[url]
);
return { loadGrantees, addPermission, removePermission };
}
const searchUsers = searchTerm =>
User.query({ q: searchTerm })
.then(({ results }) => results)
.catch(() => []);
function PermissionsEditorDialogHeader({ context }) {
return (
<>
Manage Permissions
<div className="modal-header-desc">
{`Editing this ${context} is enabled for the users in this list and for admins. `}
<HelpTrigger type="MANAGE_PERMISSIONS" />
</div>
</>
);
}
PermissionsEditorDialogHeader.propTypes = { context: PropTypes.oneOf(["query", "dashboard"]) };
PermissionsEditorDialogHeader.defaultProps = { context: "query" };
function UserSelect({ onSelect, shouldShowUser }) {
const [loadingUsers, setLoadingUsers] = useState(true);
const [users, setUsers] = useState([]);
const [searchTerm, setSearchTerm] = useState("");
const debouncedSearchUsers = useCallback(
debounce(
search =>
searchUsers(search)
.then(setUsers)
.finally(() => setLoadingUsers(false)),
DEBOUNCE_SEARCH_DURATION
),
[]
);
useEffect(() => {
setLoadingUsers(true);
debouncedSearchUsers(searchTerm);
}, [debouncedSearchUsers, searchTerm]);
return (
<Select
className="w-100 m-b-10"
placeholder="Add users..."
showSearch
onSearch={setSearchTerm}
suffixIcon={loadingUsers ? <i className="fa fa-spinner fa-pulse" /> : <i className="fa fa-search" />}
filterOption={false}
notFoundContent={null}
value={undefined}
getPopupContainer={trigger => trigger.parentNode}
onSelect={onSelect}>
{users.filter(shouldShowUser).map(user => (
<Option key={user.id} value={user.id}>
<UserPreviewCard user={user} />
</Option>
))}
</Select>
);
}
UserSelect.propTypes = {
onSelect: PropTypes.func,
shouldShowUser: PropTypes.func,
};
UserSelect.defaultProps = { onSelect: () => {}, shouldShowUser: () => true };
function PermissionsEditorDialog({ dialog, author, context, aclUrl }) {
const [loadingGrantees, setLoadingGrantees] = useState(true);
const [grantees, setGrantees] = useState([]);
const { loadGrantees, addPermission, removePermission } = useGrantees(aclUrl);
const loadUsersWithPermissions = useCallback(() => {
setLoadingGrantees(true);
loadGrantees()
.then(setGrantees)
.catch(() => notification.error("Failed to load grantees list"))
.finally(() => setLoadingGrantees(false));
}, [loadGrantees]);
const userHasPermission = useCallback(
user => user.id === author.id || !!get(find(grantees, { id: user.id }), "accessType"),
[author.id, grantees]
);
useEffect(() => {
loadUsersWithPermissions();
}, [aclUrl, loadUsersWithPermissions]);
return (
<Modal
{...dialog.props}
className="permissions-editor-dialog"
title={<PermissionsEditorDialogHeader context={context} />}
footer={<Button onClick={dialog.dismiss}>Close</Button>}>
<UserSelect
onSelect={userId => addPermission(userId).then(loadUsersWithPermissions)}
shouldShowUser={user => !userHasPermission(user)}
/>
<div className="d-flex align-items-center m-t-5">
<h5 className="flex-fill">Users with permissions</h5>
{loadingGrantees && <i className="fa fa-spinner fa-pulse" />}
</div>
<div className="scrollbox p-5" style={{ maxHeight: "40vh" }}>
<List
size="small"
dataSource={[author, ...grantees]}
renderItem={user => (
<List.Item>
<UserPreviewCard key={user.id} user={user}>
{user.id === author.id ? (
<Tag className="m-0">Author</Tag>
) : (
<Tooltip title="Remove user permissions">
<i
className="fa fa-remove clickable"
onClick={() => removePermission(user.id).then(loadUsersWithPermissions)}
/>
</Tooltip>
)}
</UserPreviewCard>
</List.Item>
)}
/>
</div>
</Modal>
);
}
PermissionsEditorDialog.propTypes = {
dialog: DialogPropType.isRequired,
author: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
context: PropTypes.oneOf(["query", "dashboard"]),
aclUrl: PropTypes.string.isRequired,
};
PermissionsEditorDialog.defaultProps = { context: "query" };
export default wrapDialog(PermissionsEditorDialog);

View File

@@ -0,0 +1,127 @@
import React, { useState, useEffect, useCallback } from "react";
import { axios } from "@/services/axios";
import { each, debounce, get, find } from "lodash";
import Button from "antd/lib/button";
import List from "antd/lib/list";
import Modal from "antd/lib/modal";
import Select from "antd/lib/select";
import Tag from "antd/lib/tag";
import Tooltip from "antd/lib/tooltip";
// @ts-expect-error ts-migrate(6133) FIXME: 'DialogPropType' is declared but its value is neve... Remove this comment to see the full error message
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import { toHuman } from "@/lib/utils";
import HelpTrigger from "@/components/HelpTrigger";
import { UserPreviewCard } from "@/components/PreviewCard";
import notification from "@/services/notification";
import User from "@/services/user";
import "./index.less";
const { Option } = Select;
const DEBOUNCE_SEARCH_DURATION = 200;
function useGrantees(url: any) {
const loadGrantees = useCallback(() => axios.get(url).then(data => {
const resultGrantees: any = [];
each(data, (grantees, accessType) => {
grantees.forEach((grantee: any) => {
grantee.accessType = toHuman(accessType);
resultGrantees.push(grantee);
});
});
return resultGrantees;
}), [url]);
const addPermission = useCallback((userId, accessType = "modify") => axios
.post(url, { access_type: accessType, user_id: userId })
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string' is not assignable to par... Remove this comment to see the full error message
.catch(() => notification.error("Could not grant permission to the user")), [url]);
const removePermission = useCallback((userId, accessType = "modify") => axios
.delete(url, { data: { access_type: accessType, user_id: userId } })
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string' is not assignable to par... Remove this comment to see the full error message
.catch(() => notification.error("Could not remove permission from the user")), [url]);
return { loadGrantees, addPermission, removePermission };
}
const searchUsers = (searchTerm: any) => User.query({ q: searchTerm })
// @ts-expect-error ts-migrate(2339) FIXME: Property 'results' does not exist on type 'AxiosRe... Remove this comment to see the full error message
.then(({ results }) => results)
.catch(() => []);
type OwnPermissionsEditorDialogHeaderProps = {
context?: "query" | "dashboard";
};
type PermissionsEditorDialogHeaderProps = OwnPermissionsEditorDialogHeaderProps & typeof PermissionsEditorDialogHeader.defaultProps;
function PermissionsEditorDialogHeader({ context }: PermissionsEditorDialogHeaderProps) {
return (<>
Manage Permissions
<div className="modal-header-desc">
{`Editing this ${context} is enabled for the users in this list and for admins. `}
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'string' is not assignable to type 'never'. */}
<HelpTrigger type="MANAGE_PERMISSIONS"/>
</div>
</>);
}
PermissionsEditorDialogHeader.defaultProps = { context: "query" };
type OwnUserSelectProps = {
onSelect?: (...args: any[]) => any;
shouldShowUser?: (...args: any[]) => any;
};
type UserSelectProps = OwnUserSelectProps & typeof UserSelect.defaultProps;
function UserSelect({ onSelect, shouldShowUser }: UserSelectProps) {
const [loadingUsers, setLoadingUsers] = useState(true);
const [users, setUsers] = useState([]);
const [searchTerm, setSearchTerm] = useState("");
const debouncedSearchUsers = useCallback(debounce((search: any) => searchUsers(search)
.then(setUsers)
.finally(() => setLoadingUsers(false)), DEBOUNCE_SEARCH_DURATION), []);
useEffect(() => {
setLoadingUsers(true);
debouncedSearchUsers(searchTerm);
}, [debouncedSearchUsers, searchTerm]);
return (<Select className="w-100 m-b-10" placeholder="Add users..." showSearch onSearch={setSearchTerm} suffixIcon={loadingUsers ? <i className="fa fa-spinner fa-pulse"/> : <i className="fa fa-search"/>} filterOption={false} notFoundContent={null} value={undefined} getPopupContainer={trigger => trigger.parentNode} onSelect={onSelect}>
{users.filter(shouldShowUser).map(user => (<Option key={(user as any).id} value={(user as any).id}>
<UserPreviewCard user={user}/>
</Option>))}
</Select>);
}
UserSelect.defaultProps = { onSelect: () => { }, shouldShowUser: () => true };
type OwnPermissionsEditorDialogProps = {
// @ts-expect-error ts-migrate(2749) FIXME: 'DialogPropType' refers to a value, but is being u... Remove this comment to see the full error message
dialog: DialogPropType;
author: any;
context?: "query" | "dashboard";
aclUrl: string;
};
type PermissionsEditorDialogProps = OwnPermissionsEditorDialogProps & typeof PermissionsEditorDialog.defaultProps;
function PermissionsEditorDialog({ dialog, author, context, aclUrl }: PermissionsEditorDialogProps) {
const [loadingGrantees, setLoadingGrantees] = useState(true);
const [grantees, setGrantees] = useState([]);
const { loadGrantees, addPermission, removePermission } = useGrantees(aclUrl);
const loadUsersWithPermissions = useCallback(() => {
setLoadingGrantees(true);
loadGrantees()
.then(setGrantees)
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string' is not assignable to par... Remove this comment to see the full error message
.catch(() => notification.error("Failed to load grantees list"))
.finally(() => setLoadingGrantees(false));
}, [loadGrantees]);
const userHasPermission = useCallback(user => user.id === author.id || !!get(find(grantees, { id: user.id }), "accessType"), [author.id, grantees]);
useEffect(() => {
loadUsersWithPermissions();
}, [aclUrl, loadUsersWithPermissions]);
return (<Modal {...dialog.props} className="permissions-editor-dialog" title={<PermissionsEditorDialogHeader context={context}/>} footer={<Button onClick={dialog.dismiss}>Close</Button>}>
{/* @ts-expect-error ts-migrate(2322) FIXME: Type '(userId: any) => Promise<void>' is not assig... Remove this comment to see the full error message */}
<UserSelect onSelect={(userId: any) => addPermission(userId).then(loadUsersWithPermissions)} shouldShowUser={(user: any) => !userHasPermission(user)}/>
<div className="d-flex align-items-center m-t-5">
<h5 className="flex-fill">Users with permissions</h5>
{loadingGrantees && <i className="fa fa-spinner fa-pulse"/>}
</div>
<div className="scrollbox p-5" style={{ maxHeight: "40vh" }}>
<List size="small" dataSource={[author, ...grantees]} renderItem={user => (<List.Item>
<UserPreviewCard key={user.id} user={user}>
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'Element' is not assignable to type 'null | u... Remove this comment to see the full error message */}
{user.id === author.id ? (<Tag className="m-0">Author</Tag>) : (<Tooltip title="Remove user permissions">
<i className="fa fa-remove clickable" onClick={() => removePermission(user.id).then(loadUsersWithPermissions)}/>
</Tooltip>)}
</UserPreviewCard>
</List.Item>)}/>
</div>
</Modal>);
}
PermissionsEditorDialog.defaultProps = { context: "query" };
export default wrapDialog(PermissionsEditorDialog);

View File

@@ -1,93 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import Link from "@/components/Link";
// PreviewCard
export function PreviewCard({ imageUrl, roundedImage, title, body, children, className, ...props }) {
return (
<div {...props} className={className + " w-100 d-flex align-items-center"}>
<img
src={imageUrl}
width="32"
height="32"
className={classNames({ "profile__image--settings": roundedImage }, "m-r-5")}
alt="Logo/Avatar"
/>
<div className="flex-fill">
<div>{title}</div>
{body && <div className="text-muted">{body}</div>}
</div>
{children}
</div>
);
}
PreviewCard.propTypes = {
imageUrl: PropTypes.string.isRequired,
title: PropTypes.node.isRequired,
body: PropTypes.node,
roundedImage: PropTypes.bool,
className: PropTypes.string,
children: PropTypes.node,
};
PreviewCard.defaultProps = {
body: null,
roundedImage: true,
className: "",
children: null,
};
// UserPreviewCard
export function UserPreviewCard({ user, withLink, children, ...props }) {
const title = withLink ? <Link href={"users/" + user.id}>{user.name}</Link> : user.name;
return (
<PreviewCard {...props} imageUrl={user.profile_image_url} title={title} body={user.email}>
{children}
</PreviewCard>
);
}
UserPreviewCard.propTypes = {
user: PropTypes.shape({
profile_image_url: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
email: PropTypes.string.isRequired,
}).isRequired,
withLink: PropTypes.bool,
children: PropTypes.node,
};
UserPreviewCard.defaultProps = {
withLink: false,
children: null,
};
// DataSourcePreviewCard
export function DataSourcePreviewCard({ dataSource, withLink, children, ...props }) {
const imageUrl = `static/images/db-logos/${dataSource.type}.png`;
const title = withLink ? <Link href={"data_sources/" + dataSource.id}>{dataSource.name}</Link> : dataSource.name;
return (
<PreviewCard {...props} imageUrl={imageUrl} title={title}>
{children}
</PreviewCard>
);
}
DataSourcePreviewCard.propTypes = {
dataSource: PropTypes.shape({
name: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
}).isRequired,
withLink: PropTypes.bool,
children: PropTypes.node,
};
DataSourcePreviewCard.defaultProps = {
withLink: false,
children: null,
};

View File

@@ -0,0 +1,72 @@
import React from "react";
import classNames from "classnames";
import Link from "@/components/Link";
type OwnPreviewCardProps = {
imageUrl: string;
title: React.ReactNode;
body?: React.ReactNode;
roundedImage?: boolean;
className?: string;
children?: React.ReactNode;
};
type PreviewCardProps = OwnPreviewCardProps & typeof PreviewCard.defaultProps;
// PreviewCard
export function PreviewCard({ imageUrl, roundedImage, title, body, children, className, ...props }: PreviewCardProps) {
return (<div {...props} className={className + " w-100 d-flex align-items-center"}>
<img src={imageUrl} width="32" height="32" className={classNames({ "profile__image--settings": roundedImage }, "m-r-5")} alt="Logo/Avatar"/>
<div className="flex-fill">
<div>{title}</div>
{body && <div className="text-muted">{body}</div>}
</div>
{children}
</div>);
}
PreviewCard.defaultProps = {
body: null,
roundedImage: true,
className: "",
children: null,
};
type OwnUserPreviewCardProps = {
user: {
profile_image_url: string;
name: string;
email: string;
};
withLink?: boolean;
children?: React.ReactNode;
};
type UserPreviewCardProps = OwnUserPreviewCardProps & typeof UserPreviewCard.defaultProps;
// UserPreviewCard
export function UserPreviewCard({ user, withLink, children, ...props }: UserPreviewCardProps) {
const title = withLink ? <Link href={"users/" + (user as any).id}>{user.name}</Link> : user.name;
// @ts-expect-error ts-migrate(2322) FIXME: Type 'string' is not assignable to type 'null | un... Remove this comment to see the full error message
return (<PreviewCard {...props} imageUrl={user.profile_image_url} title={title} body={user.email}>
{children}
</PreviewCard>);
}
UserPreviewCard.defaultProps = {
withLink: false,
children: null,
};
type OwnDataSourcePreviewCardProps = {
dataSource: {
name: string;
type: string;
};
withLink?: boolean;
children?: React.ReactNode;
};
type DataSourcePreviewCardProps = OwnDataSourcePreviewCardProps & typeof DataSourcePreviewCard.defaultProps;
// DataSourcePreviewCard
export function DataSourcePreviewCard({ dataSource, withLink, children, ...props }: DataSourcePreviewCardProps) {
const imageUrl = `static/images/db-logos/${dataSource.type}.png`;
const title = withLink ? <Link href={"data_sources/" + (dataSource as any).id}>{dataSource.name}</Link> : dataSource.name;
return (<PreviewCard {...props} imageUrl={imageUrl} title={title}>
{children}
</PreviewCard>);
}
DataSourcePreviewCard.defaultProps = {
withLink: false,
children: null,
};

View File

@@ -1,108 +0,0 @@
import { find, isArray, get, first, map, intersection, isEqual, isEmpty } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Select from "antd/lib/select";
const { Option } = Select;
export default class QueryBasedParameterInput extends React.Component {
static propTypes = {
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
mode: PropTypes.oneOf(["default", "multiple"]),
queryId: PropTypes.number,
onSelect: PropTypes.func,
className: PropTypes.string,
};
static defaultProps = {
value: null,
mode: "default",
parameter: null,
queryId: null,
onSelect: () => {},
className: "",
};
constructor(props) {
super(props);
this.state = {
options: [],
value: null,
loading: false,
};
}
componentDidMount() {
this._loadOptions(this.props.queryId);
}
componentDidUpdate(prevProps) {
if (this.props.queryId !== prevProps.queryId) {
this._loadOptions(this.props.queryId);
}
if (this.props.value !== prevProps.value) {
this.setValue(this.props.value);
}
}
setValue(value) {
const { options } = this.state;
if (this.props.mode === "multiple") {
value = isArray(value) ? value : [value];
const optionValues = map(options, option => option.value);
const validValues = intersection(value, optionValues);
this.setState({ value: validValues });
return validValues;
}
const found = find(options, option => option.value === this.props.value) !== undefined;
value = found ? value : get(first(options), "value");
this.setState({ value });
return value;
}
async _loadOptions(queryId) {
if (queryId && queryId !== this.state.queryId) {
this.setState({ loading: true });
const options = await this.props.parameter.loadDropdownValues();
// stale queryId check
if (this.props.queryId === queryId) {
this.setState({ options, loading: false }, () => {
const updatedValue = this.setValue(this.props.value);
if (!isEqual(updatedValue, this.props.value)) {
this.props.onSelect(updatedValue);
}
});
}
}
}
render() {
const { className, value, mode, onSelect, ...otherProps } = this.props;
const { loading, options } = this.state;
return (
<span>
<Select
className={className}
disabled={loading}
loading={loading}
mode={mode}
value={this.state.value}
onChange={onSelect}
dropdownMatchSelectWidth={false}
optionFilterProp="children"
showSearch
showArrow
notFoundContent={isEmpty(options) ? "No options available" : null}
{...otherProps}>
{options.map(option => (
<Option value={option.value} key={option.value}>
{option.name}
</Option>
))}
</Select>
</span>
);
}
}

View File

@@ -0,0 +1,79 @@
import { find, isArray, get, first, map, intersection, isEqual, isEmpty } from "lodash";
import React from "react";
import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll";
type OwnProps = {
parameter?: any;
value?: any;
mode?: "default" | "multiple";
queryId?: number;
onSelect?: (...args: any[]) => any;
className?: string;
};
type State = any;
type Props = OwnProps & typeof QueryBasedParameterInput.defaultProps;
export default class QueryBasedParameterInput extends React.Component<Props, State> {
static defaultProps = {
value: null,
mode: "default",
parameter: null,
queryId: null,
onSelect: () => { },
className: "",
};
constructor(props: Props) {
super(props);
this.state = {
options: [],
value: null,
loading: false,
};
}
componentDidMount() {
this._loadOptions((this.props as any).queryId);
}
componentDidUpdate(prevProps: Props) {
if ((this.props as any).queryId !== (prevProps as any).queryId) {
this._loadOptions((this.props as any).queryId);
}
if ((this.props as any).value !== (prevProps as any).value) {
this.setValue((this.props as any).value);
}
}
setValue(value: any) {
const { options } = this.state;
if ((this.props as any).mode === "multiple") {
value = isArray(value) ? value : [value];
const optionValues = map(options, option => option.value);
const validValues = intersection(value, optionValues);
this.setState({ value: validValues });
return validValues;
}
const found = find(options, option => option.value === (this.props as any).value) !== undefined;
value = found ? value : get(first(options), "value");
this.setState({ value });
return value;
}
async _loadOptions(queryId: any) {
if (queryId && queryId !== this.state.queryId) {
this.setState({ loading: true });
const options = await (this.props as any).parameter.loadDropdownValues();
// stale queryId check
if ((this.props as any).queryId === queryId) {
this.setState({ options, loading: false }, () => {
const updatedValue = this.setValue((this.props as any).value);
if (!isEqual(updatedValue, (this.props as any).value)) {
(this.props as any).onSelect(updatedValue);
}
});
}
}
}
render() {
// @ts-expect-error ts-migrate(2700) FIXME: Rest types may only be created from object types.
const { className, mode, onSelect, queryId, value, ...otherProps } = this.props;
const { loading, options } = this.state;
return (<span>
<SelectWithVirtualScroll className={className} disabled={loading} loading={loading} mode={mode} value={this.state.value} onChange={onSelect} options={map(options, ({ value, name }) => ({ label: String(name), value }))} optionFilterProp="children" showSearch showArrow notFoundContent={isEmpty(options) ? "No options available" : null} {...otherProps}/>
</span>);
}
}

View File

@@ -1,42 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import { VisualizationType } from "@redash/viz/lib";
import Link from "@/components/Link";
import VisualizationName from "@/components/visualizations/VisualizationName";
import "./QueryLink.less";
function QueryLink({ query, visualization, readOnly }) {
const getUrl = () => {
let hash = null;
if (visualization) {
if (visualization.type === "TABLE") {
// link to hard-coded table tab instead of the (hidden) visualization tab
hash = "table";
} else {
hash = visualization.id;
}
}
return query.getUrl(false, hash);
};
return (
<Link href={readOnly ? null : getUrl()} className="query-link">
<VisualizationName visualization={visualization} /> <span>{query.name}</span>
</Link>
);
}
QueryLink.propTypes = {
query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
visualization: VisualizationType,
readOnly: PropTypes.bool,
};
QueryLink.defaultProps = {
visualization: null,
readOnly: false,
};
export default QueryLink;

View File

@@ -0,0 +1,34 @@
import React from "react";
import { VisualizationType } from "@redash/viz/lib";
import Link from "@/components/Link";
import VisualizationName from "@/components/visualizations/VisualizationName";
import "./QueryLink.less";
type OwnProps = {
query: any;
visualization?: VisualizationType;
readOnly?: boolean;
};
type Props = OwnProps & typeof QueryLink.defaultProps;
function QueryLink({ query, visualization, readOnly }: Props) {
const getUrl = () => {
let hash = null;
if (visualization) {
if ((visualization as any).type === "TABLE") {
// link to hard-coded table tab instead of the (hidden) visualization tab
hash = "table";
}
else {
hash = (visualization as any).id;
}
}
return (query as any).getUrl(false, hash);
};
return (<Link href={readOnly ? null : getUrl()} className="query-link">
<VisualizationName visualization={visualization}/> <span>{(query as any).name}</span>
</Link>);
}
QueryLink.defaultProps = {
visualization: null,
readOnly: false,
};
export default QueryLink;

View File

@@ -1,159 +0,0 @@
import { find } from "lodash";
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import Input from "antd/lib/input";
import Select from "antd/lib/select";
import { Query } from "@/services/query";
import notification from "@/services/notification";
import { QueryTagsControl } from "@/components/tags-control/TagsControl";
import useSearchResults from "@/lib/hooks/useSearchResults";
const { Option } = Select;
function search(term) {
if (term === null) {
return Promise.resolve(null);
}
// get recent
if (!term) {
return Query.recent().then(results => results.filter(item => !item.is_draft)); // filter out draft
}
// search by query
return Query.query({ q: term }).then(({ results }) => results);
}
export default function QuerySelector(props) {
const [searchTerm, setSearchTerm] = useState("");
const [selectedQuery, setSelectedQuery] = useState();
const [doSearch, searchResults, searching] = useSearchResults(search, { initialResults: [] });
const placeholder = "Search a query by name";
const clearIcon = <i className="fa fa-times hide-in-percy" onClick={() => selectQuery(null)} />;
const spinIcon = <i className={cx("fa fa-spinner fa-pulse hide-in-percy", { hidden: !searching })} />;
useEffect(() => {
doSearch(searchTerm);
}, [doSearch, searchTerm]);
// set selected from prop
useEffect(() => {
if (props.selectedQuery) {
setSelectedQuery(props.selectedQuery);
}
}, [props.selectedQuery]);
function selectQuery(queryId) {
let query = null;
if (queryId) {
query = find(searchResults, { id: queryId });
if (!query) {
// shouldn't happen
notification.error("Something went wrong...", "Couldn't select query");
}
}
setSearchTerm(query ? null : ""); // empty string triggers recent fetch
setSelectedQuery(query);
props.onChange(query);
}
function renderResults() {
if (!searchResults.length) {
return <div className="text-muted">No results matching search term.</div>;
}
return (
<div className="list-group">
{searchResults.map(q => (
<a
className={cx("query-selector-result", "list-group-item", { inactive: q.is_draft })}
key={q.id}
onClick={() => selectQuery(q.id)}
data-test={`QueryId${q.id}`}>
{q.name} <QueryTagsControl isDraft={q.is_draft} tags={q.tags} className="inline-tags-control" />
</a>
))}
</div>
);
}
if (props.disabled) {
return <Input value={selectedQuery && selectedQuery.name} placeholder={placeholder} disabled />;
}
if (props.type === "select") {
const suffixIcon = selectedQuery ? clearIcon : null;
const value = selectedQuery ? selectedQuery.name : searchTerm;
return (
<Select
showSearch
dropdownMatchSelectWidth={false}
placeholder={placeholder}
value={value || undefined} // undefined for the placeholder to show
onSearch={setSearchTerm}
onChange={selectQuery}
suffixIcon={searching ? spinIcon : suffixIcon}
notFoundContent={null}
filterOption={false}
defaultActiveFirstOption={false}
className={props.className}
data-test="QuerySelector">
{searchResults &&
searchResults.map(q => {
const disabled = q.is_draft;
return (
<Option
value={q.id}
key={q.id}
disabled={disabled}
className="query-selector-result"
data-test={`QueryId${q.id}`}>
{q.name}{" "}
<QueryTagsControl
isDraft={q.is_draft}
tags={q.tags}
className={cx("inline-tags-control", { disabled })}
/>
</Option>
);
})}
</Select>
);
}
return (
<span data-test="QuerySelector">
{selectedQuery ? (
<Input value={selectedQuery.name} suffix={clearIcon} readOnly />
) : (
<Input
placeholder={placeholder}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
suffix={spinIcon}
/>
)}
<div className="scrollbox" style={{ maxHeight: "50vh", marginTop: 15 }}>
{searchResults && renderResults()}
</div>
</span>
);
}
QuerySelector.propTypes = {
onChange: PropTypes.func.isRequired,
selectedQuery: PropTypes.object, // eslint-disable-line react/forbid-prop-types
type: PropTypes.oneOf(["select", "default"]),
className: PropTypes.string,
disabled: PropTypes.bool,
};
QuerySelector.defaultProps = {
selectedQuery: null,
type: "default",
className: null,
disabled: false,
};

View File

@@ -0,0 +1,110 @@
import { find } from "lodash";
import React, { useState, useEffect } from "react";
import cx from "classnames";
import Input from "antd/lib/input";
import Select from "antd/lib/select";
import { Query } from "@/services/query";
import notification from "@/services/notification";
import { QueryTagsControl } from "@/components/tags-control/TagsControl";
import useSearchResults from "@/lib/hooks/useSearchResults";
const { Option } = Select;
function search(term: any) {
if (term === null) {
return Promise.resolve(null);
}
// get recent
if (!term) {
return (Query as any).recent().then((results: any) => results.filter((item: any) => !item.is_draft)); // filter out draft
}
// search by query
return (Query as any).query({ q: term }).then(({ results }: any) => results);
}
type OwnProps = {
onChange: (...args: any[]) => any;
selectedQuery?: any;
type?: "select" | "default";
className?: string;
disabled?: boolean;
};
type Props = OwnProps & typeof QuerySelector.defaultProps;
export default function QuerySelector(props: Props) {
const [searchTerm, setSearchTerm] = useState("");
const [selectedQuery, setSelectedQuery] = useState();
// @ts-expect-error ts-migrate(2322) FIXME: Type 'never[]' is not assignable to type 'null | u... Remove this comment to see the full error message
const [doSearch, searchResults, searching] = useSearchResults(search, { initialResults: [] });
const placeholder = "Search a query by name";
const clearIcon = <i className="fa fa-times hide-in-percy" onClick={() => selectQuery(null)}/>;
const spinIcon = <i className={cx("fa fa-spinner fa-pulse hide-in-percy", { hidden: !searching })}/>;
useEffect(() => {
// @ts-expect-error ts-migrate(2349) FIXME: This expression is not callable.
doSearch(searchTerm);
}, [doSearch, searchTerm]);
// set selected from prop
useEffect(() => {
if ((props as any).selectedQuery) {
setSelectedQuery((props as any).selectedQuery);
}
}, [props]);
function selectQuery(queryId: any) {
let query = null;
if (queryId) {
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
query = find(searchResults, { id: queryId });
if (!query) {
// shouldn't happen
// @ts-expect-error ts-migrate(2554) FIXME: Expected 1 arguments, but got 2.
notification.error("Something went wrong...", "Couldn't select query");
}
}
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | null' is not assignable... Remove this comment to see the full error message
setSearchTerm(query ? null : ""); // empty string triggers recent fetch
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'unknown' is not assignable to pa... Remove this comment to see the full error message
setSelectedQuery(query);
(props as any).onChange(query);
}
function renderResults() {
if (!(searchResults as any).length) {
return <div className="text-muted">No results matching search term.</div>;
}
return (<div className="list-group">
{(searchResults as any).map((q: any) => <a className={cx("query-selector-result", "list-group-item", { inactive: q.is_draft })} key={q.id} onClick={() => selectQuery(q.id)} data-test={`QueryId${q.id}`}>
{/* @ts-expect-error ts-migrate(2322) FIXME: Type '{ isDraft: any; tags: any; className: string... Remove this comment to see the full error message */}
{q.name} <QueryTagsControl isDraft={q.is_draft} tags={q.tags} className="inline-tags-control"/>
</a>)}
</div>);
}
if ((props as any).disabled) {
// @ts-expect-error ts-migrate(2532) FIXME: Object is possibly 'undefined'.
return <Input value={selectedQuery && selectedQuery.name} placeholder={placeholder} disabled/>;
}
if ((props as any).type === "select") {
const suffixIcon = selectedQuery ? clearIcon : null;
// @ts-expect-error ts-migrate(2532) FIXME: Object is possibly 'undefined'.
const value = selectedQuery ? selectedQuery.name : searchTerm;
return (<Select showSearch dropdownMatchSelectWidth={false} placeholder={placeholder} value={value || undefined} // undefined for the placeholder to show
onSearch={setSearchTerm} onChange={selectQuery} suffixIcon={searching ? spinIcon : suffixIcon} notFoundContent={null} filterOption={false} defaultActiveFirstOption={false} className={(props as any).className} data-test="QuerySelector">
{searchResults &&
(searchResults as any).map((q: any) => {
const disabled = q.is_draft;
return (<Option value={q.id} key={q.id} disabled={disabled} className="query-selector-result" data-test={`QueryId${q.id}`}>
{q.name}{" "}
{/* @ts-expect-error ts-migrate(2322) FIXME: Type '{ isDraft: any; tags: any; className: string... Remove this comment to see the full error message */}
<QueryTagsControl isDraft={q.is_draft} tags={q.tags} className={cx("inline-tags-control", { disabled })}/>
</Option>);
})}
</Select>);
}
return (<span data-test="QuerySelector">
{/* @ts-expect-error ts-migrate(2532) FIXME: Object is possibly 'undefined'. */}
{selectedQuery ? (<Input value={selectedQuery.name} suffix={clearIcon} readOnly/>) : (<Input placeholder={placeholder} value={searchTerm} onChange={e => setSearchTerm(e.target.value)} suffix={spinIcon}/>)}
<div className="scrollbox" style={{ maxHeight: "50vh", marginTop: 15 }}>
{searchResults && renderResults()}
</div>
</span>);
}
QuerySelector.defaultProps = {
selectedQuery: null,
type: "default",
className: null,
disabled: false,
};

View File

@@ -1,163 +0,0 @@
import d3 from "d3";
import React, { useRef, useMemo, useCallback, useState, useEffect } from "react";
import PropTypes from "prop-types";
import { Resizable as ReactResizable } from "react-resizable";
import KeyboardShortcuts from "@/services/KeyboardShortcuts";
import "./index.less";
export default function Resizable({ toggleShortcut, direction, sizeAttribute, children }) {
const [size, setSize] = useState(0);
const elementRef = useRef();
const wasUsingTouchEventsRef = useRef(false);
const wasResizedRef = useRef(false);
const sizeProp = direction === "horizontal" ? "width" : "height";
sizeAttribute = sizeAttribute || sizeProp;
const getElementSize = useCallback(() => {
if (!elementRef.current) {
return 0;
}
return Math.floor(elementRef.current.getBoundingClientRect()[sizeProp]);
}, [sizeProp]);
const savedSize = useRef(null);
const toggle = useCallback(() => {
if (!elementRef.current) {
return;
}
const element = d3.select(elementRef.current);
let targetSize;
if (savedSize.current === null) {
targetSize = "0px";
savedSize.current = `${getElementSize()}px`;
} else {
targetSize = savedSize.current;
savedSize.current = null;
}
element
.style(sizeAttribute, savedSize.current || "0px")
.transition()
.duration(200)
.ease("swing")
.style(sizeAttribute, targetSize);
// update state to new element's size
setSize(parseInt(targetSize) || 0);
}, [getElementSize, sizeAttribute]);
const resizeHandle = useMemo(
() => (
<span
className={`react-resizable-handle react-resizable-handle-${direction}`}
onClick={() => {
// On desktops resize uses `mousedown`/`mousemove`/`mouseup` events, and there is a conflict
// with this `click` handler: after user releases mouse - this handler will be executed.
// So we use `wasResized` flag to check if there was actual resize or user just pressed and released
// left mouse button (see also resize event handlers where ths flag is set).
// On mobile devices `touchstart`/`touchend` events wll be used, so it's safe to just execute this handler.
// To detect which set of events was actually used during particular resize operation, we pass
// `onMouseDown` handler to draggable core and check event type there (see also that handler's code).
if (wasUsingTouchEventsRef.current || !wasResizedRef.current) {
toggle();
}
wasUsingTouchEventsRef.current = false;
wasResizedRef.current = false;
}}
/>
),
[direction, toggle]
);
useEffect(() => {
if (toggleShortcut) {
const shortcuts = {
[toggleShortcut]: toggle,
};
KeyboardShortcuts.bind(shortcuts);
return () => {
KeyboardShortcuts.unbind(shortcuts);
};
}
}, [toggleShortcut, toggle]);
const resizeEventHandlers = useMemo(
() => ({
onResizeStart: () => {
// use element's size as initial value (it will also check constraints set in CSS)
// updated here and in `draggableCore::onMouseDown` handler to ensure that right value will be used
setSize(getElementSize());
},
onResize: (unused, data) => {
// update element directly for better UI responsiveness
d3.select(elementRef.current).style(sizeAttribute, `${data.size[sizeProp]}px`);
setSize(data.size[sizeProp]);
wasResizedRef.current = true;
},
onResizeStop: () => {
if (wasResizedRef.current) {
savedSize.current = null;
}
},
}),
[sizeProp, getElementSize, sizeAttribute]
);
const draggableCoreOptions = useMemo(
() => ({
onMouseDown: e => {
// In some cases this handler is executed twice during the same resize operation - first time
// with `touchstart` event and second time with `mousedown` (probably emulated by browser).
// Therefore we set the flag only when we receive `touchstart` because in ths case it's definitely
// mobile browser (desktop browsers will also send `mousedown` but never `touchstart`).
if (e.type === "touchstart") {
wasUsingTouchEventsRef.current = true;
}
// use element's size as initial value (it will also check constraints set in CSS)
// updated here and in `onResizeStart` handler to ensure that right value will be used
setSize(getElementSize());
},
}),
[getElementSize]
);
if (!children) {
return null;
}
children = React.createElement(children.type, { ...children.props, ref: elementRef });
return (
<ReactResizable
className="resizable-component"
axis={direction === "horizontal" ? "x" : "y"}
resizeHandles={[direction === "horizontal" ? "e" : "s"]}
handle={resizeHandle}
width={direction === "horizontal" ? size : 0}
height={direction === "vertical" ? size : 0}
minConstraints={[0, 0]}
{...resizeEventHandlers}
draggableOpts={draggableCoreOptions}>
{children}
</ReactResizable>
);
}
Resizable.propTypes = {
direction: PropTypes.oneOf(["horizontal", "vertical"]),
sizeAttribute: PropTypes.string,
toggleShortcut: PropTypes.string,
children: PropTypes.element,
};
Resizable.defaultProps = {
direction: "horizontal",
sizeAttribute: null, // "width"/"height" - depending on `direction`
toggleShortcut: null,
children: null,
};

View File

@@ -0,0 +1,128 @@
import d3 from "d3";
import React, { useRef, useMemo, useCallback, useState, useEffect } from "react";
import { Resizable as ReactResizable } from "react-resizable";
import KeyboardShortcuts from "@/services/KeyboardShortcuts";
import "./index.less";
type OwnProps = {
direction?: "horizontal" | "vertical";
sizeAttribute?: string;
toggleShortcut?: string;
children?: React.ReactElement;
};
type Props = OwnProps & typeof Resizable.defaultProps;
export default function Resizable({ toggleShortcut, direction, sizeAttribute, children }: Props) {
const [size, setSize] = useState(0);
const elementRef = useRef();
const wasUsingTouchEventsRef = useRef(false);
const wasResizedRef = useRef(false);
const sizeProp = direction === "horizontal" ? "width" : "height";
sizeAttribute = sizeAttribute || sizeProp;
const getElementSize = useCallback(() => {
if (!elementRef.current) {
return 0;
}
// @ts-expect-error ts-migrate(2532) FIXME: Object is possibly 'undefined'.
return Math.floor(elementRef.current.getBoundingClientRect()[sizeProp]);
}, [sizeProp]);
const savedSize = useRef(null);
const toggle = useCallback(() => {
if (!elementRef.current) {
return;
}
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
const element = d3.select(elementRef.current);
let targetSize;
if (savedSize.current === null) {
targetSize = "0px";
// @ts-expect-error ts-migrate(2322) FIXME: Type 'string' is not assignable to type 'null'.
savedSize.current = `${getElementSize()}px`;
}
else {
targetSize = savedSize.current;
savedSize.current = null;
}
element
.style(sizeAttribute, savedSize.current || "0px")
.transition()
.duration(200)
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string' is not assignable to par... Remove this comment to see the full error message
.ease("swing")
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
.style(sizeAttribute, targetSize);
// update state to new element's size
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | null' is not assignable... Remove this comment to see the full error message
setSize(parseInt(targetSize) || 0);
}, [getElementSize, sizeAttribute]);
const resizeHandle = useMemo(() => (<span className={`react-resizable-handle react-resizable-handle-${direction}`} onClick={() => {
// On desktops resize uses `mousedown`/`mousemove`/`mouseup` events, and there is a conflict
// with this `click` handler: after user releases mouse - this handler will be executed.
// So we use `wasResized` flag to check if there was actual resize or user just pressed and released
// left mouse button (see also resize event handlers where ths flag is set).
// On mobile devices `touchstart`/`touchend` events wll be used, so it's safe to just execute this handler.
// To detect which set of events was actually used during particular resize operation, we pass
// `onMouseDown` handler to draggable core and check event type there (see also that handler's code).
if (wasUsingTouchEventsRef.current || !wasResizedRef.current) {
toggle();
}
wasUsingTouchEventsRef.current = false;
wasResizedRef.current = false;
}}/>), [direction, toggle]);
useEffect(() => {
if (toggleShortcut) {
const shortcuts = {
[toggleShortcut]: toggle,
};
KeyboardShortcuts.bind(shortcuts);
return () => {
KeyboardShortcuts.unbind(shortcuts);
};
}
}, [toggleShortcut, toggle]);
const resizeEventHandlers = useMemo(() => ({
onResizeStart: () => {
// use element's size as initial value (it will also check constraints set in CSS)
// updated here and in `draggableCore::onMouseDown` handler to ensure that right value will be used
setSize(getElementSize());
},
onResize: (unused: any, data: any) => {
// update element directly for better UI responsiveness
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
d3.select(elementRef.current).style(sizeAttribute, `${data.size[sizeProp]}px`);
setSize(data.size[sizeProp]);
wasResizedRef.current = true;
},
onResizeStop: () => {
if (wasResizedRef.current) {
savedSize.current = null;
}
},
}), [sizeProp, getElementSize, sizeAttribute]);
const draggableCoreOptions = useMemo(() => ({
onMouseDown: (e: any) => {
// In some cases this handler is executed twice during the same resize operation - first time
// with `touchstart` event and second time with `mousedown` (probably emulated by browser).
// Therefore we set the flag only when we receive `touchstart` because in ths case it's definitely
// mobile browser (desktop browsers will also send `mousedown` but never `touchstart`).
if (e.type === "touchstart") {
wasUsingTouchEventsRef.current = true;
}
// use element's size as initial value (it will also check constraints set in CSS)
// updated here and in `onResizeStart` handler to ensure that right value will be used
setSize(getElementSize());
},
}), [getElementSize]);
if (!children) {
return null;
}
// @ts-expect-error ts-migrate(2322) FIXME: Type 'CElement<any, Component<any, any, any>>' is ... Remove this comment to see the full error message
children = React.createElement((children as any).type, { ...(children as any).props, ref: elementRef });
return (<ReactResizable className="resizable-component" axis={direction === "horizontal" ? "x" : "y"} resizeHandles={[direction === "horizontal" ? "e" : "s"]} handle={resizeHandle} width={direction === "horizontal" ? size : 0} height={direction === "vertical" ? size : 0} minConstraints={[0, 0]} {...resizeEventHandlers} draggableOpts={draggableCoreOptions}>
{children}
</ReactResizable>);
}
Resizable.defaultProps = {
direction: "horizontal",
sizeAttribute: null,
toggleShortcut: null,
children: null,
};

View File

@@ -1,192 +0,0 @@
import { filter, find, isEmpty, size } from "lodash";
import React, { useState, useCallback, useEffect } from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import Modal from "antd/lib/modal";
import Input from "antd/lib/input";
import List from "antd/lib/list";
import Button from "antd/lib/button";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import BigMessage from "@/components/BigMessage";
import LoadingState from "@/components/items-list/components/LoadingState";
import notification from "@/services/notification";
import useSearchResults from "@/lib/hooks/useSearchResults";
function ItemsList({ items, renderItem, onItemClick }) {
const renderListItem = useCallback(
item => {
const { content, className, isDisabled } = renderItem(item);
return (
<List.Item
className={classNames("p-l-10", "p-r-10", { clickable: !isDisabled, disabled: isDisabled }, className)}
onClick={isDisabled ? null : () => onItemClick(item)}>
{content}
</List.Item>
);
},
[renderItem, onItemClick]
);
return <List size="small" dataSource={items} renderItem={renderListItem} />;
}
ItemsList.propTypes = {
items: PropTypes.array,
renderItem: PropTypes.func,
onItemClick: PropTypes.func,
};
ItemsList.defaultProps = {
items: [],
renderItem: () => {},
onItemClick: () => {},
};
function SelectItemsDialog({
dialog,
dialogTitle,
inputPlaceholder,
itemKey,
renderItem,
renderStagedItem,
searchItems,
selectedItemsTitle,
width,
showCount,
extraFooterContent,
}) {
const [selectedItems, setSelectedItems] = useState([]);
const [search, items, isLoading] = useSearchResults(searchItems, { initialResults: [] });
const hasResults = items.length > 0;
useEffect(() => {
search();
}, [search]);
const isItemSelected = useCallback(
item => {
const key = itemKey(item);
return !!find(selectedItems, i => itemKey(i) === key);
},
[selectedItems, itemKey]
);
const toggleItem = useCallback(
item => {
if (isItemSelected(item)) {
const key = itemKey(item);
setSelectedItems(filter(selectedItems, i => itemKey(i) !== key));
} else {
setSelectedItems([...selectedItems, item]);
}
},
[selectedItems, itemKey, isItemSelected]
);
const save = useCallback(() => {
dialog.close(selectedItems).catch(error => {
if (error) {
notification.error("Failed to save some of selected items.");
}
});
}, [dialog, selectedItems]);
return (
<Modal
{...dialog.props}
className="select-items-dialog"
width={width}
title={dialogTitle}
footer={
<div className="d-flex align-items-center">
<span className="flex-fill m-r-5" style={{ textAlign: "left", color: "rgba(0, 0, 0, 0.5)" }}>
{extraFooterContent}
</span>
<Button {...dialog.props.cancelButtonProps} onClick={dialog.dismiss}>
Cancel
</Button>
<Button
{...dialog.props.okButtonProps}
onClick={save}
disabled={selectedItems.length === 0 || dialog.props.okButtonProps.disabled}
type="primary">
Save
{showCount && !isEmpty(selectedItems) ? ` (${size(selectedItems)})` : null}
</Button>
</div>
}>
<div className="d-flex align-items-center m-b-10">
<div className="flex-fill">
<Input.Search onChange={event => search(event.target.value)} placeholder={inputPlaceholder} autoFocus />
</div>
{renderStagedItem && (
<div className="w-50 m-l-20">
<h5 className="m-0">{selectedItemsTitle}</h5>
</div>
)}
</div>
<div className="d-flex align-items-stretch" style={{ minHeight: "30vh", maxHeight: "50vh" }}>
<div className="flex-fill scrollbox">
{isLoading && <LoadingState className="" />}
{!isLoading && !hasResults && (
<BigMessage icon="fa-search" message="No items match your search." className="" />
)}
{!isLoading && hasResults && (
<ItemsList
items={items}
renderItem={item => renderItem(item, { isSelected: isItemSelected(item) })}
onItemClick={toggleItem}
/>
)}
</div>
{renderStagedItem && (
<div className="w-50 m-l-20 scrollbox">
{selectedItems.length > 0 && (
<ItemsList
items={selectedItems}
renderItem={item => renderStagedItem(item, { isSelected: true })}
onItemClick={toggleItem}
/>
)}
</div>
)}
</div>
</Modal>
);
}
SelectItemsDialog.propTypes = {
dialog: DialogPropType.isRequired,
dialogTitle: PropTypes.string,
inputPlaceholder: PropTypes.string,
selectedItemsTitle: PropTypes.string,
searchItems: PropTypes.func.isRequired, // (searchTerm: string): Promise<Items[]> if `searchTerm === ''` load all
itemKey: PropTypes.func, // (item) => string|number - return key of item (by default `id`)
// left list
// (item, { isSelected }) => {
// content: node, // item contents
// className: string = '', // additional class for item wrapper
// isDisabled: bool = false, // is item clickable or disabled
// }
renderItem: PropTypes.func,
// right list; args/results save as for `renderItem`. if not specified - `renderItem` will be used
renderStagedItem: PropTypes.func,
width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
extraFooterContent: PropTypes.node,
showCount: PropTypes.bool,
};
SelectItemsDialog.defaultProps = {
dialogTitle: "Add Items",
inputPlaceholder: "Search...",
selectedItemsTitle: "Selected items",
itemKey: item => item.id,
renderItem: () => "",
renderStagedItem: null, // hidden by default
width: "80%",
extraFooterContent: null,
showCount: false,
};
export default wrapDialog(SelectItemsDialog);

View File

@@ -0,0 +1,132 @@
import { filter, find, isEmpty, size } from "lodash";
import React, { useState, useCallback, useEffect } from "react";
import classNames from "classnames";
import Modal from "antd/lib/modal";
import Input from "antd/lib/input";
import List from "antd/lib/list";
import Button from "antd/lib/button";
// @ts-expect-error ts-migrate(6133) FIXME: 'DialogPropType' is declared but its value is neve... Remove this comment to see the full error message
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import BigMessage from "@/components/BigMessage";
import LoadingState from "@/components/items-list/components/LoadingState";
import notification from "@/services/notification";
import useSearchResults from "@/lib/hooks/useSearchResults";
type OwnItemsListProps = {
items?: any[];
renderItem?: (...args: any[]) => any;
onItemClick?: (...args: any[]) => any;
};
type ItemsListProps = OwnItemsListProps & typeof ItemsList.defaultProps;
function ItemsList({ items, renderItem, onItemClick }: ItemsListProps) {
const renderListItem = useCallback(item => {
const { content, className, isDisabled } = renderItem(item);
// @ts-expect-error ts-migrate(2322) FIXME: Type '(() => any) | null' is not assignable to typ... Remove this comment to see the full error message
return (<List.Item className={classNames("p-l-10", "p-r-10", { clickable: !isDisabled, disabled: isDisabled }, className)} onClick={isDisabled ? null : () => onItemClick(item)}>
{content}
</List.Item>);
}, [renderItem, onItemClick]);
return <List size="small" dataSource={items} renderItem={renderListItem}/>;
}
ItemsList.defaultProps = {
items: [],
renderItem: () => { },
onItemClick: () => { },
};
type OwnSelectItemsDialogProps = {
// @ts-expect-error ts-migrate(2749) FIXME: 'DialogPropType' refers to a value, but is being u... Remove this comment to see the full error message
dialog: DialogPropType;
dialogTitle?: string;
inputPlaceholder?: string;
selectedItemsTitle?: string;
searchItems: (...args: any[]) => any;
itemKey?: (...args: any[]) => any;
renderItem?: (...args: any[]) => any;
renderStagedItem?: (...args: any[]) => any;
width?: string | number;
extraFooterContent?: React.ReactNode;
showCount?: boolean;
};
type SelectItemsDialogProps = OwnSelectItemsDialogProps & typeof SelectItemsDialog.defaultProps;
function SelectItemsDialog({ dialog, dialogTitle, inputPlaceholder, itemKey, renderItem, renderStagedItem, searchItems, selectedItemsTitle, width, showCount, extraFooterContent, }: SelectItemsDialogProps) {
const [selectedItems, setSelectedItems] = useState([]);
// @ts-expect-error ts-migrate(2322) FIXME: Type 'never[]' is not assignable to type 'null | u... Remove this comment to see the full error message
const [search, items, isLoading] = useSearchResults(searchItems, { initialResults: [] });
const hasResults = (items as any).length > 0;
useEffect(() => {
// @ts-expect-error ts-migrate(2349) FIXME: This expression is not callable.
search();
}, [search]);
const isItemSelected = useCallback(item => {
// @ts-expect-error ts-migrate(2349) FIXME: This expression is not callable.
const key = itemKey(item);
// @ts-expect-error ts-migrate(2349) FIXME: This expression is not callable.
return !!find(selectedItems, i => itemKey(i) === key);
}, [selectedItems, itemKey]);
const toggleItem = useCallback(item => {
if (isItemSelected(item)) {
// @ts-expect-error ts-migrate(2349) FIXME: This expression is not callable.
const key = itemKey(item);
// @ts-expect-error ts-migrate(2349) FIXME: This expression is not callable.
setSelectedItems(filter(selectedItems, i => itemKey(i) !== key));
}
else {
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'any[]' is not assignable to para... Remove this comment to see the full error message
setSelectedItems([...selectedItems, item]);
}
}, [selectedItems, itemKey, isItemSelected]);
const save = useCallback(() => {
(dialog as any).close(selectedItems).catch((error: any) => {
if (error) {
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string' is not assignable to par... Remove this comment to see the full error message
notification.error("Failed to save some of selected items.");
}
});
}, [dialog, selectedItems]);
return (<Modal {...(dialog as any).props} className="select-items-dialog" width={width} title={dialogTitle} footer={<div className="d-flex align-items-center">
<span className="flex-fill m-r-5" style={{ textAlign: "left", color: "rgba(0, 0, 0, 0.5)" }}>
{extraFooterContent}
</span>
<Button {...(dialog as any).props.cancelButtonProps} onClick={(dialog as any).dismiss}>
Cancel
</Button>
<Button {...(dialog as any).props.okButtonProps} onClick={save} disabled={selectedItems.length === 0 || (dialog as any).props.okButtonProps.disabled} type="primary">
Save
{showCount && !isEmpty(selectedItems) ? ` (${size(selectedItems)})` : null}
</Button>
</div>}>
<div className="d-flex align-items-center m-b-10">
<div className="flex-fill">
{/* @ts-expect-error ts-migrate(2349) FIXME: This expression is not callable. */}
<Input.Search onChange={event => search(event.target.value)} placeholder={inputPlaceholder} autoFocus/>
</div>
{renderStagedItem && (<div className="w-50 m-l-20">
<h5 className="m-0">{selectedItemsTitle}</h5>
</div>)}
</div>
<div className="d-flex align-items-stretch" style={{ minHeight: "30vh", maxHeight: "50vh" }}>
<div className="flex-fill scrollbox">
{isLoading && <LoadingState className=""/>}
{!isLoading && !hasResults && (<BigMessage icon="fa-search" message="No items match your search." className=""/>)}
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'boolean | ((searchTerm: any) => void) | null... Remove this comment to see the full error message */}
{!isLoading && hasResults && (<ItemsList items={items} renderItem={(item: any) => renderItem(item, { isSelected: isItemSelected(item) })} onItemClick={toggleItem}/>)}
</div>
{renderStagedItem && (<div className="w-50 m-l-20 scrollbox">
{/* @ts-expect-error ts-migrate(2322) FIXME: Type '(item: any) => any' is not assignable to typ... Remove this comment to see the full error message */}
{selectedItems.length > 0 && (<ItemsList items={selectedItems} renderItem={(item: any) => renderStagedItem(item, { isSelected: true })} onItemClick={toggleItem}/>)}
</div>)}
</div>
</Modal>);
}
SelectItemsDialog.defaultProps = {
dialogTitle: "Add Items",
inputPlaceholder: "Search...",
selectedItemsTitle: "Selected items",
itemKey: (item: any) => item.id,
renderItem: () => "",
renderStagedItem: null,
width: "80%",
extraFooterContent: null,
showCount: false,
};
export default wrapDialog(SelectItemsDialog);

View File

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

View File

@@ -1,39 +0,0 @@
import React from "react";
import Menu from "antd/lib/menu";
import PageHeader from "@/components/PageHeader";
import Link from "@/components/Link";
import location from "@/services/location";
import settingsMenu from "@/services/settingsMenu";
function wrapSettingsTab(id, options, WrappedComponent) {
settingsMenu.add(id, options);
return function SettingsTab(props) {
const activeItem = settingsMenu.getActiveItem(location.path);
return (
<div className="settings-screen">
<div className="container">
<PageHeader title="Settings" />
<div className="bg-white tiled">
<Menu selectedKeys={[activeItem && activeItem.title]} selectable={false} mode="horizontal">
{settingsMenu.getAvailableItems().map(item => (
<Menu.Item key={item.title}>
<Link href={item.path} data-test="SettingsScreenItem">
{item.title}
</Link>
</Menu.Item>
))}
</Menu>
<div className="p-15">
<div>
<WrappedComponent {...props} />
</div>
</div>
</div>
</div>
</div>
);
};
}
export default wrapSettingsTab;

View File

@@ -0,0 +1,32 @@
import React from "react";
import Menu from "antd/lib/menu";
import PageHeader from "@/components/PageHeader";
import Link from "@/components/Link";
import location from "@/services/location";
import settingsMenu from "@/services/settingsMenu";
function wrapSettingsTab(id: any, options: any, WrappedComponent: any) {
settingsMenu.add(id, options);
return function SettingsTab(props: any) {
const activeItem = settingsMenu.getActiveItem(location.path);
return (<div className="settings-screen">
<div className="container">
<PageHeader title="Settings"/>
<div className="bg-white tiled">
<Menu selectedKeys={[activeItem && (activeItem as any).title]} selectable={false} mode="horizontal">
{settingsMenu.getAvailableItems().map(item => (<Menu.Item key={(item as any).title}>
<Link href={(item as any).path} data-test="SettingsScreenItem">
{(item as any).title}
</Link>
</Menu.Item>))}
</Menu>
<div className="p-15">
<div>
<WrappedComponent {...props}/>
</div>
</div>
</div>
</div>
</div>);
};
}
export default wrapSettingsTab;

View File

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

View File

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

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