mirror of
https://github.com/getredash/redash.git
synced 2025-12-20 09:57:35 -05:00
Compare commits
31 Commits
ts-migrate
...
query-base
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19343a0520 | ||
|
|
c1ed8848f0 | ||
|
|
b40070d7f5 | ||
|
|
bd9ce68f68 | ||
|
|
0c0b62ae1a | ||
|
|
08bcdf77d0 | ||
|
|
aa2064b1ab | ||
|
|
d0a787cab1 | ||
|
|
a741341938 | ||
|
|
53385fa24b | ||
|
|
f396c96457 | ||
|
|
8bfcbf21e3 | ||
|
|
8a1640c4e7 | ||
|
|
a37e7f93dc | ||
|
|
cc34e781d3 | ||
|
|
6aa0ea715e | ||
|
|
6c27619671 | ||
|
|
6eeb3b3eb2 | ||
|
|
d40edb81c2 | ||
|
|
f128b4b85f | ||
|
|
264fb5798d | ||
|
|
90023ac435 | ||
|
|
df755fbc17 | ||
|
|
e555642844 | ||
|
|
bdd7b146ae | ||
|
|
b7478defec | ||
|
|
bb0d7830c9 | ||
|
|
137aa22dd4 | ||
|
|
9cf396599a | ||
|
|
b70f0fa921 | ||
|
|
5e3613d6cb |
@@ -20,7 +20,6 @@ module.exports = {
|
|||||||
// allow debugger during development
|
// allow debugger during development
|
||||||
"no-debugger": process.env.NODE_ENV === "production" ? 2 : 0,
|
"no-debugger": process.env.NODE_ENV === "production" ? 2 : 0,
|
||||||
"jsx-a11y/anchor-is-valid": "off",
|
"jsx-a11y/anchor-is-valid": "off",
|
||||||
"no-console": ["warn", { allow: ["warn", "error"] }],
|
|
||||||
"no-restricted-imports": [
|
"no-restricted-imports": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
@@ -51,7 +50,7 @@ module.exports = {
|
|||||||
"no-useless-constructor": "off",
|
"no-useless-constructor": "off",
|
||||||
"@typescript-eslint/no-useless-constructor": "error",
|
"@typescript-eslint/no-useless-constructor": "error",
|
||||||
// Many API fields and generated types use camelcase
|
// Many API fields and generated types use camelcase
|
||||||
"@typescript-eslint/camelcase": "off","@typescript-eslint/no-empty-function": "off",
|
"@typescript-eslint/camelcase": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import AceEditor from "react-ace";
|
|||||||
|
|
||||||
import "./AceEditorInput.less";
|
import "./AceEditorInput.less";
|
||||||
|
|
||||||
function AceEditorInput(props: any, ref: any) {
|
function AceEditorInput(props, ref) {
|
||||||
return (
|
return (
|
||||||
<div className="ace-editor-input" data-test={props["data-test"]}>
|
<div className="ace-editor-input" data-test={props["data-test"]}>
|
||||||
<AceEditor
|
<AceEditor
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,12 @@
|
|||||||
@backgroundColor: #001529;
|
@backgroundColor: #001529;
|
||||||
@dividerColor: rgba(255, 255, 255, 0.5);
|
@dividerColor: rgba(255, 255, 255, 0.5);
|
||||||
@textColor: rgba(255, 255, 255, 0.75);
|
@textColor: rgba(255, 255, 255, 0.75);
|
||||||
@brandColor: #ff7964; // Redash logo color
|
|
||||||
@activeItemColor: @brandColor;
|
|
||||||
@iconSize: 26px;
|
|
||||||
|
|
||||||
.desktop-navbar {
|
.desktop-navbar {
|
||||||
background: @backgroundColor;
|
background: @backgroundColor;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 80px;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&-spacer {
|
&-spacer {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
@@ -26,6 +21,12 @@
|
|||||||
height: 40px;
|
height: 40px;
|
||||||
transition: all 270ms;
|
transition: all 270ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.ant-menu-inline-collapsed {
|
||||||
|
img {
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-trigger {
|
.help-trigger {
|
||||||
@@ -33,19 +34,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ant-menu {
|
.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-item,
|
||||||
.ant-menu-submenu {
|
.ant-menu-submenu {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: @textColor;
|
color: @textColor;
|
||||||
|
|
||||||
&.navbar-active-item {
|
|
||||||
box-shadow: inset 3px 0 0 @activeItemColor;
|
|
||||||
|
|
||||||
.anticon {
|
|
||||||
color: @activeItemColor;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.ant-menu-submenu-open,
|
&.ant-menu-submenu-open,
|
||||||
&.ant-menu-submenu-active,
|
&.ant-menu-submenu-active,
|
||||||
&:hover,
|
&:hover,
|
||||||
@@ -53,16 +61,6 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.anticon {
|
|
||||||
font-size: @iconSize;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desktop-navbar-label {
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a,
|
a,
|
||||||
span,
|
span,
|
||||||
.anticon {
|
.anticon {
|
||||||
@@ -73,33 +71,21 @@
|
|||||||
.ant-menu-submenu-arrow {
|
.ant-menu-submenu-arrow {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-menu-item,
|
|
||||||
.ant-menu-submenu {
|
|
||||||
padding: 0;
|
|
||||||
height: 60px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-menu-submenu-title {
|
.ant-btn.desktop-navbar-collapse-button {
|
||||||
width: 100%;
|
background-color: @backgroundColor;
|
||||||
padding: 0;
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
color: @textColor;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active {
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
a,
|
&:after {
|
||||||
&.ant-menu-vertical > .ant-menu-submenu > .ant-menu-submenu-title,
|
animation: 0s !important;
|
||||||
.ant-menu-submenu-title {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
line-height: normal;
|
|
||||||
height: auto;
|
|
||||||
background: none;
|
|
||||||
color: inherit;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,8 +99,37 @@
|
|||||||
.profile__image_thumb {
|
.profile__image_thumb {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
width: @iconSize;
|
}
|
||||||
height: @iconSize;
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
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>);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { first } from "lodash";
|
import { first } from "lodash";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import MenuOutlinedIcon from "@ant-design/icons/MenuOutlined";
|
import MenuOutlinedIcon from "@ant-design/icons/MenuOutlined";
|
||||||
import Dropdown from "antd/lib/dropdown";
|
import Dropdown from "antd/lib/dropdown";
|
||||||
@@ -7,46 +8,59 @@ import Menu from "antd/lib/menu";
|
|||||||
import Link from "@/components/Link";
|
import Link from "@/components/Link";
|
||||||
import { Auth, currentUser } from "@/services/auth";
|
import { Auth, currentUser } from "@/services/auth";
|
||||||
import settingsMenu from "@/services/settingsMenu";
|
import settingsMenu from "@/services/settingsMenu";
|
||||||
// @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 logoUrl from "@/assets/images/redash_icon_small.png";
|
||||||
|
|
||||||
import "./MobileNavbar.less";
|
import "./MobileNavbar.less";
|
||||||
type OwnProps = {
|
|
||||||
getPopupContainer?: (...args: any[]) => any;
|
export default function MobileNavbar({ getPopupContainer }) {
|
||||||
};
|
|
||||||
type Props = OwnProps & typeof MobileNavbar.defaultProps;
|
|
||||||
export default function MobileNavbar({ getPopupContainer }: Props) {
|
|
||||||
const firstSettingsTab = first(settingsMenu.getAvailableItems());
|
const firstSettingsTab = first(settingsMenu.getAvailableItems());
|
||||||
return (<div className="mobile-navbar">
|
|
||||||
|
return (
|
||||||
|
<div className="mobile-navbar">
|
||||||
<div className="mobile-navbar-logo">
|
<div className="mobile-navbar-logo">
|
||||||
<Link href="./">
|
<Link href="./">
|
||||||
<img src={logoUrl} alt="Redash" />
|
<img src={logoUrl} alt="Redash" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Dropdown overlayStyle={{ minWidth: 200 }} trigger={["click"]} getPopupContainer={getPopupContainer} // so the overlay menu stays with the fixed header when page scrolls
|
<Dropdown
|
||||||
overlay={<Menu mode="vertical" theme="dark" selectable={false} className="mobile-navbar-menu">
|
overlayStyle={{ minWidth: 200 }}
|
||||||
{currentUser.hasPermission("list_dashboards") && (<Menu.Item key="dashboards">
|
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>
|
<Link href="dashboards">Dashboards</Link>
|
||||||
</Menu.Item>)}
|
</Menu.Item>
|
||||||
{currentUser.hasPermission("view_query") && (<Menu.Item key="queries">
|
)}
|
||||||
|
{currentUser.hasPermission("view_query") && (
|
||||||
|
<Menu.Item key="queries">
|
||||||
<Link href="queries">Queries</Link>
|
<Link href="queries">Queries</Link>
|
||||||
</Menu.Item>)}
|
</Menu.Item>
|
||||||
{currentUser.hasPermission("list_alerts") && (<Menu.Item key="alerts">
|
)}
|
||||||
|
{currentUser.hasPermission("list_alerts") && (
|
||||||
|
<Menu.Item key="alerts">
|
||||||
<Link href="alerts">Alerts</Link>
|
<Link href="alerts">Alerts</Link>
|
||||||
</Menu.Item>)}
|
</Menu.Item>
|
||||||
|
)}
|
||||||
<Menu.Item key="profile">
|
<Menu.Item key="profile">
|
||||||
<Link href="users/me">Edit Profile</Link>
|
<Link href="users/me">Edit Profile</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
{firstSettingsTab && (<Menu.Item key="settings">
|
{firstSettingsTab && (
|
||||||
<Link href={(firstSettingsTab as any).path}>Settings</Link>
|
<Menu.Item key="settings">
|
||||||
</Menu.Item>)}
|
<Link href={firstSettingsTab.path}>Settings</Link>
|
||||||
{currentUser.hasPermission("super_admin") && (<Menu.Item key="status">
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
{currentUser.hasPermission("super_admin") && (
|
||||||
|
<Menu.Item key="status">
|
||||||
<Link href="admin/status">System Status</Link>
|
<Link href="admin/status">System Status</Link>
|
||||||
</Menu.Item>)}
|
</Menu.Item>
|
||||||
|
)}
|
||||||
{currentUser.hasPermission("super_admin") && <Menu.Divider />}
|
{currentUser.hasPermission("super_admin") && <Menu.Divider />}
|
||||||
<Menu.Item key="help">
|
<Menu.Item key="help">
|
||||||
|
{/* eslint-disable-next-line react/jsx-no-target-blank */}
|
||||||
<Link href="https://redash.io/help" target="_blank" rel="noopener">
|
<Link href="https://redash.io/help" target="_blank" rel="noopener">
|
||||||
Help
|
Help
|
||||||
</Link>
|
</Link>
|
||||||
@@ -54,14 +68,21 @@ export default function MobileNavbar({ getPopupContainer }: Props) {
|
|||||||
<Menu.Item key="logout" onClick={() => Auth.logout()}>
|
<Menu.Item key="logout" onClick={() => Auth.logout()}>
|
||||||
Log out
|
Log out
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu>}>
|
</Menu>
|
||||||
|
}>
|
||||||
<Button className="mobile-navbar-toggle-button" ghost>
|
<Button className="mobile-navbar-toggle-button" ghost>
|
||||||
<MenuOutlinedIcon />
|
<MenuOutlinedIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>);
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MobileNavbar.propTypes = {
|
||||||
|
getPopupContainer: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
MobileNavbar.defaultProps = {
|
MobileNavbar.defaultProps = {
|
||||||
getPopupContainer: null,
|
getPopupContainer: null,
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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>);
|
|
||||||
}
|
|
||||||
@@ -1,36 +1,27 @@
|
|||||||
import React, { useRef, useCallback } from "react";
|
import React, { useRef, useCallback } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
import DynamicComponent from "@/components/DynamicComponent";
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
import DesktopNavbar from "./DesktopNavbar";
|
import DesktopNavbar from "./DesktopNavbar";
|
||||||
import MobileNavbar from "./MobileNavbar";
|
import MobileNavbar from "./MobileNavbar";
|
||||||
|
|
||||||
import "./index.less";
|
import "./index.less";
|
||||||
|
|
||||||
type OwnProps = {
|
export default function ApplicationLayout({ children }) {
|
||||||
children?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = OwnProps & typeof ApplicationLayout.defaultProps;
|
|
||||||
|
|
||||||
export default function ApplicationLayout({ children }: Props) {
|
|
||||||
const mobileNavbarContainerRef = useRef();
|
const mobileNavbarContainerRef = useRef();
|
||||||
|
|
||||||
const getMobileNavbarPopupContainer = useCallback(() => mobileNavbarContainerRef.current, []);
|
const getMobileNavbarPopupContainer = useCallback(() => mobileNavbarContainerRef.current, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<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">
|
<DynamicComponent name="ApplicationWrapper">
|
||||||
<div className="application-layout-side-menu">
|
<div className="application-layout-side-menu">
|
||||||
<DynamicComponent name="ApplicationDesktopNavbar">
|
<DynamicComponent name="ApplicationDesktopNavbar">
|
||||||
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
|
|
||||||
<DesktopNavbar />
|
<DesktopNavbar />
|
||||||
</DynamicComponent>
|
</DynamicComponent>
|
||||||
</div>
|
</div>
|
||||||
<div className="application-layout-content">
|
<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}>
|
<nav className="application-layout-top-menu" ref={mobileNavbarContainerRef}>
|
||||||
<DynamicComponent name="ApplicationMobileNavbar" getPopupContainer={getMobileNavbarPopupContainer}>
|
<DynamicComponent name="ApplicationMobileNavbar" getPopupContainer={getMobileNavbarPopupContainer}>
|
||||||
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
|
|
||||||
<MobileNavbar getPopupContainer={getMobileNavbarPopupContainer} />
|
<MobileNavbar getPopupContainer={getMobileNavbarPopupContainer} />
|
||||||
</DynamicComponent>
|
</DynamicComponent>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -41,6 +32,10 @@ export default function ApplicationLayout({ children }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ApplicationLayout.propTypes = {
|
||||||
|
children: PropTypes.node,
|
||||||
|
};
|
||||||
|
|
||||||
ApplicationLayout.defaultProps = {
|
ApplicationLayout.defaultProps = {
|
||||||
children: null,
|
children: null,
|
||||||
};
|
};
|
||||||
69
client/app/components/ApplicationArea/ErrorMessage.jsx
Normal file
69
client/app/components/ApplicationArea/ErrorMessage.jsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { get, isObject } from "lodash";
|
||||||
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
import "./ErrorMessage.less";
|
||||||
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
|
import { ErrorMessageDetails } from "@/components/ApplicationArea/ErrorMessageDetails";
|
||||||
|
|
||||||
|
function getErrorMessageByStatus(status, defaultMessage) {
|
||||||
|
switch (status) {
|
||||||
|
case 404:
|
||||||
|
return "It seems like the page you're looking for cannot be found.";
|
||||||
|
case 401:
|
||||||
|
case 403:
|
||||||
|
return "It seems like you don’t 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, message }) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorMessage.propTypes = {
|
||||||
|
error: PropTypes.object.isRequired,
|
||||||
|
message: PropTypes.string,
|
||||||
|
};
|
||||||
51
client/app/components/ApplicationArea/ErrorMessage.test.js
Normal file
51
client/app/components/ApplicationArea/ErrorMessage.test.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { mount } from "enzyme";
|
||||||
|
import ErrorMessage from "./ErrorMessage";
|
||||||
|
|
||||||
|
const ErrorMessages = {
|
||||||
|
UNAUTHORIZED: "It seems like you don’t 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { mount } from "enzyme";
|
|
||||||
import ErrorMessage from "./ErrorMessage";
|
|
||||||
const ErrorMessages = {
|
|
||||||
UNAUTHORIZED: "It seems like you don’t 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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 don’t 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>);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
export function ErrorMessageDetails(props) {
|
||||||
|
return <h4>{props.message}</h4>;
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorMessageDetails.propTypes = {
|
||||||
|
error: PropTypes.instanceOf(Error).isRequired,
|
||||||
|
message: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
error: any; // TODO: PropTypes.instanceOf(Error)
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ErrorMessageDetails(props: Props) {
|
|
||||||
return <h4>{props.message}</h4>;
|
|
||||||
}
|
|
||||||
145
client/app/components/ApplicationArea/Router.jsx
Normal file
145
client/app/components/ApplicationArea/Router.jsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
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: () => {},
|
||||||
|
};
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
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: () => { },
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { isString } from "lodash";
|
import { isString } from "lodash";
|
||||||
import navigateTo from "./navigateTo";
|
import navigateTo from "./navigateTo";
|
||||||
|
|
||||||
export default function handleNavigationIntent(event: any) {
|
export default function handleNavigationIntent(event) {
|
||||||
let element = event.target;
|
let element = event.target;
|
||||||
while (element) {
|
while (element) {
|
||||||
if (element.tagName === "A") {
|
if (element.tagName === "A") {
|
||||||
@@ -9,15 +9,13 @@ export default function ApplicationArea() {
|
|||||||
const [unhandledError, setUnhandledError] = useState(null);
|
const [unhandledError, setUnhandledError] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
|
|
||||||
if (currentRoute && currentRoute.title) {
|
if (currentRoute && currentRoute.title) {
|
||||||
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
|
|
||||||
document.title = currentRoute.title;
|
document.title = currentRoute.title;
|
||||||
}
|
}
|
||||||
}, [currentRoute]);
|
}, [currentRoute]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function globalErrorHandler(event: any) {
|
function globalErrorHandler(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setUnhandledError(event.error);
|
setUnhandledError(event.error);
|
||||||
}
|
}
|
||||||
@@ -35,6 +33,5 @@ export default function ApplicationArea() {
|
|||||||
return <ErrorMessage error={unhandledError} />;
|
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} />;
|
return <Router routes={routes.items} onRouteChange={setCurrentRoute} />;
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ import { stripBase } from "./Router";
|
|||||||
|
|
||||||
// When `replace` is set to `true` - it will just replace current URL
|
// When `replace` is set to `true` - it will just replace current URL
|
||||||
// without reloading current page (router will skip this location change)
|
// without reloading current page (router will skip this location change)
|
||||||
export default function navigateTo(href: any, replace = false) {
|
export default function navigateTo(href, replace = false) {
|
||||||
// Allow calling chain to roll up, and then navigate
|
// Allow calling chain to roll up, and then navigate
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const isExternal = stripBase(href) === false;
|
const isExternal = stripBase(href) === false;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import React, { useEffect, useState, useContext } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
|
||||||
|
import { Auth, clientConfig } 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 || clientConfig.disablePublicUrls) {
|
||||||
|
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} />
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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}/>,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
// @ts-expect-error (Must be removed after adding @redash/viz typing)
|
||||||
import ErrorBoundary, { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
|
import ErrorBoundary, { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
|
||||||
import { Auth } from "@/services/auth";
|
import { Auth } from "@/services/auth";
|
||||||
import { policy } from "@/services/policy";
|
import { policy } from "@/services/policy";
|
||||||
@@ -60,12 +61,10 @@ export function UserSessionWrapper<P>({ bodyClass, currentRoute, render }: UserS
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ApplicationLayout>
|
<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}>
|
<React.Fragment key={currentRoute.key}>
|
||||||
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
|
|
||||||
<ErrorBoundary renderError={(error: Error) => <ErrorMessage error={error} />}>
|
<ErrorBoundary renderError={(error: Error) => <ErrorMessage error={error} />}>
|
||||||
<ErrorBoundaryContext.Consumer>
|
<ErrorBoundaryContext.Consumer>
|
||||||
{({ handleError } /* : { handleError: UserSessionWrapperRenderChildrenProps<P>["onError"] } FIXME bring back type */) =>
|
{({ handleError }: { handleError: UserSessionWrapperRenderChildrenProps<P>["onError"] }) =>
|
||||||
render({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError })
|
render({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError })
|
||||||
}
|
}
|
||||||
</ErrorBoundaryContext.Consumer>
|
</ErrorBoundaryContext.Consumer>
|
||||||
|
|||||||
@@ -7,21 +7,28 @@ import Link from "@/components/Link";
|
|||||||
import HelpTrigger from "@/components/HelpTrigger";
|
import HelpTrigger from "@/components/HelpTrigger";
|
||||||
import DynamicComponent from "@/components/DynamicComponent";
|
import DynamicComponent from "@/components/DynamicComponent";
|
||||||
import OrgSettings from "@/services/organizationSettings";
|
import OrgSettings from "@/services/organizationSettings";
|
||||||
|
|
||||||
const Text = Typography.Text;
|
const Text = Typography.Text;
|
||||||
|
|
||||||
function BeaconConsent() {
|
function BeaconConsent() {
|
||||||
const [hide, setHide] = useState(false);
|
const [hide, setHide] = useState(false);
|
||||||
if (!(clientConfig as any).showBeaconConsentMessage || hide) {
|
|
||||||
|
if (!clientConfig.showBeaconConsentMessage || hide) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hideConsentCard = () => {
|
const hideConsentCard = () => {
|
||||||
(clientConfig as any).showBeaconConsentMessage = false;
|
clientConfig.showBeaconConsentMessage = false;
|
||||||
setHide(true);
|
setHide(true);
|
||||||
};
|
};
|
||||||
const confirmConsent = (confirm: any) => {
|
|
||||||
|
const confirmConsent = confirm => {
|
||||||
let message = "🙏 Thank you.";
|
let message = "🙏 Thank you.";
|
||||||
|
|
||||||
if (!confirm) {
|
if (!confirm) {
|
||||||
message = "Settings Saved.";
|
message = "Settings Saved.";
|
||||||
}
|
}
|
||||||
|
|
||||||
OrgSettings.save({ beacon_consent: confirm }, message)
|
OrgSettings.save({ beacon_consent: confirm }, message)
|
||||||
// .then(() => {
|
// .then(() => {
|
||||||
// // const settings = get(response, 'settings');
|
// // const settings = get(response, 'settings');
|
||||||
@@ -29,14 +36,18 @@ function BeaconConsent() {
|
|||||||
// })
|
// })
|
||||||
.finally(hideConsentCard);
|
.finally(hideConsentCard);
|
||||||
};
|
};
|
||||||
return (<DynamicComponent name="BeaconConsent">
|
|
||||||
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
|
return (
|
||||||
|
<DynamicComponent name="BeaconConsent">
|
||||||
<div className="m-t-10 tiled">
|
<div className="m-t-10 tiled">
|
||||||
<Card title={<>
|
<Card
|
||||||
|
title={
|
||||||
|
<>
|
||||||
Would you be ok with sharing anonymous usage data with the Redash team?{" "}
|
Would you be ok with sharing anonymous usage data with the Redash team?{" "}
|
||||||
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'string' is not assignable to type 'never'. */}
|
|
||||||
<HelpTrigger type="USAGE_DATA_SHARING" />
|
<HelpTrigger type="USAGE_DATA_SHARING" />
|
||||||
</>} bordered={false}>
|
</>
|
||||||
|
}
|
||||||
|
bordered={false}>
|
||||||
<Text>Help Redash improve by automatically sending anonymous usage data:</Text>
|
<Text>Help Redash improve by automatically sending anonymous usage data:</Text>
|
||||||
<div className="m-t-5">
|
<div className="m-t-5">
|
||||||
<ul>
|
<ul>
|
||||||
@@ -61,6 +72,8 @@ function BeaconConsent() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</DynamicComponent>);
|
</DynamicComponent>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default BeaconConsent;
|
export default BeaconConsent;
|
||||||
@@ -1,15 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
type OwnProps = {
|
function BigMessage({ message, icon, children, className }) {
|
||||||
message?: string;
|
|
||||||
icon: string;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = OwnProps & typeof BigMessage.defaultProps;
|
|
||||||
|
|
||||||
function BigMessage({ message, icon, children, className }: Props) {
|
|
||||||
return (
|
return (
|
||||||
<div className={"p-15 text-center " + className}>
|
<div className={"p-15 text-center " + className}>
|
||||||
<h3 className="m-t-0 m-b-0">
|
<h3 className="m-t-0 m-b-0">
|
||||||
@@ -22,6 +14,13 @@ function BigMessage({ message, icon, children, className }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BigMessage.propTypes = {
|
||||||
|
message: PropTypes.string,
|
||||||
|
icon: PropTypes.string.isRequired,
|
||||||
|
children: PropTypes.node,
|
||||||
|
className: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
BigMessage.defaultProps = {
|
BigMessage.defaultProps = {
|
||||||
message: "",
|
message: "",
|
||||||
children: null,
|
children: null,
|
||||||
@@ -1,30 +1,24 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import Tooltip from "antd/lib/tooltip";
|
import Tooltip from "antd/lib/tooltip";
|
||||||
import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined";
|
import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined";
|
||||||
import "./CodeBlock.less";
|
import "./CodeBlock.less";
|
||||||
|
|
||||||
type OwnProps = {
|
export default class CodeBlock extends React.Component {
|
||||||
copyable?: boolean;
|
static propTypes = {
|
||||||
|
copyable: PropTypes.bool,
|
||||||
|
children: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|
||||||
type State = any;
|
|
||||||
|
|
||||||
type Props = OwnProps & typeof CodeBlock.defaultProps;
|
|
||||||
|
|
||||||
export default class CodeBlock extends React.Component<Props, State> {
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
copyable: false,
|
copyable: false,
|
||||||
children: null,
|
children: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
copyFeatureEnabled: any;
|
|
||||||
ref: any;
|
|
||||||
resetCopyState: any;
|
|
||||||
|
|
||||||
state = { copied: null };
|
state = { copied: null };
|
||||||
|
|
||||||
constructor(props: Props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.ref = React.createRef();
|
this.ref = React.createRef();
|
||||||
this.copyFeatureEnabled = props.copyable && document.queryCommandSupported("copy");
|
this.copyFeatureEnabled = props.copyable && document.queryCommandSupported("copy");
|
||||||
@@ -39,7 +33,6 @@ export default class CodeBlock extends React.Component<Props, State> {
|
|||||||
|
|
||||||
copy = () => {
|
copy = () => {
|
||||||
// select text
|
// select text
|
||||||
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
|
|
||||||
window.getSelection().selectAllChildren(this.ref.current);
|
window.getSelection().selectAllChildren(this.ref.current);
|
||||||
|
|
||||||
// copy
|
// copy
|
||||||
@@ -56,7 +49,6 @@ export default class CodeBlock extends React.Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// reset selection
|
// reset selection
|
||||||
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
|
|
||||||
window.getSelection().removeAllRanges();
|
window.getSelection().removeAllRanges();
|
||||||
|
|
||||||
// reset tooltip
|
// reset tooltip
|
||||||
@@ -1,20 +1,12 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import AntCollapse from "antd/lib/collapse";
|
import AntCollapse from "antd/lib/collapse";
|
||||||
|
|
||||||
type OwnProps = {
|
export default function Collapse({ collapsed, children, className, ...props }) {
|
||||||
collapsed?: boolean;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = OwnProps & typeof Collapse.defaultProps;
|
|
||||||
|
|
||||||
export default function Collapse({ collapsed, children, className, ...props }: Props) {
|
|
||||||
return (
|
return (
|
||||||
<AntCollapse
|
<AntCollapse
|
||||||
{...props}
|
{...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"}
|
activeKey={collapsed ? null : "content"}
|
||||||
className={cx(className, "ant-collapse-headerless")}>
|
className={cx(className, "ant-collapse-headerless")}>
|
||||||
<AntCollapse.Panel key="content" header="">
|
<AntCollapse.Panel key="content" header="">
|
||||||
@@ -24,6 +16,12 @@ export default function Collapse({ collapsed, children, className, ...props }: P
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Collapse.propTypes = {
|
||||||
|
collapsed: PropTypes.bool,
|
||||||
|
children: PropTypes.node,
|
||||||
|
className: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
Collapse.defaultProps = {
|
Collapse.defaultProps = {
|
||||||
collapsed: true,
|
collapsed: true,
|
||||||
children: null,
|
children: null,
|
||||||
198
client/app/components/CreateSourceDialog.jsx
Normal file
198
client/app/components/CreateSourceDialog.jsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
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);
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
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);
|
|
||||||
43
client/app/components/DateInput.jsx
Normal file
43
client/app/components/DateInput.jsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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;
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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;
|
|
||||||
45
client/app/components/DateRangeInput.jsx
Normal file
45
client/app/components/DateRangeInput.jsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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;
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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;
|
|
||||||
46
client/app/components/DateTimeInput.jsx
Normal file
46
client/app/components/DateTimeInput.jsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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;
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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;
|
|
||||||
50
client/app/components/DateTimeRangeInput.jsx
Normal file
50
client/app/components/DateTimeRangeInput.jsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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;
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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;
|
|
||||||
227
client/app/components/DialogWrapper.jsx
Normal file
227
client/app/components/DialogWrapper.jsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,36 +1,33 @@
|
|||||||
import { isFunction, isString, isUndefined } from "lodash";
|
import { isFunction, isString, isUndefined } from "lodash";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
const componentsRegistry = new Map();
|
const componentsRegistry = new Map();
|
||||||
const activeInstances = new Set();
|
const activeInstances = new Set();
|
||||||
|
|
||||||
export function registerComponent(name: any, component: any) {
|
export function registerComponent(name, component) {
|
||||||
if (isString(name) && name !== "") {
|
if (isString(name) && name !== "") {
|
||||||
componentsRegistry.set(name, isFunction(component) ? component : null);
|
componentsRegistry.set(name, isFunction(component) ? component : null);
|
||||||
// Refresh active DynamicComponent instances which use this component
|
// Refresh active DynamicComponent instances which use this component
|
||||||
activeInstances.forEach(dynamicComponent => {
|
activeInstances.forEach(dynamicComponent => {
|
||||||
// @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'.
|
|
||||||
if (dynamicComponent.props.name === name) {
|
if (dynamicComponent.props.name === name) {
|
||||||
// @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'.
|
|
||||||
dynamicComponent.forceUpdate();
|
dynamicComponent.forceUpdate();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unregisterComponent(name: any) {
|
export function unregisterComponent(name) {
|
||||||
registerComponent(name, null);
|
registerComponent(name, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
type OwnProps = {
|
export default class DynamicComponent extends React.Component {
|
||||||
name: string;
|
static propTypes = {
|
||||||
fallback?: React.ReactNode;
|
name: PropTypes.string.isRequired,
|
||||||
|
fallback: PropTypes.node,
|
||||||
|
children: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = OwnProps & typeof DynamicComponent.defaultProps;
|
|
||||||
|
|
||||||
export default class DynamicComponent extends React.Component<Props> {
|
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
children: null,
|
children: null,
|
||||||
};
|
};
|
||||||
104
client/app/components/EditInPlace.jsx
Normal file
104
client/app/components/EditInPlace.jsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
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>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
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);
|
|
||||||
296
client/app/components/EditParameterSettingsDialog/index.jsx
Normal file
296
client/app/components/EditParameterSettingsDialog/index.jsx
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import { includes, words, capitalize, clone, isNull, map, get, find } from "lodash";
|
||||||
|
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import Checkbox from "antd/lib/checkbox";
|
||||||
|
import Modal from "antd/lib/modal";
|
||||||
|
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";
|
||||||
|
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
|
||||||
|
import QueryBasedParameterMappingTable from "./query-based-parameter/QueryBasedParameterMappingTable";
|
||||||
|
|
||||||
|
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 [paramQuery, setParamQuery] = useState();
|
||||||
|
const mappingParameters = useMemo(
|
||||||
|
() =>
|
||||||
|
map(paramQuery && paramQuery.getParametersDefs(), mappingParam => ({
|
||||||
|
mappingParam,
|
||||||
|
existingMapping: get(param.parameterMapping, mappingParam.name, {
|
||||||
|
mappingType: QueryBasedParameterMappingType.UNDEFINED,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
[param.parameterMapping, paramQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isNew = !props.parameter.name;
|
||||||
|
|
||||||
|
// fetch query by id
|
||||||
|
const initialQueryId = useRef(props.parameter.queryId);
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialQueryId.current) {
|
||||||
|
Query.get({ id: initialQueryId.current }).then(setParamQuery);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function isFulfilled() {
|
||||||
|
// name
|
||||||
|
if (!isNameValid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// title
|
||||||
|
if (param.title === "") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// query
|
||||||
|
if (param.type === "query") {
|
||||||
|
if (!param.queryId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (find(mappingParameters, { existingMapping: { mappingType: QueryBasedParameterMappingType.UNDEFINED } })) {
|
||||||
|
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>
|
||||||
|
<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" required {...formItemProps}>
|
||||||
|
<QuerySelector
|
||||||
|
selectedQuery={paramQuery}
|
||||||
|
onChange={q => {
|
||||||
|
if (q) {
|
||||||
|
setParamQuery(q);
|
||||||
|
setParam({ ...param, queryId: q.id, parameterMapping: {} });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="select"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
{param.type === "query" && paramQuery && paramQuery.hasParameters() && (
|
||||||
|
<Form.Item className="m-t-15 m-b-5" label="Parameters" required {...formItemProps}>
|
||||||
|
<QueryBasedParameterMappingTable
|
||||||
|
param={param}
|
||||||
|
mappingParameters={mappingParameters}
|
||||||
|
onChangeParam={setParam}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
{(param.type === "enum" || param.type === "query") && (
|
||||||
|
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
|
||||||
|
<Checkbox
|
||||||
|
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);
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useReducer } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { values } from "lodash";
|
||||||
|
import Button from "antd/lib/button";
|
||||||
|
import Tooltip from "antd/lib/tooltip";
|
||||||
|
import Radio from "antd/lib/radio";
|
||||||
|
import Typography from "antd/lib/typography/Typography";
|
||||||
|
import ParameterValueInput from "@/components/ParameterValueInput";
|
||||||
|
import InputPopover from "@/components/InputPopover";
|
||||||
|
import Form from "antd/lib/form";
|
||||||
|
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
|
||||||
|
|
||||||
|
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
|
||||||
|
import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
|
||||||
|
export default function QueryBasedParameterMappingEditor({ parameter, mapping, searchAvailable, onChange }) {
|
||||||
|
const [showPopover, setShowPopover] = useState(false);
|
||||||
|
const [newMapping, setNewMapping] = useReducer((prevState, updates) => ({ ...prevState, ...updates }), mapping);
|
||||||
|
|
||||||
|
const newMappingRef = useRef(newMapping);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
mapping.mappingType !== newMappingRef.current.mappingType ||
|
||||||
|
mapping.staticValue !== newMappingRef.current.staticValue
|
||||||
|
) {
|
||||||
|
setNewMapping(mapping);
|
||||||
|
}
|
||||||
|
}, [mapping]);
|
||||||
|
|
||||||
|
const parameterRef = useRef(parameter);
|
||||||
|
useEffect(() => {
|
||||||
|
parameterRef.current.setValue(mapping.staticValue);
|
||||||
|
}, [mapping.staticValue]);
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
setNewMapping(mapping);
|
||||||
|
setShowPopover(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOk = () => {
|
||||||
|
onChange(newMapping);
|
||||||
|
setShowPopover(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentState = <Text type="secondary">Pick a type</Text>;
|
||||||
|
if (mapping.mappingType === QueryBasedParameterMappingType.DROPDOWN_SEARCH) {
|
||||||
|
currentState = "Dropdown Search";
|
||||||
|
} else if (mapping.mappingType === QueryBasedParameterMappingType.STATIC) {
|
||||||
|
currentState = `Value: ${mapping.staticValue}`;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{currentState}
|
||||||
|
<InputPopover
|
||||||
|
placement="left"
|
||||||
|
trigger="click"
|
||||||
|
header="Edit Parameter Source"
|
||||||
|
okButtonProps={{
|
||||||
|
disabled: newMapping.mappingType === QueryBasedParameterMappingType.STATIC && parameter.isEmpty,
|
||||||
|
}}
|
||||||
|
onOk={onOk}
|
||||||
|
onCancel={onCancel}
|
||||||
|
content={
|
||||||
|
<Form>
|
||||||
|
<Form.Item className="m-b-15" label="Source" {...formItemProps}>
|
||||||
|
<Radio.Group
|
||||||
|
value={newMapping.mappingType}
|
||||||
|
onChange={({ target }) => setNewMapping({ mappingType: target.value })}>
|
||||||
|
<Radio
|
||||||
|
className="radio"
|
||||||
|
value={QueryBasedParameterMappingType.DROPDOWN_SEARCH}
|
||||||
|
disabled={!searchAvailable || parameter.type !== "text"}>
|
||||||
|
Dropdown Search{" "}
|
||||||
|
{(!searchAvailable || parameter.type !== "text") && (
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
parameter.type !== "text"
|
||||||
|
? "Dropdown Search is only available for Text Parameters"
|
||||||
|
: "There is already a parameter mapped with the Dropdown Search type."
|
||||||
|
}>
|
||||||
|
<QuestionCircleFilledIcon />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Radio>
|
||||||
|
<Radio className="radio" value={QueryBasedParameterMappingType.STATIC}>
|
||||||
|
Static Value
|
||||||
|
</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
{newMapping.mappingType === QueryBasedParameterMappingType.STATIC && (
|
||||||
|
<Form.Item label="Value" required {...formItemProps}>
|
||||||
|
<ParameterValueInput
|
||||||
|
type={parameter.type}
|
||||||
|
value={parameter.normalizedValue}
|
||||||
|
enumOptions={parameter.enumOptions}
|
||||||
|
queryId={parameter.queryId}
|
||||||
|
parameter={parameter}
|
||||||
|
onSelect={value => {
|
||||||
|
parameter.setValue(value);
|
||||||
|
setNewMapping({ staticValue: parameter.getExecutionValue({ joinListValues: true }) });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
}
|
||||||
|
visible={showPopover}
|
||||||
|
onVisibleChange={setShowPopover}>
|
||||||
|
<Button className="m-l-5" size="small" type="dashed">
|
||||||
|
<EditOutlinedIcon />
|
||||||
|
</Button>
|
||||||
|
</InputPopover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBasedParameterMappingEditor.propTypes = {
|
||||||
|
parameter: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||||
|
mapping: PropTypes.shape({
|
||||||
|
mappingType: PropTypes.oneOf(values(QueryBasedParameterMappingType)),
|
||||||
|
staticValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||||
|
}),
|
||||||
|
searchAvailable: PropTypes.bool,
|
||||||
|
onChange: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
QueryBasedParameterMappingEditor.defaultProps = {
|
||||||
|
mapping: { mappingType: QueryBasedParameterMappingType.UNDEFINED, staticValue: undefined },
|
||||||
|
searchAvailable: false,
|
||||||
|
onChange: () => {},
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { findKey } from "lodash";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import Table from "antd/lib/table";
|
||||||
|
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
|
||||||
|
import QueryBasedParameterMappingEditor from "./QueryBasedParameterMappingEditor";
|
||||||
|
|
||||||
|
export default function QueryBasedParameterMappingTable({ param, mappingParameters, onChangeParam }) {
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
dataSource={mappingParameters}
|
||||||
|
size="middle"
|
||||||
|
pagination={false}
|
||||||
|
rowKey={({ mappingParam }) => `param${mappingParam.name}`}>
|
||||||
|
<Table.Column title="Title" key="title" render={({ mappingParam }) => mappingParam.getTitle()} />
|
||||||
|
<Table.Column
|
||||||
|
title="Keyword"
|
||||||
|
key="keyword"
|
||||||
|
className="keyword"
|
||||||
|
render={({ mappingParam }) => <code>{`{{ ${mappingParam.name} }}`}</code>}
|
||||||
|
/>
|
||||||
|
<Table.Column
|
||||||
|
title="Value Source"
|
||||||
|
key="source"
|
||||||
|
render={({ mappingParam, existingMapping }) => (
|
||||||
|
<QueryBasedParameterMappingEditor
|
||||||
|
parameter={mappingParam.setValue(existingMapping.staticValue)}
|
||||||
|
mapping={existingMapping}
|
||||||
|
searchAvailable={
|
||||||
|
!findKey(param.parameterMapping, {
|
||||||
|
mappingType: QueryBasedParameterMappingType.DROPDOWN_SEARCH,
|
||||||
|
}) || existingMapping.mappingType === QueryBasedParameterMappingType.DROPDOWN_SEARCH
|
||||||
|
}
|
||||||
|
onChange={mapping =>
|
||||||
|
onChangeParam({
|
||||||
|
...param,
|
||||||
|
parameterMapping: { ...param.parameterMapping, [mappingParam.name]: mapping },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBasedParameterMappingTable.propTypes = {
|
||||||
|
param: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||||
|
mappingParameters: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
|
||||||
|
onChangeParam: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
QueryBasedParameterMappingTable.defaultProps = {
|
||||||
|
mappingParameters: [],
|
||||||
|
onChangeParam: () => {},
|
||||||
|
};
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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 { 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";
|
||||||
|
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
{!clientConfig.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.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: "",
|
||||||
|
};
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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: "",
|
|
||||||
};
|
|
||||||
@@ -1,19 +1,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
import Link from "@/components/Link";
|
import Link from "@/components/Link";
|
||||||
|
|
||||||
type OwnProps = {
|
export default function QueryResultsLink(props) {
|
||||||
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 = "";
|
let href = "";
|
||||||
|
|
||||||
const { query, queryResult, fileType } = props;
|
const { query, queryResult, fileType } = props;
|
||||||
@@ -35,6 +24,16 @@ export default function QueryResultsLink(props: 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 = {
|
QueryResultsLink.defaultProps = {
|
||||||
queryResult: {},
|
queryResult: {},
|
||||||
fileType: "csv",
|
fileType: "csv",
|
||||||
@@ -1,15 +1,9 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import FormOutlinedIcon from "@ant-design/icons/FormOutlined";
|
import FormOutlinedIcon from "@ant-design/icons/FormOutlined";
|
||||||
|
|
||||||
type OwnProps = {
|
export default function EditVisualizationButton(props) {
|
||||||
openVisualizationEditor: (...args: any[]) => any;
|
|
||||||
selectedTab?: string | number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = OwnProps & typeof EditVisualizationButton.defaultProps;
|
|
||||||
|
|
||||||
export default function EditVisualizationButton(props: Props) {
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
data-test="EditVisualization"
|
data-test="EditVisualization"
|
||||||
@@ -21,6 +15,11 @@ export default function EditVisualizationButton(props: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EditVisualizationButton.propTypes = {
|
||||||
|
openVisualizationEditor: PropTypes.func.isRequired,
|
||||||
|
selectedTab: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||||
|
};
|
||||||
|
|
||||||
EditVisualizationButton.defaultProps = {
|
EditVisualizationButton.defaultProps = {
|
||||||
selectedTab: "",
|
selectedTab: "",
|
||||||
};
|
};
|
||||||
47
client/app/components/EmailSettingsWarning.jsx
Normal file
47
client/app/components/EmailSettingsWarning.jsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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'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,
|
||||||
|
};
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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'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,
|
|
||||||
};
|
|
||||||
@@ -1,21 +1,19 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
type OwnProps = {
|
export default class FavoritesControl extends React.Component {
|
||||||
item: {
|
static propTypes = {
|
||||||
is_favorite: boolean;
|
item: PropTypes.shape({
|
||||||
|
is_favorite: PropTypes.bool.isRequired,
|
||||||
|
}).isRequired,
|
||||||
|
onChange: PropTypes.func,
|
||||||
};
|
};
|
||||||
onChange?: (...args: any[]) => any;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = OwnProps & typeof FavoritesControl.defaultProps;
|
|
||||||
|
|
||||||
export default class FavoritesControl extends React.Component<Props> {
|
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
onChange: () => {},
|
onChange: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
toggleItem(event: any, item: any, callback: any) {
|
toggleItem(event, item, callback) {
|
||||||
const action = item.is_favorite ? item.unfavorite.bind(item) : item.favorite.bind(item);
|
const action = item.is_favorite ? item.unfavorite.bind(item) : item.favorite.bind(item);
|
||||||
const savedIsFavorite = item.is_favorite;
|
const savedIsFavorite = item.is_favorite;
|
||||||
|
|
||||||
146
client/app/components/Filters.jsx
Normal file
146
client/app/components/Filters.jsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
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;
|
||||||
@@ -1,121 +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###";
|
|
||||||
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;
|
|
||||||
257
client/app/components/HelpTrigger.jsx
Normal file
257
client/app/components/HelpTrigger.jsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
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]
|
||||||
|
);
|
||||||
|
|
||||||
|
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, 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);
|
||||||
|
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 (!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.type);
|
||||||
|
return helpTriggerType ? helpTriggerType[0] : this.props.href;
|
||||||
|
};
|
||||||
|
|
||||||
|
openDrawer = e => {
|
||||||
|
// keep "open in new tab" behavior
|
||||||
|
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.setState({ visible: true });
|
||||||
|
// wait for drawer animation to complete so there's no animation jank
|
||||||
|
setTimeout(() => this.loadIframe(this.getUrl()), 300);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
closeDrawer = event => {
|
||||||
|
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.type}[1]`, this.props.title);
|
||||||
|
const className = cx("help-trigger", this.props.className);
|
||||||
|
const url = this.state.currentUrl;
|
||||||
|
const isAllowedDomain = some(allowedDomains, domain => startsWith(url || targetUrl, domain));
|
||||||
|
const shouldRenderAsLink = this.props.renderAsLink || !isAllowedDomain;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
this.props.showTooltip ? (
|
||||||
|
<>
|
||||||
|
{tooltip}
|
||||||
|
{shouldRenderAsLink && <i className="fa fa-external-link" style={{ marginLeft: 5 }} />}
|
||||||
|
</>
|
||||||
|
) : null
|
||||||
|
}>
|
||||||
|
<Link
|
||||||
|
href={url || this.getUrl()}
|
||||||
|
className={className}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
onClick={shouldRenderAsLink ? () => {} : this.openDrawer}>
|
||||||
|
{this.props.children}
|
||||||
|
</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">
|
||||||
|
{/* 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="Usage Help"
|
||||||
|
src="about:blank"
|
||||||
|
className={cx({ ready: !this.state.loading })}
|
||||||
|
onLoad={this.onIframeLoaded}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* loading indicator */}
|
||||||
|
{this.state.loading && (
|
||||||
|
<BigMessage icon="fa-spinner fa-2x fa-pulse" message="Loading..." className="help-message" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* error message */}
|
||||||
|
{this.state.error && (
|
||||||
|
<BigMessage icon="fa-exclamation-circle" className="help-message">
|
||||||
|
Something went wrong.
|
||||||
|
<br />
|
||||||
|
{/* eslint-disable-next-line react/jsx-no-target-blank */}
|
||||||
|
<Link href={this.state.error} target="_blank" rel="noopener">
|
||||||
|
Click here
|
||||||
|
</Link>{" "}
|
||||||
|
to open the page in a new window.
|
||||||
|
</BigMessage>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* extra content */}
|
||||||
|
<DynamicComponent name="HelpDrawerExtraContent" onLeave={this.closeDrawer} openPageUrl={this.loadIframe} />
|
||||||
|
</Drawer>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
registerComponent("HelpTrigger", helpTriggerWithTypes(TYPES, [DOMAIN]));
|
||||||
|
|
||||||
|
export default function HelpTrigger(props) {
|
||||||
|
return <DynamicComponent {...props} name="HelpTrigger" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
HelpTrigger.propTypes = HelpTriggerPropTypes;
|
||||||
|
HelpTrigger.defaultProps = HelpTriggerDefaultProps;
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
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;
|
|
||||||
57
client/app/components/InputPopover/index.jsx
Normal file
57
client/app/components/InputPopover/index.jsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import Button from "antd/lib/button";
|
||||||
|
import Popover from "antd/lib/popover";
|
||||||
|
|
||||||
|
import "./index.less";
|
||||||
|
|
||||||
|
export default function InputPopover({
|
||||||
|
header,
|
||||||
|
content,
|
||||||
|
children,
|
||||||
|
okButtonProps,
|
||||||
|
cancelButtonProps,
|
||||||
|
onCancel,
|
||||||
|
onOk,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
{...props}
|
||||||
|
content={
|
||||||
|
<div className="input-popover-content" data-test="InputPopoverContent">
|
||||||
|
{header && <header>{header}</header>}
|
||||||
|
{content}
|
||||||
|
<footer>
|
||||||
|
<Button onClick={onCancel} {...cancelButtonProps}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onOk} type="primary" {...okButtonProps}>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
{children}
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
InputPopover.propTypes = {
|
||||||
|
header: PropTypes.node,
|
||||||
|
content: PropTypes.node,
|
||||||
|
children: PropTypes.node,
|
||||||
|
okButtonProps: PropTypes.object,
|
||||||
|
cancelButtonProps: PropTypes.object,
|
||||||
|
onOk: PropTypes.func,
|
||||||
|
onCancel: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
InputPopover.defaultProps = {
|
||||||
|
header: null,
|
||||||
|
children: null,
|
||||||
|
okButtonProps: null,
|
||||||
|
cancelButtonProps: null,
|
||||||
|
onOk: () => {},
|
||||||
|
onCancel: () => {},
|
||||||
|
};
|
||||||
37
client/app/components/InputPopover/index.less
Normal file
37
client/app/components/InputPopover/index.less
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
@import "~antd/lib/modal/style/index"; // for ant @vars
|
||||||
|
|
||||||
|
.input-popover-content {
|
||||||
|
width: 390px;
|
||||||
|
|
||||||
|
.radio {
|
||||||
|
display: block;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-item {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
padding: 0 16px 10px;
|
||||||
|
margin: 0 -16px 20px;
|
||||||
|
border-bottom: @border-width-base @border-style-base @border-color-split;
|
||||||
|
font-size: @font-size-lg;
|
||||||
|
font-weight: 500;
|
||||||
|
color: @heading-color;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
border-top: @border-width-base @border-style-base @border-color-split;
|
||||||
|
padding: 10px 16px 0;
|
||||||
|
margin: 0 -16px;
|
||||||
|
text-align: right;
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,8 @@ import Input from "antd/lib/input";
|
|||||||
import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined";
|
import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined";
|
||||||
import Tooltip from "antd/lib/tooltip";
|
import Tooltip from "antd/lib/tooltip";
|
||||||
|
|
||||||
type State = any;
|
export default class InputWithCopy extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
export default class InputWithCopy extends React.Component<{}, State> {
|
|
||||||
copyFeatureSupported: any;
|
|
||||||
ref: any;
|
|
||||||
resetCopyState: any;
|
|
||||||
constructor(props: {}) {
|
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { copied: null };
|
this.state = { copied: null };
|
||||||
this.ref = React.createRef();
|
this.ref = React.createRef();
|
||||||
@@ -1,21 +1,21 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
|
|
||||||
function DefaultLinkComponent(props: any) {
|
function DefaultLinkComponent(props) {
|
||||||
return <a {...props} />; // eslint-disable-line jsx-a11y/anchor-has-content
|
return <a {...props} />; // eslint-disable-line jsx-a11y/anchor-has-content
|
||||||
}
|
}
|
||||||
|
|
||||||
function Link(props: any) {
|
function Link(props) {
|
||||||
return <Link.Component {...props} />;
|
return <Link.Component {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
Link.Component = DefaultLinkComponent;
|
Link.Component = DefaultLinkComponent;
|
||||||
|
|
||||||
function DefaultButtonLinkComponent(props: any) {
|
function DefaultButtonLinkComponent(props) {
|
||||||
return <Button role="button" {...props} />;
|
return <Button role="button" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ButtonLink(props: any) {
|
function ButtonLink(props) {
|
||||||
return <ButtonLink.Component {...props} />;
|
return <ButtonLink.Component {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
18
client/app/components/NoTaggedObjectsFound.jsx
Normal file
18
client/app/components/NoTaggedObjectsFound.jsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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
|
||||||
|
<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,
|
||||||
|
};
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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
|
|
||||||
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
|
|
||||||
<TagsControl className="inline-tags-control" tags={Array.from(tags)} tagSeparator={"+"} />.
|
|
||||||
</BigMessage>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,9 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
import "./index.less";
|
import "./index.less";
|
||||||
|
|
||||||
type OwnProps = {
|
export default function PageHeader({ title, actions }) {
|
||||||
title?: string;
|
|
||||||
actions?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = OwnProps & typeof PageHeader.defaultProps;
|
|
||||||
|
|
||||||
export default function PageHeader({ title, actions }: Props) {
|
|
||||||
return (
|
return (
|
||||||
<div className="page-header-wrapper">
|
<div className="page-header-wrapper">
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
@@ -18,6 +12,11 @@ export default function PageHeader({ title, actions }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PageHeader.propTypes = {
|
||||||
|
title: PropTypes.string,
|
||||||
|
actions: PropTypes.node,
|
||||||
|
};
|
||||||
|
|
||||||
PageHeader.defaultProps = {
|
PageHeader.defaultProps = {
|
||||||
title: "",
|
title: "",
|
||||||
actions: null,
|
actions: null,
|
||||||
@@ -1,20 +1,10 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
import Pagination from "antd/lib/pagination";
|
import Pagination from "antd/lib/pagination";
|
||||||
|
|
||||||
const MIN_ITEMS_PER_PAGE = 5;
|
const MIN_ITEMS_PER_PAGE = 5;
|
||||||
|
|
||||||
type OwnProps = {
|
export default function Paginator({ page, showPageSizeSelect, pageSize, onPageSizeChange, totalCount, onChange }) {
|
||||||
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)) {
|
if (totalCount <= (showPageSizeSelect ? MIN_ITEMS_PER_PAGE : pageSize)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -33,6 +23,15 @@ 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 = {
|
Paginator.defaultProps = {
|
||||||
showPageSizeSelect: false,
|
showPageSizeSelect: false,
|
||||||
onChange: () => {},
|
onChange: () => {},
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
import Button from "antd/lib/button";
|
import Button from "antd/lib/button";
|
||||||
import Badge from "antd/lib/badge";
|
import Badge from "antd/lib/badge";
|
||||||
import Tooltip from "antd/lib/tooltip";
|
import Tooltip from "antd/lib/tooltip";
|
||||||
import KeyboardShortcuts from "@/services/KeyboardShortcuts";
|
import KeyboardShortcuts from "@/services/KeyboardShortcuts";
|
||||||
|
|
||||||
type Props = {
|
function ParameterApplyButton({ paramCount, onClick }) {
|
||||||
onClick: (...args: any[]) => any;
|
|
||||||
paramCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function ParameterApplyButton({ paramCount, onClick }: Props) {
|
|
||||||
// show spinner when count is empty so the fade out is consistent
|
// show spinner when count is empty so the fade out is consistent
|
||||||
const icon = !paramCount ? "spinner fa-pulse" : "check";
|
const icon = !paramCount ? "spinner fa-pulse" : "check";
|
||||||
|
|
||||||
@@ -28,4 +24,9 @@ function ParameterApplyButton({ paramCount, onClick }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ParameterApplyButton.propTypes = {
|
||||||
|
onClick: PropTypes.func.isRequired,
|
||||||
|
paramCount: PropTypes.number.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
export default ParameterApplyButton;
|
export default ParameterApplyButton;
|
||||||
614
client/app/components/ParameterMappingInput.jsx
Normal file
614
client/app/components/ParameterMappingInput.jsx
Normal file
@@ -0,0 +1,614 @@
|
|||||||
|
/* 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 InputPopover from "@/components/InputPopover";
|
||||||
|
|
||||||
|
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
|
||||||
|
import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
|
||||||
|
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
|
||||||
|
import CheckOutlinedIcon from "@ant-design/icons/CheckOutlined";
|
||||||
|
|
||||||
|
import "./ParameterMappingInput.less";
|
||||||
|
|
||||||
|
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;
|
||||||
|
const options = map(existingParamNames, paramName => ({ label: paramName, value: paramName }));
|
||||||
|
|
||||||
|
return <Select value={mapping.mapTo} onChange={mapTo => this.updateParamMapping({ mapTo })} options={options} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { visible, mapping, inputError } = this.state;
|
||||||
|
return (
|
||||||
|
<InputPopover
|
||||||
|
placement="left"
|
||||||
|
trigger="click"
|
||||||
|
header={
|
||||||
|
<>
|
||||||
|
Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
content={
|
||||||
|
<ParameterMappingInput
|
||||||
|
mapping={mapping}
|
||||||
|
existingParamNames={this.props.existingParamNames}
|
||||||
|
onChange={this.onChange}
|
||||||
|
inputError={inputError}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onOk={this.save}
|
||||||
|
onCancel={this.hide}
|
||||||
|
okButtonProps={{ disabled: !!inputError }}
|
||||||
|
visible={visible}
|
||||||
|
onVisibleChange={this.onVisibleChange}>
|
||||||
|
<Button size="small" type="dashed" data-test={`EditParamMappingButton-${mapping.param.name}`}>
|
||||||
|
<EditOutlinedIcon />
|
||||||
|
</Button>
|
||||||
|
</InputPopover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
@import '~antd/lib/modal/style/index'; // for ant @vars
|
@import "~antd/lib/modal/style/index"; // for ant @vars
|
||||||
|
|
||||||
.parameters-mapping-list {
|
.parameters-mapping-list {
|
||||||
.keyword {
|
.keyword {
|
||||||
@@ -22,48 +22,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.parameter-mapping-editor {
|
|
||||||
width: 390px;
|
|
||||||
|
|
||||||
.radio {
|
|
||||||
display: block;
|
|
||||||
height: 30px;
|
|
||||||
line-height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-item {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
padding: 0 16px 10px;
|
|
||||||
margin: 0 -16px 20px;
|
|
||||||
border-bottom: @border-width-base @border-style-base @border-color-split;
|
|
||||||
font-size: @font-size-lg;
|
|
||||||
font-weight: 500;
|
|
||||||
color: @heading-color;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
border-top: @border-width-base @border-style-base @border-color-split;
|
|
||||||
padding: 10px 16px 0;
|
|
||||||
margin: 0 -16px;
|
|
||||||
text-align: right;
|
|
||||||
|
|
||||||
button {
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.parameter-mapping-title {
|
.parameter-mapping-title {
|
||||||
.text {
|
.text {
|
||||||
margin-right: 3px;
|
margin-right: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.disabled, .fa {
|
&.disabled,
|
||||||
|
.fa {
|
||||||
color: #a4a4a4;
|
color: #a4a4a4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,467 +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 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>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
192
client/app/components/ParameterValueInput.jsx
Normal file
192
client/app/components/ParameterValueInput.jsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { isEqual, isEmpty, map } from "lodash";
|
||||||
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
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 => `+${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 (
|
||||||
|
<SelectWithVirtualScroll
|
||||||
|
className={this.props.className}
|
||||||
|
mode={parameter.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;
|
||||||
|
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;
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
.@{ant-prefix}-input-number,
|
.@{ant-prefix}-input-number,
|
||||||
.@{ant-prefix}-select-selector,
|
.@{ant-prefix}-select-selector,
|
||||||
.@{ant-prefix}-picker {
|
.@{ant-prefix}-picker {
|
||||||
background-color: @input-dirty;
|
background-color: @input-dirty !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,37 +1,33 @@
|
|||||||
import { size, filter, forEach, extend } from "lodash";
|
import { size, filter, forEach, extend } from "lodash";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
import { SortableContainer, SortableElement, DragHandle } from "@redash/viz/lib/components/sortable";
|
import { SortableContainer, SortableElement, DragHandle } from "@redash/viz/lib/components/sortable";
|
||||||
import location from "@/services/location";
|
import location from "@/services/location";
|
||||||
import { createParameter } from "@/services/parameters";
|
import { Parameter, createParameter } from "@/services/parameters";
|
||||||
import ParameterApplyButton from "@/components/ParameterApplyButton";
|
import ParameterApplyButton from "@/components/ParameterApplyButton";
|
||||||
import ParameterValueInput from "@/components/ParameterValueInput";
|
import ParameterValueInput from "@/components/ParameterValueInput";
|
||||||
import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
|
import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
|
||||||
import { toHuman } from "@/lib/utils";
|
|
||||||
|
|
||||||
import "./Parameters.less";
|
import "./Parameters.less";
|
||||||
|
|
||||||
function updateUrl(parameters: any) {
|
function updateUrl(parameters) {
|
||||||
const params = extend({}, location.search);
|
const params = extend({}, location.search);
|
||||||
parameters.forEach((param: any) => {
|
parameters.forEach(param => {
|
||||||
extend(params, param.toUrlParams());
|
extend(params, param.toUrlParams());
|
||||||
});
|
});
|
||||||
location.setSearch(params, true);
|
location.setSearch(params, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
type OwnProps = {
|
export default class Parameters extends React.Component {
|
||||||
parameters?: any[]; // TODO: PropTypes.instanceOf(Parameter)
|
static propTypes = {
|
||||||
editable?: boolean;
|
parameters: PropTypes.arrayOf(PropTypes.instanceOf(Parameter)),
|
||||||
disableUrlUpdate?: boolean;
|
editable: PropTypes.bool,
|
||||||
onValuesChange?: (...args: any[]) => any;
|
disableUrlUpdate: PropTypes.bool,
|
||||||
onPendingValuesChange?: (...args: any[]) => any;
|
onValuesChange: PropTypes.func,
|
||||||
onParametersEdit?: (...args: any[]) => any;
|
onPendingValuesChange: PropTypes.func,
|
||||||
|
onParametersEdit: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
type State = any;
|
|
||||||
|
|
||||||
type Props = OwnProps & typeof Parameters.defaultProps;
|
|
||||||
|
|
||||||
export default class Parameters extends React.Component<Props, State> {
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
parameters: [],
|
parameters: [],
|
||||||
editable: false,
|
editable: false,
|
||||||
@@ -41,9 +37,7 @@ export default class Parameters extends React.Component<Props, State> {
|
|||||||
onParametersEdit: () => {},
|
onParametersEdit: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
onBeforeSortStart: any;
|
constructor(props) {
|
||||||
|
|
||||||
constructor(props: Props) {
|
|
||||||
super(props);
|
super(props);
|
||||||
const { parameters } = props;
|
const { parameters } = props;
|
||||||
this.state = { parameters };
|
this.state = { parameters };
|
||||||
@@ -52,7 +46,7 @@ export default class Parameters extends React.Component<Props, State> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate = (prevProps: any) => {
|
componentDidUpdate = prevProps => {
|
||||||
const { parameters, disableUrlUpdate } = this.props;
|
const { parameters, disableUrlUpdate } = this.props;
|
||||||
const parametersChanged = prevProps.parameters !== parameters;
|
const parametersChanged = prevProps.parameters !== parameters;
|
||||||
const disableUrlUpdateChanged = prevProps.disableUrlUpdate !== disableUrlUpdate;
|
const disableUrlUpdateChanged = prevProps.disableUrlUpdate !== disableUrlUpdate;
|
||||||
@@ -64,7 +58,7 @@ export default class Parameters extends React.Component<Props, State> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handleKeyDown = (e: any) => {
|
handleKeyDown = e => {
|
||||||
// Cmd/Ctrl/Alt + Enter
|
// Cmd/Ctrl/Alt + Enter
|
||||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey || e.altKey)) {
|
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey || e.altKey)) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -72,11 +66,9 @@ export default class Parameters extends React.Component<Props, State> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
setPendingValue = (param: any, value: any, isDirty: any) => {
|
setPendingValue = (param, value, isDirty) => {
|
||||||
const { onPendingValuesChange } = this.props;
|
const { onPendingValuesChange } = this.props;
|
||||||
this.setState(({
|
this.setState(({ parameters }) => {
|
||||||
parameters
|
|
||||||
}: any) => {
|
|
||||||
if (isDirty) {
|
if (isDirty) {
|
||||||
param.setPendingValue(value);
|
param.setPendingValue(value);
|
||||||
} else {
|
} else {
|
||||||
@@ -87,15 +79,10 @@ export default class Parameters extends React.Component<Props, State> {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
moveParameter = ({
|
moveParameter = ({ oldIndex, newIndex }) => {
|
||||||
oldIndex,
|
|
||||||
newIndex
|
|
||||||
}: any) => {
|
|
||||||
const { onParametersEdit } = this.props;
|
const { onParametersEdit } = this.props;
|
||||||
if (oldIndex !== newIndex) {
|
if (oldIndex !== newIndex) {
|
||||||
this.setState(({
|
this.setState(({ parameters }) => {
|
||||||
parameters
|
|
||||||
}: any) => {
|
|
||||||
parameters.splice(newIndex, 0, parameters.splice(oldIndex, 1)[0]);
|
parameters.splice(newIndex, 0, parameters.splice(oldIndex, 1)[0]);
|
||||||
onParametersEdit();
|
onParametersEdit();
|
||||||
return { parameters };
|
return { parameters };
|
||||||
@@ -105,10 +92,8 @@ export default class Parameters extends React.Component<Props, State> {
|
|||||||
|
|
||||||
applyChanges = () => {
|
applyChanges = () => {
|
||||||
const { onValuesChange, disableUrlUpdate } = this.props;
|
const { onValuesChange, disableUrlUpdate } = this.props;
|
||||||
this.setState(({
|
this.setState(({ parameters }) => {
|
||||||
parameters
|
const parametersWithPendingValues = parameters.filter(p => p.hasPendingValue);
|
||||||
}: any) => {
|
|
||||||
const parametersWithPendingValues = parameters.filter((p: any) => p.hasPendingValue);
|
|
||||||
forEach(parameters, p => p.applyPendingValue());
|
forEach(parameters, p => p.applyPendingValue());
|
||||||
if (!disableUrlUpdate) {
|
if (!disableUrlUpdate) {
|
||||||
updateUrl(parameters);
|
updateUrl(parameters);
|
||||||
@@ -118,12 +103,10 @@ export default class Parameters extends React.Component<Props, State> {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
showParameterSettings = (parameter: any, index: any) => {
|
showParameterSettings = (parameter, index) => {
|
||||||
const { onParametersEdit } = this.props;
|
const { onParametersEdit } = this.props;
|
||||||
EditParameterSettingsDialog.showModal({ parameter }).onClose((updated: any) => {
|
EditParameterSettingsDialog.showModal({ parameter }).onClose(updated => {
|
||||||
this.setState(({
|
this.setState(({ parameters }) => {
|
||||||
parameters
|
|
||||||
}: any) => {
|
|
||||||
const updatedParameter = extend(parameter, updated);
|
const updatedParameter = extend(parameter, updated);
|
||||||
parameters[index] = createParameter(updatedParameter, updatedParameter.parentQueryId);
|
parameters[index] = createParameter(updatedParameter, updatedParameter.parentQueryId);
|
||||||
onParametersEdit();
|
onParametersEdit();
|
||||||
@@ -132,12 +115,12 @@ export default class Parameters extends React.Component<Props, State> {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
renderParameter(param: any, index: any) {
|
renderParameter(param, index) {
|
||||||
const { editable } = this.props;
|
const { editable } = this.props;
|
||||||
return (
|
return (
|
||||||
<div key={param.name} className="di-block" data-test={`ParameterName-${param.name}`}>
|
<div key={param.name} className="di-block" data-test={`ParameterName-${param.name}`}>
|
||||||
<div className="parameter-heading">
|
<div className="parameter-heading">
|
||||||
<label>{param.title || toHuman(param.name)}</label>
|
<label>{param.getTitle()}</label>
|
||||||
{editable && (
|
{editable && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-default btn-xs m-l-5"
|
className="btn btn-default btn-xs m-l-5"
|
||||||
@@ -149,18 +132,12 @@ export default class Parameters extends React.Component<Props, State> {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ParameterValueInput
|
<ParameterValueInput
|
||||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'any' is not assignable to type 'never'.
|
|
||||||
type={param.type}
|
type={param.type}
|
||||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'any' is not assignable to type 'never'.
|
|
||||||
value={param.normalizedValue}
|
value={param.normalizedValue}
|
||||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'any' is not assignable to type 'never'.
|
|
||||||
parameter={param}
|
parameter={param}
|
||||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'any' is not assignable to type 'never'.
|
|
||||||
enumOptions={param.enumOptions}
|
enumOptions={param.enumOptions}
|
||||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'any' is not assignable to type 'never'.
|
|
||||||
queryId={param.queryId}
|
queryId={param.queryId}
|
||||||
// @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, isDirty) => this.setPendingValue(param, value, isDirty)}
|
||||||
onSelect={(value: any, isDirty: any) => this.setPendingValue(param, value, isDirty)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -171,7 +148,6 @@ export default class Parameters extends React.Component<Props, State> {
|
|||||||
const { editable } = this.props;
|
const { editable } = this.props;
|
||||||
const dirtyParamCount = size(filter(parameters, "hasPendingValue"));
|
const dirtyParamCount = size(filter(parameters, "hasPendingValue"));
|
||||||
return (
|
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
|
<SortableContainer
|
||||||
disabled={!editable}
|
disabled={!editable}
|
||||||
axis="xy"
|
axis="xy"
|
||||||
@@ -184,7 +160,7 @@ export default class Parameters extends React.Component<Props, State> {
|
|||||||
className: "parameter-container",
|
className: "parameter-container",
|
||||||
onKeyDown: dirtyParamCount ? this.handleKeyDown : null,
|
onKeyDown: dirtyParamCount ? this.handleKeyDown : null,
|
||||||
}}>
|
}}>
|
||||||
{parameters.map((param: any, index: any) => (
|
{parameters.map((param, index) => (
|
||||||
<SortableElement key={param.name} index={index}>
|
<SortableElement key={param.name} index={index}>
|
||||||
<div className="parameter-block" data-editable={editable || null}>
|
<div className="parameter-block" data-editable={editable || null}>
|
||||||
{editable && <DragHandle data-test={`DragHandle-${param.name}`} />}
|
{editable && <DragHandle data-test={`DragHandle-${param.name}`} />}
|
||||||
196
client/app/components/PermissionsEditorDialog/index.jsx
Normal file
196
client/app/components/PermissionsEditorDialog/index.jsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
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);
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
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);
|
|
||||||
93
client/app/components/PreviewCard.jsx
Normal file
93
client/app/components/PreviewCard.jsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
145
client/app/components/QueryBasedParameterInput.jsx
Normal file
145
client/app/components/QueryBasedParameterInput.jsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { find, isArray, get, first, map, intersection, isEqual, isEmpty, trim, debounce, isNil } from "lodash";
|
||||||
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll";
|
||||||
|
|
||||||
|
const SEARCH_DEBOUNCE_TIME = 300;
|
||||||
|
|
||||||
|
function filterValuesThatAreNotInOptions(value, options) {
|
||||||
|
if (isArray(value)) {
|
||||||
|
const optionValues = map(options, option => option.value);
|
||||||
|
return intersection(value, optionValues);
|
||||||
|
}
|
||||||
|
const found = find(options, option => option.value === value) !== undefined;
|
||||||
|
return found ? value : get(first(options), "value");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class QueryBasedParameterInput extends React.Component {
|
||||||
|
static propTypes = {
|
||||||
|
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,
|
||||||
|
currentSearchTerm: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this._loadOptions(this.props.queryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (this.props.queryId !== prevProps.queryId || this.props.parameter !== prevProps.parameter) {
|
||||||
|
this._loadOptions(this.props.queryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.props.value !== prevProps.value) {
|
||||||
|
this.setValue(this.props.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(value) {
|
||||||
|
const { options } = this.state;
|
||||||
|
const { mode, parameter } = this.props;
|
||||||
|
|
||||||
|
if (mode === "multiple") {
|
||||||
|
if (isNil(value)) {
|
||||||
|
value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
value = isArray(value) ? value : [value];
|
||||||
|
}
|
||||||
|
|
||||||
|
// parameters with search don't have options available, so we trust what we get
|
||||||
|
if (!parameter.searchFunction) {
|
||||||
|
value = filterValuesThatAreNotInOptions(value, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ value });
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOptions(options) {
|
||||||
|
this.setState({ options, loading: false }, () => {
|
||||||
|
const updatedValue = this.setValue(this.props.value);
|
||||||
|
if (!isEqual(updatedValue, this.props.value)) {
|
||||||
|
this.props.onSelect(updatedValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async _loadOptions(queryId) {
|
||||||
|
if (queryId && queryId !== this.state.queryId) {
|
||||||
|
this.setState({ loading: true });
|
||||||
|
const options = await this.props.parameter.loadDropdownValues(this.state.currentSearchTerm);
|
||||||
|
|
||||||
|
// stale queryId check
|
||||||
|
if (this.props.queryId === queryId) {
|
||||||
|
this.updateOptions(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
searchFunction = debounce(searchTerm => {
|
||||||
|
const { parameter } = this.props;
|
||||||
|
if (parameter.searchFunction && trim(searchTerm)) {
|
||||||
|
this.setState({ loading: true, currentSearchTerm: searchTerm });
|
||||||
|
parameter.searchFunction(searchTerm).then(options => {
|
||||||
|
if (this.state.currentSearchTerm === searchTerm) {
|
||||||
|
this.updateOptions(options);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, SEARCH_DEBOUNCE_TIME);
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { parameter, className, mode, onSelect, queryId, value, ...otherProps } = this.props;
|
||||||
|
const { loading, options } = this.state;
|
||||||
|
const selectProps = { ...otherProps };
|
||||||
|
|
||||||
|
if (parameter.searchColumn) {
|
||||||
|
selectProps.filterOption = false;
|
||||||
|
selectProps.onSearch = this.searchFunction;
|
||||||
|
selectProps.onChange = value => onSelect(parameter.normalizeValue(value));
|
||||||
|
selectProps.notFoundContent = null;
|
||||||
|
selectProps.labelInValue = true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
<SelectWithVirtualScroll
|
||||||
|
className={className}
|
||||||
|
disabled={!parameter.searchFunction && loading}
|
||||||
|
loading={loading}
|
||||||
|
mode={mode}
|
||||||
|
value={this.state.value || undefined}
|
||||||
|
onChange={onSelect}
|
||||||
|
options={options}
|
||||||
|
optionFilterProp="children"
|
||||||
|
showSearch
|
||||||
|
showArrow
|
||||||
|
notFoundContent={isEmpty(options) ? "No options available" : null}
|
||||||
|
{...selectProps}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
42
client/app/components/QueryLink.jsx
Normal file
42
client/app/components/QueryLink.jsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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;
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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;
|
|
||||||
159
client/app/components/QuerySelector.jsx
Normal file
159
client/app/components/QuerySelector.jsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
163
client/app/components/Resizable/index.jsx
Normal file
163
client/app/components/Resizable/index.jsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
192
client/app/components/SelectItemsDialog.jsx
Normal file
192
client/app/components/SelectItemsDialog.jsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
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);
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
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);
|
|
||||||
39
client/app/components/SettingsWrapper.jsx
Normal file
39
client/app/components/SettingsWrapper.jsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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;
|
||||||
@@ -1,32 +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: 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;
|
|
||||||
56
client/app/components/TimeAgo.jsx
Normal file
56
client/app/components/TimeAgo.jsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import moment from "moment";
|
||||||
|
import { isNil } from "lodash";
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { Moment } from "@/components/proptypes";
|
||||||
|
import { clientConfig } from "@/services/auth";
|
||||||
|
import Tooltip from "antd/lib/tooltip";
|
||||||
|
|
||||||
|
function toMoment(value) {
|
||||||
|
value = !isNil(value) ? moment(value) : null;
|
||||||
|
return value && value.isValid() ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimeAgo({ date, placeholder, autoUpdate, variation }) {
|
||||||
|
const startDate = toMoment(date);
|
||||||
|
const [value, setValue] = useState(null);
|
||||||
|
const title = useMemo(() => (startDate ? startDate.format(clientConfig.dateTimeFormat) : null), [startDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function update() {
|
||||||
|
setValue(startDate ? startDate.fromNow() : placeholder);
|
||||||
|
}
|
||||||
|
update();
|
||||||
|
|
||||||
|
if (autoUpdate) {
|
||||||
|
const timer = setInterval(update, 30 * 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}
|
||||||
|
}, [autoUpdate, startDate, placeholder]);
|
||||||
|
|
||||||
|
if (variation === "timeAgoInTooltip") {
|
||||||
|
return (
|
||||||
|
<Tooltip title={value}>
|
||||||
|
<span data-test="TimeAgo">{title}</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Tooltip title={title}>
|
||||||
|
<span data-test="TimeAgo">{value}</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeAgo.propTypes = {
|
||||||
|
date: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.instanceOf(Date), Moment]),
|
||||||
|
placeholder: PropTypes.string,
|
||||||
|
autoUpdate: PropTypes.bool,
|
||||||
|
variation: PropTypes.oneOf(["timeAgoInTooltip"]),
|
||||||
|
};
|
||||||
|
|
||||||
|
TimeAgo.defaultProps = {
|
||||||
|
date: null,
|
||||||
|
placeholder: "",
|
||||||
|
autoUpdate: true,
|
||||||
|
};
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import moment from "moment";
|
|
||||||
import { isNil } from "lodash";
|
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
|
||||||
// @ts-expect-error ts-migrate(6133) FIXME: 'Moment' is declared but its value is never read.
|
|
||||||
import { Moment } from "@/components/proptypes";
|
|
||||||
import { clientConfig } from "@/services/auth";
|
|
||||||
import Tooltip from "antd/lib/tooltip";
|
|
||||||
function toMoment(value: any) {
|
|
||||||
value = !isNil(value) ? moment(value) : null;
|
|
||||||
return value && value.isValid() ? value : null;
|
|
||||||
}
|
|
||||||
type OwnProps = {
|
|
||||||
// @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
|
|
||||||
date?: string | number | any | Moment;
|
|
||||||
placeholder?: string;
|
|
||||||
autoUpdate?: boolean;
|
|
||||||
variation?: "timeAgoInTooltip";
|
|
||||||
};
|
|
||||||
type Props = OwnProps & typeof TimeAgo.defaultProps;
|
|
||||||
export default function TimeAgo({ date, placeholder, autoUpdate, variation }: Props) {
|
|
||||||
const startDate = toMoment(date);
|
|
||||||
const [value, setValue] = useState(null);
|
|
||||||
const title = useMemo(() => (startDate ? startDate.format((clientConfig as any).dateTimeFormat) : null), [startDate]);
|
|
||||||
useEffect(() => {
|
|
||||||
function update() {
|
|
||||||
setValue(startDate ? startDate.fromNow() : placeholder);
|
|
||||||
}
|
|
||||||
update();
|
|
||||||
if (autoUpdate) {
|
|
||||||
const timer = setInterval(update, 30 * 1000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}
|
|
||||||
}, [autoUpdate, startDate, placeholder]);
|
|
||||||
if (variation === "timeAgoInTooltip") {
|
|
||||||
return (<Tooltip title={value}>
|
|
||||||
<span data-test="TimeAgo">{title}</span>
|
|
||||||
</Tooltip>);
|
|
||||||
}
|
|
||||||
return (<Tooltip title={title}>
|
|
||||||
<span data-test="TimeAgo">{value}</span>
|
|
||||||
</Tooltip>);
|
|
||||||
}
|
|
||||||
TimeAgo.defaultProps = {
|
|
||||||
date: null,
|
|
||||||
placeholder: "",
|
|
||||||
autoUpdate: true,
|
|
||||||
};
|
|
||||||
@@ -1,16 +1,9 @@
|
|||||||
import React, { useMemo, useState, useEffect } from "react";
|
import React, { useMemo, useState, useEffect } from "react";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
// @ts-expect-error ts-migrate(6133) FIXME: 'Moment' is declared but its value is never read.
|
import PropTypes from "prop-types";
|
||||||
import { Moment } from "@/components/proptypes";
|
import { Moment } from "@/components/proptypes";
|
||||||
|
|
||||||
type OwnProps = {
|
export default function Timer({ from }) {
|
||||||
// @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
|
|
||||||
from?: string | number | any | Moment;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = OwnProps & typeof Timer.defaultProps;
|
|
||||||
|
|
||||||
export default function Timer({ from }: Props) {
|
|
||||||
const startTime = useMemo(() => moment(from).valueOf(), [from]);
|
const startTime = useMemo(() => moment(from).valueOf(), [from]);
|
||||||
const [value, setValue] = useState(null);
|
const [value, setValue] = useState(null);
|
||||||
|
|
||||||
@@ -18,7 +11,6 @@ export default function Timer({ from }: Props) {
|
|||||||
function update() {
|
function update() {
|
||||||
const diff = moment.now() - startTime;
|
const diff = moment.now() - startTime;
|
||||||
const format = diff > 1000 * 60 * 60 ? "HH:mm:ss" : "mm:ss"; // no HH under an hour
|
const format = diff > 1000 * 60 * 60 ? "HH:mm:ss" : "mm:ss"; // no HH under an hour
|
||||||
// @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
|
|
||||||
setValue(moment.utc(diff).format(format));
|
setValue(moment.utc(diff).format(format));
|
||||||
}
|
}
|
||||||
update();
|
update();
|
||||||
@@ -30,6 +22,10 @@ export default function Timer({ from }: Props) {
|
|||||||
return <span className="rd-timer">{value}</span>;
|
return <span className="rd-timer">{value}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timer.propTypes = {
|
||||||
|
from: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.instanceOf(Date), Moment]),
|
||||||
|
};
|
||||||
|
|
||||||
Timer.defaultProps = {
|
Timer.defaultProps = {
|
||||||
from: null,
|
from: null,
|
||||||
};
|
};
|
||||||
@@ -1,21 +1,12 @@
|
|||||||
import { map } from "lodash";
|
import { map } from "lodash";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
import Tag from "antd/lib/tag";
|
import Tag from "antd/lib/tag";
|
||||||
import Link from "@/components/Link";
|
import Link from "@/components/Link";
|
||||||
|
|
||||||
import "./UserGroups.less";
|
import "./UserGroups.less";
|
||||||
|
|
||||||
type OwnProps = {
|
export default function UserGroups({ groups, linkGroups, ...props }) {
|
||||||
groups?: {
|
|
||||||
id: number | string;
|
|
||||||
name?: string;
|
|
||||||
}[];
|
|
||||||
linkGroups?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = OwnProps & typeof UserGroups.defaultProps;
|
|
||||||
|
|
||||||
export default function UserGroups({ groups, linkGroups, ...props }: Props) {
|
|
||||||
return (
|
return (
|
||||||
<div className="user-groups" {...props}>
|
<div className="user-groups" {...props}>
|
||||||
{map(groups, group => (
|
{map(groups, group => (
|
||||||
@@ -25,6 +16,16 @@ export default function UserGroups({ groups, linkGroups, ...props }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UserGroups.propTypes = {
|
||||||
|
groups: PropTypes.arrayOf(
|
||||||
|
PropTypes.shape({
|
||||||
|
id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
|
||||||
|
name: PropTypes.string,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
linkGroups: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
UserGroups.defaultProps = {
|
UserGroups.defaultProps = {
|
||||||
groups: [],
|
groups: [],
|
||||||
linkGroups: true,
|
linkGroups: true,
|
||||||
@@ -1,18 +1,12 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
import Menu from "antd/lib/menu";
|
import Menu from "antd/lib/menu";
|
||||||
import PageHeader from "@/components/PageHeader";
|
import PageHeader from "@/components/PageHeader";
|
||||||
import Link from "@/components/Link";
|
import Link from "@/components/Link";
|
||||||
|
|
||||||
import "./layout.less";
|
import "./layout.less";
|
||||||
|
|
||||||
type OwnProps = {
|
export default function Layout({ activeTab, children }) {
|
||||||
activeTab?: string;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = OwnProps & typeof Layout.defaultProps;
|
|
||||||
|
|
||||||
export default function Layout({ activeTab, children }: Props) {
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-page-layout">
|
<div className="admin-page-layout">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
@@ -36,6 +30,11 @@ export default function Layout({ activeTab, children }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Layout.propTypes = {
|
||||||
|
activeTab: PropTypes.string,
|
||||||
|
children: PropTypes.node,
|
||||||
|
};
|
||||||
|
|
||||||
Layout.defaultProps = {
|
Layout.defaultProps = {
|
||||||
activeTab: "system_status",
|
activeTab: "system_status",
|
||||||
children: null,
|
children: null,
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { map } from "lodash";
|
import { map } from "lodash";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
import Badge from "antd/lib/badge";
|
import Badge from "antd/lib/badge";
|
||||||
import Card from "antd/lib/card";
|
import Card from "antd/lib/card";
|
||||||
@@ -7,17 +8,9 @@ import Spin from "antd/lib/spin";
|
|||||||
import Table from "antd/lib/table";
|
import Table from "antd/lib/table";
|
||||||
import { Columns } from "@/components/items-list/components/ItemsTable";
|
import { Columns } from "@/components/items-list/components/ItemsTable";
|
||||||
|
|
||||||
type OwnCounterCardProps = {
|
|
||||||
title: string;
|
|
||||||
value?: number | string;
|
|
||||||
loading: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CounterCardProps = OwnCounterCardProps & typeof CounterCard.defaultProps;
|
|
||||||
|
|
||||||
// CounterCard
|
// CounterCard
|
||||||
|
|
||||||
export function CounterCard({ title, value, loading }: CounterCardProps) {
|
export function CounterCard({ title, value, loading }) {
|
||||||
return (
|
return (
|
||||||
<Spin spinning={loading}>
|
<Spin spinning={loading}>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -28,6 +21,12 @@ export function CounterCard({ title, value, loading }: CounterCardProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CounterCard.propTypes = {
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
|
||||||
|
loading: PropTypes.bool.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
CounterCard.defaultProps = {
|
CounterCard.defaultProps = {
|
||||||
value: "",
|
value: "",
|
||||||
};
|
};
|
||||||
@@ -36,11 +35,11 @@ CounterCard.defaultProps = {
|
|||||||
|
|
||||||
const queryJobsColumns = [
|
const queryJobsColumns = [
|
||||||
{ title: "Queue", dataIndex: "origin" },
|
{ title: "Queue", dataIndex: "origin" },
|
||||||
{ title: "Query ID", dataIndex: ["meta", "query_id"] },
|
{ title: "Query ID", dataIndex: "meta.query_id" },
|
||||||
{ title: "Org ID", dataIndex: ["meta", "org_id"] },
|
{ title: "Org ID", dataIndex: "meta.org_id" },
|
||||||
{ title: "Data Source ID", dataIndex: ["meta", "data_source_id"] },
|
{ title: "Data Source ID", dataIndex: "meta.data_source_id" },
|
||||||
{ title: "User ID", dataIndex: ["meta", "user_id"] },
|
{ title: "User ID", dataIndex: "meta.user_id" },
|
||||||
Columns.custom((scheduled: any) => scheduled.toString(), { title: "Scheduled", dataIndex: ["meta", "scheduled"] }),
|
Columns.custom(scheduled => scheduled.toString(), { title: "Scheduled", dataIndex: "meta.scheduled" }),
|
||||||
Columns.timeAgo({ title: "Start Time", dataIndex: "started_at" }),
|
Columns.timeAgo({ title: "Start Time", dataIndex: "started_at" }),
|
||||||
Columns.timeAgo({ title: "Enqueue Time", dataIndex: "enqueued_at" }),
|
Columns.timeAgo({ title: "Enqueue Time", dataIndex: "enqueued_at" }),
|
||||||
];
|
];
|
||||||
@@ -54,11 +53,12 @@ const otherJobsColumns = [
|
|||||||
|
|
||||||
const workersColumns = [
|
const workersColumns = [
|
||||||
Columns.custom(
|
Columns.custom(
|
||||||
(value: any) => <span>
|
value => (
|
||||||
{/* @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message */}
|
<span>
|
||||||
<Badge status={{ busy: "processing", idle: "default", started: "success", suspended: "warning" }[value]} />{" "}
|
<Badge status={{ busy: "processing", idle: "default", started: "success", suspended: "warning" }[value]} />{" "}
|
||||||
{value}
|
{value}
|
||||||
</span>,
|
</span>
|
||||||
|
),
|
||||||
{ title: "State", dataIndex: "state" }
|
{ title: "State", dataIndex: "state" }
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -75,27 +75,12 @@ const workersColumns = [
|
|||||||
|
|
||||||
const queuesColumns = map(["Name", "Started", "Queued"], c => ({ title: c, dataIndex: c.toLowerCase() }));
|
const queuesColumns = map(["Name", "Started", "Queued"], c => ({ title: c, dataIndex: c.toLowerCase() }));
|
||||||
|
|
||||||
type WorkersTableProps = {
|
const TablePropTypes = {
|
||||||
loading: boolean;
|
loading: PropTypes.bool.isRequired,
|
||||||
items: any[];
|
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
type QueuesTableProps = {
|
export function WorkersTable({ loading, items }) {
|
||||||
loading: boolean;
|
|
||||||
items: any[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type QueryJobsTableProps = {
|
|
||||||
loading: boolean;
|
|
||||||
items: any[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type OtherJobsTableProps = {
|
|
||||||
loading: boolean;
|
|
||||||
items: any[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function WorkersTable({ loading, items }: WorkersTableProps) {
|
|
||||||
return (
|
return (
|
||||||
<Table
|
<Table
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -111,7 +96,9 @@ export function WorkersTable({ loading, items }: WorkersTableProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QueuesTable({ loading, items }: QueuesTableProps) {
|
WorkersTable.propTypes = TablePropTypes;
|
||||||
|
|
||||||
|
export function QueuesTable({ loading, items }) {
|
||||||
return (
|
return (
|
||||||
<Table
|
<Table
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -127,7 +114,9 @@ export function QueuesTable({ loading, items }: QueuesTableProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QueryJobsTable({ loading, items }: QueryJobsTableProps) {
|
QueuesTable.propTypes = TablePropTypes;
|
||||||
|
|
||||||
|
export function QueryJobsTable({ loading, items }) {
|
||||||
return (
|
return (
|
||||||
<Table
|
<Table
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -143,7 +132,9 @@ export function QueryJobsTable({ loading, items }: QueryJobsTableProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OtherJobsTable({ loading, items }: OtherJobsTableProps) {
|
QueryJobsTable.propTypes = TablePropTypes;
|
||||||
|
|
||||||
|
export function OtherJobsTable({ loading, items }) {
|
||||||
return (
|
return (
|
||||||
<Table
|
<Table
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -158,3 +149,5 @@ export function OtherJobsTable({ loading, items }: OtherJobsTableProps) {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OtherJobsTable.propTypes = TablePropTypes;
|
||||||
@@ -9,9 +9,7 @@ import TimeAgo from "@/components/TimeAgo";
|
|||||||
|
|
||||||
import { toHuman, prettySize } from "@/lib/utils";
|
import { toHuman, prettySize } from "@/lib/utils";
|
||||||
|
|
||||||
export function General({
|
export function General({ info }) {
|
||||||
info
|
|
||||||
}: any) {
|
|
||||||
info = toPairs(info);
|
info = toPairs(info);
|
||||||
return (
|
return (
|
||||||
<Card title="General" size="small">
|
<Card title="General" size="small">
|
||||||
@@ -21,7 +19,6 @@ export function General({
|
|||||||
size="small"
|
size="small"
|
||||||
itemLayout="vertical"
|
itemLayout="vertical"
|
||||||
dataSource={info}
|
dataSource={info}
|
||||||
// @ts-expect-error ts-migrate(2322) FIXME: Type '([name, value]: [any, any]) => Element' is n... Remove this comment to see the full error message
|
|
||||||
renderItem={([name, value]) => (
|
renderItem={([name, value]) => (
|
||||||
<List.Item extra={<span className="badge">{value}</span>}>{toHuman(name)}</List.Item>
|
<List.Item extra={<span className="badge">{value}</span>}>{toHuman(name)}</List.Item>
|
||||||
)}
|
)}
|
||||||
@@ -31,9 +28,7 @@ export function General({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DatabaseMetrics({
|
export function DatabaseMetrics({ info }) {
|
||||||
info
|
|
||||||
}: any) {
|
|
||||||
return (
|
return (
|
||||||
<Card title="Redash Database" size="small">
|
<Card title="Redash Database" size="small">
|
||||||
{info.length === 0 && <div className="text-muted text-center">No data</div>}
|
{info.length === 0 && <div className="text-muted text-center">No data</div>}
|
||||||
@@ -42,7 +37,6 @@ export function DatabaseMetrics({
|
|||||||
size="small"
|
size="small"
|
||||||
itemLayout="vertical"
|
itemLayout="vertical"
|
||||||
dataSource={info}
|
dataSource={info}
|
||||||
// @ts-expect-error ts-migrate(2322) FIXME: Type '([name, size]: [any, any]) => Element' is no... Remove this comment to see the full error message
|
|
||||||
renderItem={([name, size]) => (
|
renderItem={([name, size]) => (
|
||||||
<List.Item extra={<span className="badge">{prettySize(size)}</span>}>{name}</List.Item>
|
<List.Item extra={<span className="badge">{prettySize(size)}</span>}>{name}</List.Item>
|
||||||
)}
|
)}
|
||||||
@@ -52,9 +46,7 @@ export function DatabaseMetrics({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Queues({
|
export function Queues({ info }) {
|
||||||
info
|
|
||||||
}: any) {
|
|
||||||
info = toPairs(info);
|
info = toPairs(info);
|
||||||
return (
|
return (
|
||||||
<Card title="Queues" size="small">
|
<Card title="Queues" size="small">
|
||||||
@@ -64,7 +56,6 @@ export function Queues({
|
|||||||
size="small"
|
size="small"
|
||||||
itemLayout="vertical"
|
itemLayout="vertical"
|
||||||
dataSource={info}
|
dataSource={info}
|
||||||
// @ts-expect-error ts-migrate(2322) FIXME: Type '([name, queue]: [any, any]) => Element' is n... Remove this comment to see the full error message
|
|
||||||
renderItem={([name, queue]) => (
|
renderItem={([name, queue]) => (
|
||||||
<List.Item extra={<span className="badge">{queue.size}</span>}>{name}</List.Item>
|
<List.Item extra={<span className="badge">{queue.size}</span>}>{name}</List.Item>
|
||||||
)}
|
)}
|
||||||
@@ -74,9 +65,7 @@ export function Queues({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Manager({
|
export function Manager({ info }) {
|
||||||
info
|
|
||||||
}: any) {
|
|
||||||
const items = info
|
const items = info
|
||||||
? [
|
? [
|
||||||
<List.Item
|
<List.Item
|
||||||
156
client/app/components/dashboards/AddWidgetDialog.jsx
Normal file
156
client/app/components/dashboards/AddWidgetDialog.jsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { map, includes, groupBy, first, find } from "lodash";
|
||||||
|
import React, { useState, useMemo, useCallback } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import Select from "antd/lib/select";
|
||||||
|
import Modal from "antd/lib/modal";
|
||||||
|
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||||
|
import { MappingType, ParameterMappingListInput } from "@/components/ParameterMappingInput";
|
||||||
|
import QuerySelector from "@/components/QuerySelector";
|
||||||
|
import notification from "@/services/notification";
|
||||||
|
import { Query } from "@/services/query";
|
||||||
|
|
||||||
|
function VisualizationSelect({ query, visualization, onChange }) {
|
||||||
|
const visualizationGroups = useMemo(() => {
|
||||||
|
return query ? groupBy(query.visualizations, "type") : {};
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
visualizationId => {
|
||||||
|
const selectedVisualization = query ? find(query.visualizations, { id: visualizationId }) : null;
|
||||||
|
onChange(selectedVisualization || null);
|
||||||
|
},
|
||||||
|
[query, onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="choose-visualization">Choose Visualization</label>
|
||||||
|
<Select
|
||||||
|
id="choose-visualization"
|
||||||
|
className="w-100"
|
||||||
|
value={visualization ? visualization.id : undefined}
|
||||||
|
onChange={handleChange}>
|
||||||
|
{map(visualizationGroups, (visualizations, groupKey) => (
|
||||||
|
<Select.OptGroup key={groupKey} label={groupKey}>
|
||||||
|
{map(visualizations, visualization => (
|
||||||
|
<Select.Option key={`${visualization.id}`} value={visualization.id}>
|
||||||
|
{visualization.name}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select.OptGroup>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
VisualizationSelect.propTypes = {
|
||||||
|
query: PropTypes.object,
|
||||||
|
visualization: PropTypes.object,
|
||||||
|
onChange: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
VisualizationSelect.defaultProps = {
|
||||||
|
query: null,
|
||||||
|
visualization: null,
|
||||||
|
onChange: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
function AddWidgetDialog({ dialog, dashboard }) {
|
||||||
|
const [selectedQuery, setSelectedQuery] = useState(null);
|
||||||
|
const [selectedVisualization, setSelectedVisualization] = useState(null);
|
||||||
|
const [parameterMappings, setParameterMappings] = useState([]);
|
||||||
|
|
||||||
|
const selectQuery = useCallback(
|
||||||
|
queryId => {
|
||||||
|
// Clear previously selected query (if any)
|
||||||
|
setSelectedQuery(null);
|
||||||
|
setSelectedVisualization(null);
|
||||||
|
setParameterMappings([]);
|
||||||
|
|
||||||
|
if (queryId) {
|
||||||
|
Query.get({ id: queryId }).then(query => {
|
||||||
|
if (query) {
|
||||||
|
const existingParamNames = map(dashboard.getParametersDefs(), param => param.name);
|
||||||
|
setSelectedQuery(query);
|
||||||
|
setParameterMappings(
|
||||||
|
map(query.getParametersDefs(), param => ({
|
||||||
|
name: param.name,
|
||||||
|
type: includes(existingParamNames, param.name)
|
||||||
|
? MappingType.DashboardMapToExisting
|
||||||
|
: MappingType.DashboardAddNew,
|
||||||
|
mapTo: param.name,
|
||||||
|
value: param.normalizedValue,
|
||||||
|
title: "",
|
||||||
|
param,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
if (query.visualizations.length > 0) {
|
||||||
|
setSelectedVisualization(first(query.visualizations));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dashboard]
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveWidget = useCallback(() => {
|
||||||
|
dialog.close({ visualization: selectedVisualization, parameterMappings }).catch(() => {
|
||||||
|
notification.error("Widget could not be added");
|
||||||
|
});
|
||||||
|
}, [dialog, selectedVisualization, parameterMappings]);
|
||||||
|
|
||||||
|
const existingParams = dashboard.getParametersDefs();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
{...dialog.props}
|
||||||
|
title="Add Widget"
|
||||||
|
onOk={saveWidget}
|
||||||
|
okButtonProps={{
|
||||||
|
...dialog.props.okButtonProps,
|
||||||
|
disabled: !selectedQuery || dialog.props.okButtonProps.disabled,
|
||||||
|
}}
|
||||||
|
okText="Add to Dashboard"
|
||||||
|
width={700}>
|
||||||
|
<div data-test="AddWidgetDialog">
|
||||||
|
<QuerySelector onChange={query => selectQuery(query ? query.id : null)} />
|
||||||
|
|
||||||
|
{selectedQuery && (
|
||||||
|
<VisualizationSelect
|
||||||
|
query={selectedQuery}
|
||||||
|
visualization={selectedVisualization}
|
||||||
|
onChange={setSelectedVisualization}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{parameterMappings.length > 0 && [
|
||||||
|
<label key="parameters-title" htmlFor="parameter-mappings">
|
||||||
|
Parameters
|
||||||
|
</label>,
|
||||||
|
<ParameterMappingListInput
|
||||||
|
key="parameters-list"
|
||||||
|
id="parameter-mappings"
|
||||||
|
mappings={parameterMappings}
|
||||||
|
existingParams={existingParams}
|
||||||
|
onChange={setParameterMappings}
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AddWidgetDialog.propTypes = {
|
||||||
|
dialog: DialogPropType.isRequired,
|
||||||
|
dashboard: PropTypes.object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default wrapDialog(AddWidgetDialog);
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import { map, includes, groupBy, first, find } from "lodash";
|
|
||||||
import React, { useState, useMemo, useCallback } from "react";
|
|
||||||
import Select from "antd/lib/select";
|
|
||||||
import Modal from "antd/lib/modal";
|
|
||||||
// @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 { MappingType, ParameterMappingListInput } from "@/components/ParameterMappingInput";
|
|
||||||
import QuerySelector from "@/components/QuerySelector";
|
|
||||||
import notification from "@/services/notification";
|
|
||||||
import { Query } from "@/services/query";
|
|
||||||
type OwnVisualizationSelectProps = {
|
|
||||||
query?: any;
|
|
||||||
visualization?: any;
|
|
||||||
onChange?: (...args: any[]) => any;
|
|
||||||
};
|
|
||||||
type VisualizationSelectProps = OwnVisualizationSelectProps & typeof VisualizationSelect.defaultProps;
|
|
||||||
function VisualizationSelect({ query, visualization, onChange }: VisualizationSelectProps) {
|
|
||||||
const visualizationGroups = useMemo(() => {
|
|
||||||
return query ? groupBy(query.visualizations, "type") : {};
|
|
||||||
}, [query]);
|
|
||||||
const handleChange = useCallback(visualizationId => {
|
|
||||||
const selectedVisualization = query ? find(query.visualizations, { id: visualizationId }) : null;
|
|
||||||
onChange(selectedVisualization || null);
|
|
||||||
}, [query, onChange]);
|
|
||||||
if (!query) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (<div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="choose-visualization">Choose Visualization</label>
|
|
||||||
<Select id="choose-visualization" className="w-100" value={visualization ? visualization.id : undefined} onChange={handleChange}>
|
|
||||||
{map(visualizationGroups, (visualizations, groupKey) => (<Select.OptGroup key={groupKey} label={groupKey}>
|
|
||||||
{map(visualizations, visualization => (<Select.Option key={`${visualization.id}`} value={visualization.id}>
|
|
||||||
{visualization.name}
|
|
||||||
</Select.Option>))}
|
|
||||||
</Select.OptGroup>))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>);
|
|
||||||
}
|
|
||||||
VisualizationSelect.defaultProps = {
|
|
||||||
query: null,
|
|
||||||
visualization: null,
|
|
||||||
onChange: () => { },
|
|
||||||
};
|
|
||||||
type AddWidgetDialogProps = {
|
|
||||||
// @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;
|
|
||||||
dashboard: any;
|
|
||||||
};
|
|
||||||
function AddWidgetDialog({ dialog, dashboard }: AddWidgetDialogProps) {
|
|
||||||
const [selectedQuery, setSelectedQuery] = useState(null);
|
|
||||||
const [selectedVisualization, setSelectedVisualization] = useState(null);
|
|
||||||
const [parameterMappings, setParameterMappings] = useState([]);
|
|
||||||
const selectQuery = useCallback(queryId => {
|
|
||||||
// Clear previously selected query (if any)
|
|
||||||
setSelectedQuery(null);
|
|
||||||
setSelectedVisualization(null);
|
|
||||||
setParameterMappings([]);
|
|
||||||
if (queryId) {
|
|
||||||
(Query as any).get({ id: queryId }).then((query: any) => {
|
|
||||||
if (query) {
|
|
||||||
const existingParamNames = map(dashboard.getParametersDefs(), param => param.name);
|
|
||||||
setSelectedQuery(query);
|
|
||||||
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type '{ name: any; type: string; mapTo... Remove this comment to see the full error message
|
|
||||||
setParameterMappings(map(query.getParametersDefs(), param => ({
|
|
||||||
name: param.name,
|
|
||||||
type: includes(existingParamNames, param.name)
|
|
||||||
? MappingType.DashboardMapToExisting
|
|
||||||
: MappingType.DashboardAddNew,
|
|
||||||
mapTo: param.name,
|
|
||||||
value: param.normalizedValue,
|
|
||||||
title: "",
|
|
||||||
param,
|
|
||||||
})));
|
|
||||||
if (query.visualizations.length > 0) {
|
|
||||||
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type '((prevState: null) => null) | nu... Remove this comment to see the full error message
|
|
||||||
setSelectedVisualization(first(query.visualizations));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [dashboard]);
|
|
||||||
const saveWidget = useCallback(() => {
|
|
||||||
dialog.close({ visualization: selectedVisualization, parameterMappings }).catch(() => {
|
|
||||||
// @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("Widget could not be added");
|
|
||||||
});
|
|
||||||
}, [dialog, selectedVisualization, parameterMappings]);
|
|
||||||
const existingParams = dashboard.getParametersDefs();
|
|
||||||
return (<Modal {...dialog.props} title="Add Widget" onOk={saveWidget} okButtonProps={{
|
|
||||||
...dialog.props.okButtonProps,
|
|
||||||
disabled: !selectedQuery || dialog.props.okButtonProps.disabled,
|
|
||||||
}} okText="Add to Dashboard" width={700}>
|
|
||||||
<div data-test="AddWidgetDialog">
|
|
||||||
{/* @ts-expect-error ts-migrate(2322) FIXME: Type '(query: any) => void' is not assignable to t... Remove this comment to see the full error message */}
|
|
||||||
<QuerySelector onChange={(query: any) => selectQuery(query ? query.id : null)}/>
|
|
||||||
|
|
||||||
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'Dispatch<SetStateAction<null>>' is not assig... Remove this comment to see the full error message */}
|
|
||||||
{selectedQuery && (<VisualizationSelect query={selectedQuery} visualization={selectedVisualization} onChange={setSelectedVisualization}/>)}
|
|
||||||
|
|
||||||
{parameterMappings.length > 0 && [
|
|
||||||
<label key="parameters-title" htmlFor="parameter-mappings">
|
|
||||||
Parameters
|
|
||||||
</label>,
|
|
||||||
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
|
|
||||||
<ParameterMappingListInput key="parameters-list" id="parameter-mappings" mappings={parameterMappings} existingParams={existingParams} onChange={setParameterMappings}/>,
|
|
||||||
]}
|
|
||||||
</div>
|
|
||||||
</Modal>);
|
|
||||||
}
|
|
||||||
export default wrapDialog(AddWidgetDialog);
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user