mirror of
https://github.com/getredash/redash.git
synced 2025-12-19 17:37:19 -05:00
Compare commits
21 Commits
query-base
...
ts-migrate
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d1cd87a5c | ||
|
|
14e51da97a | ||
|
|
6b3f1f9e27 | ||
|
|
cbd51a896a | ||
|
|
0bf15ed559 | ||
|
|
79591657e0 | ||
|
|
b7ab070b62 | ||
|
|
6d86312d6f | ||
|
|
b0ec7a25d2 | ||
|
|
13dead75fa | ||
|
|
d0793c4ba8 | ||
|
|
7b8bcdf356 | ||
|
|
c290864ccd | ||
|
|
b70e95a323 | ||
|
|
18ee5343aa | ||
|
|
fdf636a393 | ||
|
|
88c13868a3 | ||
|
|
aab11dc79b | ||
|
|
00c77cf36e | ||
|
|
6e2631dec2 | ||
|
|
4b88959341 |
@@ -20,6 +20,7 @@ module.exports = {
|
||||
// allow debugger during development
|
||||
"no-debugger": process.env.NODE_ENV === "production" ? 2 : 0,
|
||||
"jsx-a11y/anchor-is-valid": "off",
|
||||
"no-console": ["warn", { allow: ["warn", "error"] }],
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
@@ -50,7 +51,7 @@ module.exports = {
|
||||
"no-useless-constructor": "off",
|
||||
"@typescript-eslint/no-useless-constructor": "error",
|
||||
// Many API fields and generated types use camelcase
|
||||
"@typescript-eslint/camelcase": "off",
|
||||
"@typescript-eslint/camelcase": "off","@typescript-eslint/no-empty-function": "off",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -3,7 +3,7 @@ import AceEditor from "react-ace";
|
||||
|
||||
import "./AceEditorInput.less";
|
||||
|
||||
function AceEditorInput(props, ref) {
|
||||
function AceEditorInput(props: any, ref: any) {
|
||||
return (
|
||||
<div className="ace-editor-input" data-test={props["data-test"]}>
|
||||
<AceEditor
|
||||
@@ -1,176 +0,0 @@
|
||||
import { first } from "lodash";
|
||||
import React, { useState } from "react";
|
||||
import Button from "antd/lib/button";
|
||||
import Menu from "antd/lib/menu";
|
||||
import Link from "@/components/Link";
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
|
||||
import { Auth, currentUser } from "@/services/auth";
|
||||
import settingsMenu from "@/services/settingsMenu";
|
||||
import logoUrl from "@/assets/images/redash_icon_small.png";
|
||||
|
||||
import DesktopOutlinedIcon from "@ant-design/icons/DesktopOutlined";
|
||||
import CodeOutlinedIcon from "@ant-design/icons/CodeOutlined";
|
||||
import AlertOutlinedIcon from "@ant-design/icons/AlertOutlined";
|
||||
import PlusOutlinedIcon from "@ant-design/icons/PlusOutlined";
|
||||
import QuestionCircleOutlinedIcon from "@ant-design/icons/QuestionCircleOutlined";
|
||||
import SettingOutlinedIcon from "@ant-design/icons/SettingOutlined";
|
||||
import MenuUnfoldOutlinedIcon from "@ant-design/icons/MenuUnfoldOutlined";
|
||||
import MenuFoldOutlinedIcon from "@ant-design/icons/MenuFoldOutlined";
|
||||
|
||||
import VersionInfo from "./VersionInfo";
|
||||
import "./DesktopNavbar.less";
|
||||
|
||||
function NavbarSection({ inlineCollapsed, children, ...props }) {
|
||||
return (
|
||||
<Menu
|
||||
selectable={false}
|
||||
mode={inlineCollapsed ? "inline" : "vertical"}
|
||||
inlineCollapsed={inlineCollapsed}
|
||||
theme="dark"
|
||||
{...props}>
|
||||
{children}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DesktopNavbar() {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
|
||||
const firstSettingsTab = first(settingsMenu.getAvailableItems());
|
||||
|
||||
const canCreateQuery = currentUser.hasPermission("create_query");
|
||||
const canCreateDashboard = currentUser.hasPermission("create_dashboard");
|
||||
const canCreateAlert = currentUser.hasPermission("list_alerts");
|
||||
|
||||
return (
|
||||
<div className="desktop-navbar">
|
||||
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-logo">
|
||||
<div>
|
||||
<Link href="./">
|
||||
<img src={logoUrl} alt="Redash" />
|
||||
</Link>
|
||||
</div>
|
||||
</NavbarSection>
|
||||
|
||||
<NavbarSection inlineCollapsed={collapsed}>
|
||||
{currentUser.hasPermission("list_dashboards") && (
|
||||
<Menu.Item key="dashboards">
|
||||
<Link href="dashboards">
|
||||
<DesktopOutlinedIcon />
|
||||
<span>Dashboards</span>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("view_query") && (
|
||||
<Menu.Item key="queries">
|
||||
<Link href="queries">
|
||||
<CodeOutlinedIcon />
|
||||
<span>Queries</span>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("list_alerts") && (
|
||||
<Menu.Item key="alerts">
|
||||
<Link href="alerts">
|
||||
<AlertOutlinedIcon />
|
||||
<span>Alerts</span>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</NavbarSection>
|
||||
|
||||
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-spacer">
|
||||
{(canCreateQuery || canCreateDashboard || canCreateAlert) && <Menu.Divider />}
|
||||
{(canCreateQuery || canCreateDashboard || canCreateAlert) && (
|
||||
<Menu.SubMenu
|
||||
key="create"
|
||||
popupClassName="desktop-navbar-submenu"
|
||||
title={
|
||||
<React.Fragment>
|
||||
<span data-test="CreateButton">
|
||||
<PlusOutlinedIcon />
|
||||
<span>Create</span>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
}>
|
||||
{canCreateQuery && (
|
||||
<Menu.Item key="new-query">
|
||||
<Link href="queries/new" data-test="CreateQueryMenuItem">
|
||||
New Query
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{canCreateDashboard && (
|
||||
<Menu.Item key="new-dashboard">
|
||||
<a data-test="CreateDashboardMenuItem" onMouseUp={() => CreateDashboardDialog.showModal()}>
|
||||
New Dashboard
|
||||
</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{canCreateAlert && (
|
||||
<Menu.Item key="new-alert">
|
||||
<Link data-test="CreateAlertMenuItem" href="alerts/new">
|
||||
New Alert
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.SubMenu>
|
||||
)}
|
||||
</NavbarSection>
|
||||
|
||||
<NavbarSection inlineCollapsed={collapsed}>
|
||||
<Menu.Item key="help">
|
||||
<HelpTrigger showTooltip={false} type="HOME">
|
||||
<QuestionCircleOutlinedIcon />
|
||||
<span>Help</span>
|
||||
</HelpTrigger>
|
||||
</Menu.Item>
|
||||
{firstSettingsTab && (
|
||||
<Menu.Item key="settings">
|
||||
<Link href={firstSettingsTab.path} data-test="SettingsLink">
|
||||
<SettingOutlinedIcon />
|
||||
<span>Settings</span>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
</NavbarSection>
|
||||
|
||||
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-profile-menu">
|
||||
<Menu.SubMenu
|
||||
key="profile"
|
||||
popupClassName="desktop-navbar-submenu"
|
||||
title={
|
||||
<span data-test="ProfileDropdown" className="desktop-navbar-profile-menu-title">
|
||||
<img className="profile__image_thumb" src={currentUser.profile_image_url} alt={currentUser.name} />
|
||||
<span>{currentUser.name}</span>
|
||||
</span>
|
||||
}>
|
||||
<Menu.Item key="profile">
|
||||
<Link href="users/me">Profile</Link>
|
||||
</Menu.Item>
|
||||
{currentUser.hasPermission("super_admin") && (
|
||||
<Menu.Item key="status">
|
||||
<Link href="admin/status">System Status</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item key="logout">
|
||||
<a data-test="LogOutButton" onClick={() => Auth.logout()}>
|
||||
Log out
|
||||
</a>
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item key="version" disabled className="version-info">
|
||||
<VersionInfo />
|
||||
</Menu.Item>
|
||||
</Menu.SubMenu>
|
||||
</NavbarSection>
|
||||
|
||||
<Button onClick={() => setCollapsed(!collapsed)} className="desktop-navbar-collapse-button">
|
||||
{collapsed ? <MenuUnfoldOutlinedIcon /> : <MenuFoldOutlinedIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
@backgroundColor: #001529;
|
||||
@dividerColor: rgba(255, 255, 255, 0.5);
|
||||
@textColor: rgba(255, 255, 255, 0.75);
|
||||
@brandColor: #ff7964; // Redash logo color
|
||||
@activeItemColor: @brandColor;
|
||||
@iconSize: 26px;
|
||||
|
||||
.desktop-navbar {
|
||||
background: @backgroundColor;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 80px;
|
||||
overflow: hidden;
|
||||
|
||||
&-spacer {
|
||||
flex: 1 1 auto;
|
||||
@@ -21,12 +26,6 @@
|
||||
height: 40px;
|
||||
transition: all 270ms;
|
||||
}
|
||||
|
||||
&.ant-menu-inline-collapsed {
|
||||
img {
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.help-trigger {
|
||||
@@ -34,26 +33,19 @@
|
||||
}
|
||||
|
||||
.ant-menu {
|
||||
&:not(.ant-menu-inline-collapsed) {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
&.ant-menu-inline-collapsed > .ant-menu-submenu-title span img + span,
|
||||
&.ant-menu-inline-collapsed > .ant-menu-item i + span {
|
||||
display: inline-block;
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ant-menu-item-divider {
|
||||
background: @dividerColor;
|
||||
}
|
||||
|
||||
.ant-menu-item,
|
||||
.ant-menu-submenu {
|
||||
font-weight: 500;
|
||||
color: @textColor;
|
||||
|
||||
&.navbar-active-item {
|
||||
box-shadow: inset 3px 0 0 @activeItemColor;
|
||||
|
||||
.anticon {
|
||||
color: @activeItemColor;
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-menu-submenu-open,
|
||||
&.ant-menu-submenu-active,
|
||||
&:hover,
|
||||
@@ -61,6 +53,16 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.anticon {
|
||||
font-size: @iconSize;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.desktop-navbar-label {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
a,
|
||||
span,
|
||||
.anticon {
|
||||
@@ -71,21 +73,33 @@
|
||||
.ant-menu-submenu-arrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-btn.desktop-navbar-collapse-button {
|
||||
background-color: @backgroundColor;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
color: @textColor;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
color: #fff;
|
||||
.ant-menu-item,
|
||||
.ant-menu-submenu {
|
||||
padding: 0;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&:after {
|
||||
animation: 0s !important;
|
||||
.ant-menu-submenu-title {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a,
|
||||
&.ant-menu-vertical > .ant-menu-submenu > .ant-menu-submenu-title,
|
||||
.ant-menu-submenu-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: normal;
|
||||
height: auto;
|
||||
background: none;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,37 +113,8 @@
|
||||
.profile__image_thumb {
|
||||
margin: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.profile__image_thumb + span {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
margin-left: 10px;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
|
||||
// styles from Antd
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
|
||||
margin-left 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), width 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-menu-inline-collapsed {
|
||||
.ant-menu-submenu-title {
|
||||
padding-left: 16px !important;
|
||||
padding-right: 16px !important;
|
||||
}
|
||||
|
||||
.desktop-navbar-profile-menu-title {
|
||||
.profile__image_thumb + span {
|
||||
opacity: 0;
|
||||
max-width: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
width: @iconSize;
|
||||
height: @iconSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { first, includes } from "lodash";
|
||||
import Menu from "antd/lib/menu";
|
||||
import Link from "@/components/Link";
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
|
||||
import { useCurrentRoute } from "@/components/ApplicationArea/Router";
|
||||
import { Auth, currentUser } from "@/services/auth";
|
||||
import settingsMenu from "@/services/settingsMenu";
|
||||
// @ts-expect-error ts-migrate(2307) FIXME: Cannot find module '@/assets/images/redash_icon_sm... Remove this comment to see the full error message
|
||||
import logoUrl from "@/assets/images/redash_icon_small.png";
|
||||
import DesktopOutlinedIcon from "@ant-design/icons/DesktopOutlined";
|
||||
import CodeOutlinedIcon from "@ant-design/icons/CodeOutlined";
|
||||
import AlertOutlinedIcon from "@ant-design/icons/AlertOutlined";
|
||||
import PlusOutlinedIcon from "@ant-design/icons/PlusOutlined";
|
||||
import QuestionCircleOutlinedIcon from "@ant-design/icons/QuestionCircleOutlined";
|
||||
import SettingOutlinedIcon from "@ant-design/icons/SettingOutlined";
|
||||
import VersionInfo from "./VersionInfo";
|
||||
import "./DesktopNavbar.less";
|
||||
function NavbarSection({ children, ...props }: any) {
|
||||
return (<Menu selectable={false} mode="vertical" theme="dark" {...props}>
|
||||
{children}
|
||||
</Menu>);
|
||||
}
|
||||
function useNavbarActiveState() {
|
||||
const currentRoute = useCurrentRoute();
|
||||
return useMemo(() => ({
|
||||
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
|
||||
dashboards: includes(["Dashboards.List", "Dashboards.Favorites", "Dashboards.ViewOrEdit", "Dashboards.LegacyViewOrEdit"], currentRoute.id),
|
||||
queries: includes([
|
||||
"Queries.List",
|
||||
"Queries.Favorites",
|
||||
"Queries.Archived",
|
||||
"Queries.My",
|
||||
"Queries.View",
|
||||
"Queries.New",
|
||||
"Queries.Edit",
|
||||
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
|
||||
], currentRoute.id),
|
||||
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
|
||||
dataSources: includes(["DataSources.List"], currentRoute.id),
|
||||
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
|
||||
alerts: includes(["Alerts.List", "Alerts.New", "Alerts.View", "Alerts.Edit"], currentRoute.id),
|
||||
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
|
||||
}), [currentRoute.id]);
|
||||
}
|
||||
export default function DesktopNavbar() {
|
||||
const firstSettingsTab = first(settingsMenu.getAvailableItems());
|
||||
const activeState = useNavbarActiveState();
|
||||
const canCreateQuery = currentUser.hasPermission("create_query");
|
||||
const canCreateDashboard = currentUser.hasPermission("create_dashboard");
|
||||
const canCreateAlert = currentUser.hasPermission("list_alerts");
|
||||
return (<div className="desktop-navbar">
|
||||
<NavbarSection className="desktop-navbar-logo">
|
||||
<div>
|
||||
<Link href="./">
|
||||
<img src={logoUrl} alt="Redash"/>
|
||||
</Link>
|
||||
</div>
|
||||
</NavbarSection>
|
||||
|
||||
<NavbarSection>
|
||||
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
|
||||
{currentUser.hasPermission("list_dashboards") && (<Menu.Item key="dashboards" className={activeState.dashboards ? "navbar-active-item" : null}>
|
||||
<Link href="dashboards">
|
||||
<DesktopOutlinedIcon />
|
||||
<span className="desktop-navbar-label">Dashboards</span>
|
||||
</Link>
|
||||
</Menu.Item>)}
|
||||
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
|
||||
{currentUser.hasPermission("view_query") && (<Menu.Item key="queries" className={activeState.queries ? "navbar-active-item" : null}>
|
||||
<Link href="queries">
|
||||
<CodeOutlinedIcon />
|
||||
<span className="desktop-navbar-label">Queries</span>
|
||||
</Link>
|
||||
</Menu.Item>)}
|
||||
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
|
||||
{currentUser.hasPermission("list_alerts") && (<Menu.Item key="alerts" className={activeState.alerts ? "navbar-active-item" : null}>
|
||||
<Link href="alerts">
|
||||
<AlertOutlinedIcon />
|
||||
<span className="desktop-navbar-label">Alerts</span>
|
||||
</Link>
|
||||
</Menu.Item>)}
|
||||
</NavbarSection>
|
||||
|
||||
<NavbarSection className="desktop-navbar-spacer">
|
||||
{(canCreateQuery || canCreateDashboard || canCreateAlert) && (<Menu.SubMenu key="create" popupClassName="desktop-navbar-submenu" data-test="CreateButton" title={<React.Fragment>
|
||||
<PlusOutlinedIcon />
|
||||
<span className="desktop-navbar-label">Create</span>
|
||||
</React.Fragment>}>
|
||||
{canCreateQuery && (<Menu.Item key="new-query">
|
||||
<Link href="queries/new" data-test="CreateQueryMenuItem">
|
||||
New Query
|
||||
</Link>
|
||||
</Menu.Item>)}
|
||||
{canCreateDashboard && (<Menu.Item key="new-dashboard">
|
||||
{/* @ts-expect-error ts-migrate(2554) FIXME: Expected 1 arguments, but got 0. */}
|
||||
<a data-test="CreateDashboardMenuItem" onMouseUp={() => CreateDashboardDialog.showModal()}>
|
||||
New Dashboard
|
||||
</a>
|
||||
</Menu.Item>)}
|
||||
{canCreateAlert && (<Menu.Item key="new-alert">
|
||||
<Link data-test="CreateAlertMenuItem" href="alerts/new">
|
||||
New Alert
|
||||
</Link>
|
||||
</Menu.Item>)}
|
||||
</Menu.SubMenu>)}
|
||||
</NavbarSection>
|
||||
|
||||
<NavbarSection>
|
||||
<Menu.Item key="help">
|
||||
{/* @ts-expect-error ts-migrate(2746) FIXME: This JSX tag's 'children' prop expects a single ch... Remove this comment to see the full error message */}
|
||||
<HelpTrigger showTooltip={false} type="HOME">
|
||||
<QuestionCircleOutlinedIcon />
|
||||
<span className="desktop-navbar-label">Help</span>
|
||||
</HelpTrigger>
|
||||
</Menu.Item>
|
||||
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
|
||||
{firstSettingsTab && (<Menu.Item key="settings" className={activeState.dataSources ? "navbar-active-item" : null}>
|
||||
<Link href={(firstSettingsTab as any).path} data-test="SettingsLink">
|
||||
<SettingOutlinedIcon />
|
||||
<span className="desktop-navbar-label">Settings</span>
|
||||
</Link>
|
||||
</Menu.Item>)}
|
||||
</NavbarSection>
|
||||
|
||||
<NavbarSection className="desktop-navbar-profile-menu">
|
||||
<Menu.SubMenu key="profile" popupClassName="desktop-navbar-submenu" title={<span data-test="ProfileDropdown" className="desktop-navbar-profile-menu-title">
|
||||
<img className="profile__image_thumb" src={(currentUser as any).profile_image_url} alt={(currentUser as any).name}/>
|
||||
</span>}>
|
||||
<Menu.Item key="profile">
|
||||
<Link href="users/me">Profile</Link>
|
||||
</Menu.Item>
|
||||
{currentUser.hasPermission("super_admin") && (<Menu.Item key="status">
|
||||
<Link href="admin/status">System Status</Link>
|
||||
</Menu.Item>)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item key="logout">
|
||||
<a data-test="LogOutButton" onClick={() => Auth.logout()}>
|
||||
Log out
|
||||
</a>
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item key="version" disabled className="version-info">
|
||||
<VersionInfo />
|
||||
</Menu.Item>
|
||||
</Menu.SubMenu>
|
||||
</NavbarSection>
|
||||
</div>);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { first } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Button from "antd/lib/button";
|
||||
import MenuOutlinedIcon from "@ant-design/icons/MenuOutlined";
|
||||
import Dropdown from "antd/lib/dropdown";
|
||||
@@ -8,59 +7,46 @@ import Menu from "antd/lib/menu";
|
||||
import Link from "@/components/Link";
|
||||
import { Auth, currentUser } from "@/services/auth";
|
||||
import settingsMenu from "@/services/settingsMenu";
|
||||
// @ts-expect-error ts-migrate(2307) FIXME: Cannot find module '@/assets/images/redash_icon_sm... Remove this comment to see the full error message
|
||||
import logoUrl from "@/assets/images/redash_icon_small.png";
|
||||
|
||||
import "./MobileNavbar.less";
|
||||
|
||||
export default function MobileNavbar({ getPopupContainer }) {
|
||||
const firstSettingsTab = first(settingsMenu.getAvailableItems());
|
||||
|
||||
return (
|
||||
<div className="mobile-navbar">
|
||||
type OwnProps = {
|
||||
getPopupContainer?: (...args: any[]) => any;
|
||||
};
|
||||
type Props = OwnProps & typeof MobileNavbar.defaultProps;
|
||||
export default function MobileNavbar({ getPopupContainer }: Props) {
|
||||
const firstSettingsTab = first(settingsMenu.getAvailableItems());
|
||||
return (<div className="mobile-navbar">
|
||||
<div className="mobile-navbar-logo">
|
||||
<Link href="./">
|
||||
<img src={logoUrl} alt="Redash" />
|
||||
<img src={logoUrl} alt="Redash"/>
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<Dropdown
|
||||
overlayStyle={{ minWidth: 200 }}
|
||||
trigger={["click"]}
|
||||
getPopupContainer={getPopupContainer} // so the overlay menu stays with the fixed header when page scrolls
|
||||
overlay={
|
||||
<Menu mode="vertical" theme="dark" selectable={false} className="mobile-navbar-menu">
|
||||
{currentUser.hasPermission("list_dashboards") && (
|
||||
<Menu.Item key="dashboards">
|
||||
<Dropdown overlayStyle={{ minWidth: 200 }} trigger={["click"]} getPopupContainer={getPopupContainer} // so the overlay menu stays with the fixed header when page scrolls
|
||||
overlay={<Menu mode="vertical" theme="dark" selectable={false} className="mobile-navbar-menu">
|
||||
{currentUser.hasPermission("list_dashboards") && (<Menu.Item key="dashboards">
|
||||
<Link href="dashboards">Dashboards</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("view_query") && (
|
||||
<Menu.Item key="queries">
|
||||
</Menu.Item>)}
|
||||
{currentUser.hasPermission("view_query") && (<Menu.Item key="queries">
|
||||
<Link href="queries">Queries</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("list_alerts") && (
|
||||
<Menu.Item key="alerts">
|
||||
</Menu.Item>)}
|
||||
{currentUser.hasPermission("list_alerts") && (<Menu.Item key="alerts">
|
||||
<Link href="alerts">Alerts</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Item>)}
|
||||
<Menu.Item key="profile">
|
||||
<Link href="users/me">Edit Profile</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
{firstSettingsTab && (
|
||||
<Menu.Item key="settings">
|
||||
<Link href={firstSettingsTab.path}>Settings</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("super_admin") && (
|
||||
<Menu.Item key="status">
|
||||
{firstSettingsTab && (<Menu.Item key="settings">
|
||||
<Link href={(firstSettingsTab as any).path}>Settings</Link>
|
||||
</Menu.Item>)}
|
||||
{currentUser.hasPermission("super_admin") && (<Menu.Item key="status">
|
||||
<Link href="admin/status">System Status</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Item>)}
|
||||
{currentUser.hasPermission("super_admin") && <Menu.Divider />}
|
||||
<Menu.Item key="help">
|
||||
{/* eslint-disable-next-line react/jsx-no-target-blank */}
|
||||
|
||||
<Link href="https://redash.io/help" target="_blank" rel="noopener">
|
||||
Help
|
||||
</Link>
|
||||
@@ -68,21 +54,14 @@ export default function MobileNavbar({ getPopupContainer }) {
|
||||
<Menu.Item key="logout" onClick={() => Auth.logout()}>
|
||||
Log out
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
}>
|
||||
</Menu>}>
|
||||
<Button className="mobile-navbar-toggle-button" ghost>
|
||||
<MenuOutlinedIcon />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
}
|
||||
|
||||
MobileNavbar.propTypes = {
|
||||
getPopupContainer: PropTypes.func,
|
||||
};
|
||||
|
||||
MobileNavbar.defaultProps = {
|
||||
getPopupContainer: null,
|
||||
getPopupContainer: null,
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import React from "react";
|
||||
import Link from "@/components/Link";
|
||||
import { clientConfig, currentUser } from "@/services/auth";
|
||||
import frontendVersion from "@/version.json";
|
||||
|
||||
export default function VersionInfo() {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div>
|
||||
Version: {clientConfig.version}
|
||||
{frontendVersion !== clientConfig.version && ` (${frontendVersion.substring(0, 8)})`}
|
||||
</div>
|
||||
{clientConfig.newVersionAvailable && currentUser.hasPermission("super_admin") && (
|
||||
<div className="m-t-10">
|
||||
{/* eslint-disable react/jsx-no-target-blank */}
|
||||
<Link href="https://version.redash.io/" className="update-available" target="_blank" rel="noopener">
|
||||
Update Available
|
||||
<i className="fa fa-external-link m-l-5" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from "react";
|
||||
import Link from "@/components/Link";
|
||||
import { clientConfig, currentUser } from "@/services/auth";
|
||||
// @ts-expect-error ts-migrate(7042) FIXME: Module '@/version.json' was resolved to '/Users/el... Remove this comment to see the full error message
|
||||
import frontendVersion from "@/version.json";
|
||||
export default function VersionInfo() {
|
||||
return (<React.Fragment>
|
||||
<div>
|
||||
Version: {(clientConfig as any).version}
|
||||
{frontendVersion !== (clientConfig as any).version && ` (${frontendVersion.substring(0, 8)})`}
|
||||
</div>
|
||||
{(clientConfig as any).newVersionAvailable && currentUser.hasPermission("super_admin") && (<div className="m-t-10">
|
||||
|
||||
<Link href="https://version.redash.io/" className="update-available" target="_blank" rel="noopener">
|
||||
Update Available
|
||||
<i className="fa fa-external-link m-l-5"/>
|
||||
</Link>
|
||||
</div>)}
|
||||
</React.Fragment>);
|
||||
}
|
||||
@@ -1,27 +1,36 @@
|
||||
import React, { useRef, useCallback } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
import DesktopNavbar from "./DesktopNavbar";
|
||||
import MobileNavbar from "./MobileNavbar";
|
||||
|
||||
import "./index.less";
|
||||
|
||||
export default function ApplicationLayout({ children }) {
|
||||
type OwnProps = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
type Props = OwnProps & typeof ApplicationLayout.defaultProps;
|
||||
|
||||
export default function ApplicationLayout({ children }: Props) {
|
||||
const mobileNavbarContainerRef = useRef();
|
||||
|
||||
const getMobileNavbarPopupContainer = useCallback(() => mobileNavbarContainerRef.current, []);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{/* @ts-expect-error ts-migrate(2746) FIXME: This JSX tag's 'children' prop expects a single ch... Remove this comment to see the full error message */}
|
||||
<DynamicComponent name="ApplicationWrapper">
|
||||
<div className="application-layout-side-menu">
|
||||
<DynamicComponent name="ApplicationDesktopNavbar">
|
||||
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
|
||||
<DesktopNavbar />
|
||||
</DynamicComponent>
|
||||
</div>
|
||||
<div className="application-layout-content">
|
||||
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'MutableRefObject<undefined>' is not assignab... Remove this comment to see the full error message */}
|
||||
<nav className="application-layout-top-menu" ref={mobileNavbarContainerRef}>
|
||||
<DynamicComponent name="ApplicationMobileNavbar" getPopupContainer={getMobileNavbarPopupContainer}>
|
||||
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
|
||||
<MobileNavbar getPopupContainer={getMobileNavbarPopupContainer} />
|
||||
</DynamicComponent>
|
||||
</nav>
|
||||
@@ -32,10 +41,6 @@ export default function ApplicationLayout({ children }) {
|
||||
);
|
||||
}
|
||||
|
||||
ApplicationLayout.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
ApplicationLayout.defaultProps = {
|
||||
children: null,
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
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,
|
||||
};
|
||||
@@ -1,51 +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.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);
|
||||
});
|
||||
});
|
||||
40
client/app/components/ApplicationArea/ErrorMessage.test.tsx
Normal file
40
client/app/components/ApplicationArea/ErrorMessage.test.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
54
client/app/components/ApplicationArea/ErrorMessage.tsx
Normal file
54
client/app/components/ApplicationArea/ErrorMessage.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { get, isObject } from "lodash";
|
||||
import React from "react";
|
||||
import "./ErrorMessage.less";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
import { ErrorMessageDetails } from "@/components/ApplicationArea/ErrorMessageDetails";
|
||||
function getErrorMessageByStatus(status: any, defaultMessage: any) {
|
||||
switch (status) {
|
||||
case 404:
|
||||
return "It seems like the page you're looking for cannot be found.";
|
||||
case 401:
|
||||
case 403:
|
||||
return "It seems like you 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>);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
export function ErrorMessageDetails(props) {
|
||||
return <h4>{props.message}</h4>;
|
||||
}
|
||||
|
||||
ErrorMessageDetails.propTypes = {
|
||||
error: PropTypes.instanceOf(Error).isRequired,
|
||||
message: PropTypes.string.isRequired,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
error: any; // TODO: PropTypes.instanceOf(Error)
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function ErrorMessageDetails(props: Props) {
|
||||
return <h4>{props.message}</h4>;
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { isFunction, startsWith, trimStart, trimEnd } from "lodash";
|
||||
import React, { useState, useEffect, useRef, useContext } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import UniversalRouter from "universal-router";
|
||||
import ErrorBoundary from "@redash/viz/lib/components/ErrorBoundary";
|
||||
import location from "@/services/location";
|
||||
import url from "@/services/url";
|
||||
|
||||
import ErrorMessage from "./ErrorMessage";
|
||||
|
||||
function generateRouteKey() {
|
||||
return Math.random()
|
||||
.toString(32)
|
||||
.substr(2);
|
||||
}
|
||||
|
||||
export const CurrentRouteContext = React.createContext(null);
|
||||
|
||||
export function useCurrentRoute() {
|
||||
return useContext(CurrentRouteContext);
|
||||
}
|
||||
|
||||
export function stripBase(href) {
|
||||
// Resolve provided link and '' (root) relative to document's base.
|
||||
// If provided href is not related to current document (does not
|
||||
// start with resolved root) - return false. Otherwise
|
||||
// strip root and return relative url.
|
||||
|
||||
const baseHref = trimEnd(url.normalize(""), "/") + "/";
|
||||
href = url.normalize(href);
|
||||
|
||||
if (startsWith(href, baseHref)) {
|
||||
return "/" + trimStart(href.substr(baseHref.length), "/");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export default function Router({ routes, onRouteChange }) {
|
||||
const [currentRoute, setCurrentRoute] = useState(null);
|
||||
|
||||
const currentPathRef = useRef(null);
|
||||
const errorHandlerRef = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
let isAbandoned = false;
|
||||
|
||||
const router = new UniversalRouter(routes, {
|
||||
resolveRoute({ route }, routeParams) {
|
||||
if (isFunction(route.render)) {
|
||||
return { ...route, routeParams };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function resolve(action) {
|
||||
if (!isAbandoned) {
|
||||
if (errorHandlerRef.current) {
|
||||
errorHandlerRef.current.reset();
|
||||
}
|
||||
|
||||
const pathname = stripBase(location.path) || "/";
|
||||
|
||||
// This is a optimization for route resolver: if current route was already resolved
|
||||
// from this path - do nothing. It also prevents router from using outdated route in a case
|
||||
// when user navigated to another path while current one was still resolving.
|
||||
// Note: this lock uses only `path` fragment of URL to distinguish routes because currently
|
||||
// all pages depend only on this fragment and handle search/hash on their own. If router
|
||||
// should reload page on search/hash change - this fragment (and few checks below) should be updated
|
||||
if (pathname === currentPathRef.current) {
|
||||
return;
|
||||
}
|
||||
currentPathRef.current = pathname;
|
||||
|
||||
// Don't reload controller if URL was replaced
|
||||
if (action === "REPLACE") {
|
||||
return;
|
||||
}
|
||||
|
||||
router
|
||||
.resolve({ pathname })
|
||||
.then(route => {
|
||||
if (!isAbandoned && currentPathRef.current === pathname) {
|
||||
setCurrentRoute({ ...route, key: generateRouteKey() });
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
if (!isAbandoned && currentPathRef.current === pathname) {
|
||||
setCurrentRoute({
|
||||
render: currentRoute => <ErrorMessage {...currentRoute.routeParams} />,
|
||||
routeParams: { error },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
resolve("PUSH");
|
||||
|
||||
const unlisten = location.listen((unused, action) => resolve(action));
|
||||
|
||||
return () => {
|
||||
isAbandoned = true;
|
||||
currentPathRef.current = null;
|
||||
unlisten();
|
||||
};
|
||||
}, [routes]);
|
||||
|
||||
useEffect(() => {
|
||||
onRouteChange(currentRoute);
|
||||
}, [currentRoute, onRouteChange]);
|
||||
|
||||
if (!currentRoute) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CurrentRouteContext.Provider value={currentRoute}>
|
||||
<ErrorBoundary ref={errorHandlerRef} renderError={error => <ErrorMessage error={error} />}>
|
||||
{currentRoute.render(currentRoute)}
|
||||
</ErrorBoundary>
|
||||
</CurrentRouteContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
Router.propTypes = {
|
||||
routes: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
path: PropTypes.string.isRequired,
|
||||
render: PropTypes.func, // (routeParams: PropTypes.object; currentRoute; location) => PropTypes.node
|
||||
// Additional props to be injected into route component.
|
||||
// Object keys are props names. Object values will become prop values:
|
||||
// - if value is a function - it will be called without arguments, and result will be used; otherwise value will be used;
|
||||
// - after previous step, if value is a promise - router will wait for it to resolve; resolved value then will be used;
|
||||
// otherwise value will be used directly.
|
||||
resolve: PropTypes.objectOf(PropTypes.any),
|
||||
})
|
||||
),
|
||||
onRouteChange: PropTypes.func,
|
||||
};
|
||||
|
||||
Router.defaultProps = {
|
||||
routes: [],
|
||||
onRouteChange: () => {},
|
||||
};
|
||||
118
client/app/components/ApplicationArea/Router.tsx
Normal file
118
client/app/components/ApplicationArea/Router.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { isFunction, startsWith, trimStart, trimEnd } from "lodash";
|
||||
import React, { useState, useEffect, useRef, useContext } from "react";
|
||||
import UniversalRouter from "universal-router";
|
||||
import ErrorBoundary from "@redash/viz/lib/components/ErrorBoundary";
|
||||
import location from "@/services/location";
|
||||
import url from "@/services/url";
|
||||
import ErrorMessage from "./ErrorMessage";
|
||||
function generateRouteKey() {
|
||||
return Math.random()
|
||||
.toString(32)
|
||||
.substr(2);
|
||||
}
|
||||
export const CurrentRouteContext = React.createContext(null);
|
||||
export function useCurrentRoute() {
|
||||
return useContext(CurrentRouteContext);
|
||||
}
|
||||
export function stripBase(href: any) {
|
||||
// Resolve provided link and '' (root) relative to document's base.
|
||||
// If provided href is not related to current document (does not
|
||||
// start with resolved root) - return false. Otherwise
|
||||
// strip root and return relative url.
|
||||
const baseHref = trimEnd(url.normalize(""), "/") + "/";
|
||||
href = url.normalize(href);
|
||||
if (startsWith(href, baseHref)) {
|
||||
return "/" + trimStart(href.substr(baseHref.length), "/");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
type OwnProps = {
|
||||
routes?: {
|
||||
path: string;
|
||||
render?: (...args: any[]) => any;
|
||||
resolve?: {
|
||||
[key: string]: any;
|
||||
};
|
||||
}[];
|
||||
onRouteChange?: (...args: any[]) => any;
|
||||
};
|
||||
type Props = OwnProps & typeof Router.defaultProps;
|
||||
export default function Router({ routes, onRouteChange }: Props) {
|
||||
const [currentRoute, setCurrentRoute] = useState(null);
|
||||
const currentPathRef = useRef(null);
|
||||
const errorHandlerRef = useRef();
|
||||
useEffect(() => {
|
||||
let isAbandoned = false;
|
||||
const router = new UniversalRouter(routes, {
|
||||
resolveRoute({ route }, routeParams) {
|
||||
if (isFunction((route as any).render)) {
|
||||
return { ...route, routeParams };
|
||||
}
|
||||
},
|
||||
});
|
||||
function resolve(action: any) {
|
||||
if (!isAbandoned) {
|
||||
if (errorHandlerRef.current) {
|
||||
// @ts-expect-error ts-migrate(2532) FIXME: Object is possibly 'undefined'.
|
||||
errorHandlerRef.current.reset();
|
||||
}
|
||||
const pathname = stripBase(location.path) || "/";
|
||||
// This is a optimization for route resolver: if current route was already resolved
|
||||
// from this path - do nothing. It also prevents router from using outdated route in a case
|
||||
// when user navigated to another path while current one was still resolving.
|
||||
// Note: this lock uses only `path` fragment of URL to distinguish routes because currently
|
||||
// all pages depend only on this fragment and handle search/hash on their own. If router
|
||||
// should reload page on search/hash change - this fragment (and few checks below) should be updated
|
||||
if (pathname === currentPathRef.current) {
|
||||
return;
|
||||
}
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'string' is not assignable to type 'null'.
|
||||
currentPathRef.current = pathname;
|
||||
// Don't reload controller if URL was replaced
|
||||
if (action === "REPLACE") {
|
||||
return;
|
||||
}
|
||||
router
|
||||
.resolve({ pathname })
|
||||
.then(route => {
|
||||
if (!isAbandoned && currentPathRef.current === pathname) {
|
||||
setCurrentRoute({ ...route, key: generateRouteKey() });
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
if (!isAbandoned && currentPathRef.current === pathname) {
|
||||
setCurrentRoute({
|
||||
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type '{ render: (currentRoute: any) =>... Remove this comment to see the full error message
|
||||
render: (currentRoute: any) => <ErrorMessage {...currentRoute.routeParams}/>,
|
||||
routeParams: { error },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
resolve("PUSH");
|
||||
const unlisten = location.listen((unused: any, action: any) => resolve(action));
|
||||
return () => {
|
||||
isAbandoned = true;
|
||||
currentPathRef.current = null;
|
||||
unlisten();
|
||||
};
|
||||
}, [routes]);
|
||||
useEffect(() => {
|
||||
onRouteChange(currentRoute);
|
||||
}, [currentRoute, onRouteChange]);
|
||||
if (!currentRoute) {
|
||||
return null;
|
||||
}
|
||||
return (<CurrentRouteContext.Provider value={currentRoute}>
|
||||
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
|
||||
<ErrorBoundary ref={errorHandlerRef} renderError={(error: any) => <ErrorMessage error={error}/>}>
|
||||
{/* @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'. */}
|
||||
{currentRoute.render(currentRoute)}
|
||||
</ErrorBoundary>
|
||||
</CurrentRouteContext.Provider>);
|
||||
}
|
||||
Router.defaultProps = {
|
||||
routes: [],
|
||||
onRouteChange: () => { },
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { isString } from "lodash";
|
||||
import navigateTo from "./navigateTo";
|
||||
|
||||
export default function handleNavigationIntent(event) {
|
||||
export default function handleNavigationIntent(event: any) {
|
||||
let element = event.target;
|
||||
while (element) {
|
||||
if (element.tagName === "A") {
|
||||
@@ -9,13 +9,15 @@ export default function ApplicationArea() {
|
||||
const [unhandledError, setUnhandledError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
|
||||
if (currentRoute && currentRoute.title) {
|
||||
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
|
||||
document.title = currentRoute.title;
|
||||
}
|
||||
}, [currentRoute]);
|
||||
|
||||
useEffect(() => {
|
||||
function globalErrorHandler(event) {
|
||||
function globalErrorHandler(event: any) {
|
||||
event.preventDefault();
|
||||
setUnhandledError(event.error);
|
||||
}
|
||||
@@ -33,5 +35,6 @@ export default function ApplicationArea() {
|
||||
return <ErrorMessage error={unhandledError} />;
|
||||
}
|
||||
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'RouteItem[]' is not assignable to type '{ pa... Remove this comment to see the full error message
|
||||
return <Router routes={routes.items} onRouteChange={setCurrentRoute} />;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { stripBase } from "./Router";
|
||||
|
||||
// When `replace` is set to `true` - it will just replace current URL
|
||||
// without reloading current page (router will skip this location change)
|
||||
export default function navigateTo(href, replace = false) {
|
||||
export default function navigateTo(href: any, replace = false) {
|
||||
// Allow calling chain to roll up, and then navigate
|
||||
setTimeout(() => {
|
||||
const isExternal = stripBase(href) === false;
|
||||
@@ -1,63 +0,0 @@
|
||||
import React, { useEffect, useState, useContext } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
|
||||
import { Auth, 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} />
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, { useEffect, useState, useContext } from "react";
|
||||
import { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
|
||||
import { Auth, clientConfig } from "@/services/auth";
|
||||
type OwnProps = {
|
||||
apiKey: string;
|
||||
renderChildren?: (...args: any[]) => any;
|
||||
};
|
||||
type Props = OwnProps & typeof ApiKeySessionWrapper.defaultProps;
|
||||
// This wrapper modifies `route.render` function and instead of passing `currentRoute` passes an object
|
||||
// that contains:
|
||||
// - `currentRoute.routeParams`
|
||||
// - `pageTitle` field which is equal to `currentRoute.title`
|
||||
// - `onError` field which is a `handleError` method of nearest error boundary
|
||||
// - `apiKey` field
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'currentRoute' does not exist on type 'Pr... Remove this comment to see the full error message
|
||||
function ApiKeySessionWrapper({ apiKey, currentRoute, renderChildren }: Props) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const { handleError } = useContext(ErrorBoundaryContext);
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
Auth.setApiKey(apiKey);
|
||||
Auth.loadConfig()
|
||||
.then(() => {
|
||||
if (!isCancelled) {
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCancelled) {
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [apiKey]);
|
||||
if (!isAuthenticated || (clientConfig as any).disablePublicUrls) {
|
||||
return null;
|
||||
}
|
||||
return (<React.Fragment key={currentRoute.key}>
|
||||
{renderChildren({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError, apiKey })}
|
||||
</React.Fragment>);
|
||||
}
|
||||
ApiKeySessionWrapper.defaultProps = {
|
||||
renderChildren: () => null,
|
||||
};
|
||||
export default function routeWithApiKeySession({ render, getApiKey, ...rest }: any) {
|
||||
return {
|
||||
...rest,
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type '{ apiKey: any; currentRoute: any; renderChil... Remove this comment to see the full error message
|
||||
render: (currentRoute: any) => <ApiKeySessionWrapper apiKey={getApiKey(currentRoute)} currentRoute={currentRoute} renderChildren={render}/>,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
// @ts-expect-error (Must be removed after adding @redash/viz typing)
|
||||
import ErrorBoundary, { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
|
||||
import { Auth } from "@/services/auth";
|
||||
import { policy } from "@/services/policy";
|
||||
@@ -61,10 +60,12 @@ export function UserSessionWrapper<P>({ bodyClass, currentRoute, render }: UserS
|
||||
|
||||
return (
|
||||
<ApplicationLayout>
|
||||
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'Element' is not assignable to type 'null | u... Remove this comment to see the full error message */}
|
||||
<React.Fragment key={currentRoute.key}>
|
||||
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
|
||||
<ErrorBoundary renderError={(error: Error) => <ErrorMessage error={error} />}>
|
||||
<ErrorBoundaryContext.Consumer>
|
||||
{({ handleError }: { handleError: UserSessionWrapperRenderChildrenProps<P>["onError"] }) =>
|
||||
{({ handleError } /* : { handleError: UserSessionWrapperRenderChildrenProps<P>["onError"] } FIXME bring back type */) =>
|
||||
render({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError })
|
||||
}
|
||||
</ErrorBoundaryContext.Consumer>
|
||||
|
||||
@@ -7,47 +7,36 @@ import Link from "@/components/Link";
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
import OrgSettings from "@/services/organizationSettings";
|
||||
|
||||
const Text = Typography.Text;
|
||||
|
||||
function BeaconConsent() {
|
||||
const [hide, setHide] = useState(false);
|
||||
|
||||
if (!clientConfig.showBeaconConsentMessage || hide) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hideConsentCard = () => {
|
||||
clientConfig.showBeaconConsentMessage = false;
|
||||
setHide(true);
|
||||
};
|
||||
|
||||
const confirmConsent = confirm => {
|
||||
let message = "🙏 Thank you.";
|
||||
|
||||
if (!confirm) {
|
||||
message = "Settings Saved.";
|
||||
const [hide, setHide] = useState(false);
|
||||
if (!(clientConfig as any).showBeaconConsentMessage || hide) {
|
||||
return null;
|
||||
}
|
||||
|
||||
OrgSettings.save({ beacon_consent: confirm }, message)
|
||||
// .then(() => {
|
||||
// // const settings = get(response, 'settings');
|
||||
// // this.setState({ settings, formValues: { ...settings } });
|
||||
// })
|
||||
.finally(hideConsentCard);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicComponent name="BeaconConsent">
|
||||
const hideConsentCard = () => {
|
||||
(clientConfig as any).showBeaconConsentMessage = false;
|
||||
setHide(true);
|
||||
};
|
||||
const confirmConsent = (confirm: any) => {
|
||||
let message = "🙏 Thank you.";
|
||||
if (!confirm) {
|
||||
message = "Settings Saved.";
|
||||
}
|
||||
OrgSettings.save({ beacon_consent: confirm }, message)
|
||||
// .then(() => {
|
||||
// // const settings = get(response, 'settings');
|
||||
// // this.setState({ settings, formValues: { ...settings } });
|
||||
// })
|
||||
.finally(hideConsentCard);
|
||||
};
|
||||
return (<DynamicComponent name="BeaconConsent">
|
||||
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
|
||||
<div className="m-t-10 tiled">
|
||||
<Card
|
||||
title={
|
||||
<>
|
||||
<Card title={<>
|
||||
Would you be ok with sharing anonymous usage data with the Redash team?{" "}
|
||||
<HelpTrigger type="USAGE_DATA_SHARING" />
|
||||
</>
|
||||
}
|
||||
bordered={false}>
|
||||
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'string' is not assignable to type 'never'. */}
|
||||
<HelpTrigger type="USAGE_DATA_SHARING"/>
|
||||
</>} bordered={false}>
|
||||
<Text>Help Redash improve by automatically sending anonymous usage data:</Text>
|
||||
<div className="m-t-5">
|
||||
<ul>
|
||||
@@ -72,8 +61,6 @@ function BeaconConsent() {
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</DynamicComponent>
|
||||
);
|
||||
</DynamicComponent>);
|
||||
}
|
||||
|
||||
export default BeaconConsent;
|
||||
@@ -1,7 +1,15 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
function BigMessage({ message, icon, children, className }) {
|
||||
type OwnProps = {
|
||||
message?: string;
|
||||
icon: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type Props = OwnProps & typeof BigMessage.defaultProps;
|
||||
|
||||
function BigMessage({ message, icon, children, className }: Props) {
|
||||
return (
|
||||
<div className={"p-15 text-center " + className}>
|
||||
<h3 className="m-t-0 m-b-0">
|
||||
@@ -14,13 +22,6 @@ function BigMessage({ message, icon, children, className }) {
|
||||
);
|
||||
}
|
||||
|
||||
BigMessage.propTypes = {
|
||||
message: PropTypes.string,
|
||||
icon: PropTypes.string.isRequired,
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
BigMessage.defaultProps = {
|
||||
message: "",
|
||||
children: null,
|
||||
@@ -1,24 +1,30 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Button from "antd/lib/button";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined";
|
||||
import "./CodeBlock.less";
|
||||
|
||||
export default class CodeBlock extends React.Component {
|
||||
static propTypes = {
|
||||
copyable: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
type OwnProps = {
|
||||
copyable?: boolean;
|
||||
};
|
||||
|
||||
type State = any;
|
||||
|
||||
type Props = OwnProps & typeof CodeBlock.defaultProps;
|
||||
|
||||
export default class CodeBlock extends React.Component<Props, State> {
|
||||
static defaultProps = {
|
||||
copyable: false,
|
||||
children: null,
|
||||
};
|
||||
|
||||
copyFeatureEnabled: any;
|
||||
ref: any;
|
||||
resetCopyState: any;
|
||||
|
||||
state = { copied: null };
|
||||
|
||||
constructor(props) {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.ref = React.createRef();
|
||||
this.copyFeatureEnabled = props.copyable && document.queryCommandSupported("copy");
|
||||
@@ -33,6 +39,7 @@ export default class CodeBlock extends React.Component {
|
||||
|
||||
copy = () => {
|
||||
// select text
|
||||
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
|
||||
window.getSelection().selectAllChildren(this.ref.current);
|
||||
|
||||
// copy
|
||||
@@ -49,6 +56,7 @@ export default class CodeBlock extends React.Component {
|
||||
}
|
||||
|
||||
// reset selection
|
||||
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
|
||||
window.getSelection().removeAllRanges();
|
||||
|
||||
// reset tooltip
|
||||
@@ -1,12 +1,20 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import cx from "classnames";
|
||||
import AntCollapse from "antd/lib/collapse";
|
||||
|
||||
export default function Collapse({ collapsed, children, className, ...props }) {
|
||||
type OwnProps = {
|
||||
collapsed?: boolean;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type Props = OwnProps & typeof Collapse.defaultProps;
|
||||
|
||||
export default function Collapse({ collapsed, children, className, ...props }: Props) {
|
||||
return (
|
||||
<AntCollapse
|
||||
{...props}
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'string | null' is not assignable to type 'st... Remove this comment to see the full error message
|
||||
activeKey={collapsed ? null : "content"}
|
||||
className={cx(className, "ant-collapse-headerless")}>
|
||||
<AntCollapse.Panel key="content" header="">
|
||||
@@ -16,12 +24,6 @@ export default function Collapse({ collapsed, children, className, ...props }) {
|
||||
);
|
||||
}
|
||||
|
||||
Collapse.propTypes = {
|
||||
collapsed: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
Collapse.defaultProps = {
|
||||
collapsed: true,
|
||||
children: null,
|
||||
@@ -1,198 +0,0 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { isEmpty, toUpper, includes, get } from "lodash";
|
||||
import Button from "antd/lib/button";
|
||||
import List from "antd/lib/list";
|
||||
import Modal from "antd/lib/modal";
|
||||
import Input from "antd/lib/input";
|
||||
import Steps from "antd/lib/steps";
|
||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||
import Link from "@/components/Link";
|
||||
import { PreviewCard } from "@/components/PreviewCard";
|
||||
import EmptyState from "@/components/items-list/components/EmptyState";
|
||||
import DynamicForm from "@/components/dynamic-form/DynamicForm";
|
||||
import helper from "@/components/dynamic-form/dynamicFormHelper";
|
||||
import HelpTrigger, { TYPES as HELP_TRIGGER_TYPES } from "@/components/HelpTrigger";
|
||||
|
||||
const { Step } = Steps;
|
||||
const { Search } = Input;
|
||||
|
||||
const StepEnum = {
|
||||
SELECT_TYPE: 0,
|
||||
CONFIGURE_IT: 1,
|
||||
DONE: 2,
|
||||
};
|
||||
|
||||
class CreateSourceDialog extends React.Component {
|
||||
static propTypes = {
|
||||
dialog: DialogPropType.isRequired,
|
||||
types: PropTypes.arrayOf(PropTypes.object),
|
||||
sourceType: PropTypes.string.isRequired,
|
||||
imageFolder: PropTypes.string.isRequired,
|
||||
helpTriggerPrefix: PropTypes.string,
|
||||
onCreate: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
types: [],
|
||||
helpTriggerPrefix: null,
|
||||
};
|
||||
|
||||
state = {
|
||||
searchText: "",
|
||||
selectedType: null,
|
||||
savingSource: false,
|
||||
currentStep: StepEnum.SELECT_TYPE,
|
||||
};
|
||||
|
||||
selectType = selectedType => {
|
||||
this.setState({ selectedType, currentStep: StepEnum.CONFIGURE_IT });
|
||||
};
|
||||
|
||||
resetType = () => {
|
||||
if (this.state.currentStep === StepEnum.CONFIGURE_IT) {
|
||||
this.setState({ searchText: "", selectedType: null, currentStep: StepEnum.SELECT_TYPE });
|
||||
}
|
||||
};
|
||||
|
||||
createSource = (values, successCallback, errorCallback) => {
|
||||
const { selectedType, savingSource } = this.state;
|
||||
if (!savingSource) {
|
||||
this.setState({ savingSource: true, currentStep: StepEnum.DONE });
|
||||
this.props
|
||||
.onCreate(selectedType, values)
|
||||
.then(data => {
|
||||
successCallback("Saved.");
|
||||
this.props.dialog.close({ success: true, data });
|
||||
})
|
||||
.catch(error => {
|
||||
this.setState({ savingSource: false, currentStep: StepEnum.CONFIGURE_IT });
|
||||
errorCallback(get(error, "response.data.message", "Failed saving."));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
renderTypeSelector() {
|
||||
const { types } = this.props;
|
||||
const { searchText } = this.state;
|
||||
const filteredTypes = types.filter(
|
||||
type => isEmpty(searchText) || includes(type.name.toLowerCase(), searchText.toLowerCase())
|
||||
);
|
||||
return (
|
||||
<div className="m-t-10">
|
||||
<Search
|
||||
placeholder="Search..."
|
||||
onChange={e => this.setState({ searchText: e.target.value })}
|
||||
autoFocus
|
||||
data-test="SearchSource"
|
||||
/>
|
||||
<div className="scrollbox p-5 m-t-10" style={{ minHeight: "30vh", maxHeight: "40vh" }}>
|
||||
{isEmpty(filteredTypes) ? (
|
||||
<EmptyState className="" />
|
||||
) : (
|
||||
<List size="small" dataSource={filteredTypes} renderItem={item => this.renderItem(item)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderForm() {
|
||||
const { imageFolder, helpTriggerPrefix } = this.props;
|
||||
const { selectedType } = this.state;
|
||||
const fields = helper.getFields(selectedType);
|
||||
const helpTriggerType = `${helpTriggerPrefix}${toUpper(selectedType.type)}`;
|
||||
return (
|
||||
<div>
|
||||
<div className="d-flex justify-content-center align-items-center">
|
||||
<img className="p-5" src={`${imageFolder}/${selectedType.type}.png`} alt={selectedType.name} width="48" />
|
||||
<h4 className="m-0">{selectedType.name}</h4>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{HELP_TRIGGER_TYPES[helpTriggerType] && (
|
||||
<HelpTrigger className="f-13" type={helpTriggerType}>
|
||||
Setup Instructions <i className="fa fa-question-circle" />
|
||||
</HelpTrigger>
|
||||
)}
|
||||
</div>
|
||||
<DynamicForm id="sourceForm" fields={fields} onSubmit={this.createSource} feedbackIcons hideSubmitButton />
|
||||
{selectedType.type === "databricks" && (
|
||||
<small>
|
||||
By using the Databricks Data Source you agree to the Databricks JDBC/ODBC{" "}
|
||||
<Link href="https://databricks.com/spark/odbc-driver-download" target="_blank" rel="noopener noreferrer">
|
||||
Driver Download Terms and Conditions
|
||||
</Link>
|
||||
.
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderItem(item) {
|
||||
const { imageFolder } = this.props;
|
||||
return (
|
||||
<List.Item className="p-l-10 p-r-10 clickable" onClick={() => this.selectType(item)}>
|
||||
<PreviewCard
|
||||
title={item.name}
|
||||
imageUrl={`${imageFolder}/${item.type}.png`}
|
||||
roundedImage={false}
|
||||
data-test="PreviewItem"
|
||||
data-test-type={item.type}>
|
||||
<i className="fa fa-angle-double-right" />
|
||||
</PreviewCard>
|
||||
</List.Item>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { currentStep, savingSource } = this.state;
|
||||
const { dialog, sourceType } = this.props;
|
||||
return (
|
||||
<Modal
|
||||
{...dialog.props}
|
||||
title={`Create a New ${sourceType}`}
|
||||
footer={
|
||||
currentStep === StepEnum.SELECT_TYPE
|
||||
? [
|
||||
<Button key="cancel" onClick={() => dialog.dismiss()} data-test="CreateSourceCancelButton">
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button key="submit" type="primary" disabled>
|
||||
Create
|
||||
</Button>,
|
||||
]
|
||||
: [
|
||||
<Button key="previous" onClick={this.resetType}>
|
||||
Previous
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
htmlType="submit"
|
||||
form="sourceForm"
|
||||
type="primary"
|
||||
loading={savingSource}
|
||||
data-test="CreateSourceSaveButton">
|
||||
Create
|
||||
</Button>,
|
||||
]
|
||||
}>
|
||||
<div data-test="CreateSourceDialog">
|
||||
<Steps className="hidden-xs m-b-10" size="small" current={currentStep} progressDot>
|
||||
{currentStep === StepEnum.CONFIGURE_IT ? (
|
||||
<Step title={<a>Type Selection</a>} className="clickable" onClick={this.resetType} />
|
||||
) : (
|
||||
<Step title="Type Selection" />
|
||||
)}
|
||||
<Step title="Configuration" />
|
||||
<Step title="Done" />
|
||||
</Steps>
|
||||
{currentStep === StepEnum.SELECT_TYPE && this.renderTypeSelector()}
|
||||
{currentStep !== StepEnum.SELECT_TYPE && this.renderForm()}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default wrapDialog(CreateSourceDialog);
|
||||
152
client/app/components/CreateSourceDialog.tsx
Normal file
152
client/app/components/CreateSourceDialog.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React from "react";
|
||||
import { isEmpty, toUpper, includes, get } from "lodash";
|
||||
import Button from "antd/lib/button";
|
||||
import List from "antd/lib/list";
|
||||
import Modal from "antd/lib/modal";
|
||||
import Input from "antd/lib/input";
|
||||
import Steps from "antd/lib/steps";
|
||||
// @ts-expect-error ts-migrate(6133) FIXME: 'DialogPropType' is declared but its value is neve... Remove this comment to see the full error message
|
||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||
import Link from "@/components/Link";
|
||||
import { PreviewCard } from "@/components/PreviewCard";
|
||||
import EmptyState from "@/components/items-list/components/EmptyState";
|
||||
import DynamicForm from "@/components/dynamic-form/DynamicForm";
|
||||
import helper from "@/components/dynamic-form/dynamicFormHelper";
|
||||
import HelpTrigger, { TYPES as HELP_TRIGGER_TYPES } from "@/components/HelpTrigger";
|
||||
const { Step } = Steps;
|
||||
const { Search } = Input;
|
||||
const StepEnum = {
|
||||
SELECT_TYPE: 0,
|
||||
CONFIGURE_IT: 1,
|
||||
DONE: 2,
|
||||
};
|
||||
type OwnProps = {
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'DialogPropType' refers to a value, but is being u... Remove this comment to see the full error message
|
||||
dialog: DialogPropType;
|
||||
types?: any[];
|
||||
sourceType: string;
|
||||
imageFolder: string;
|
||||
helpTriggerPrefix?: string;
|
||||
onCreate: (...args: any[]) => any;
|
||||
};
|
||||
type State = any;
|
||||
type Props = OwnProps & typeof CreateSourceDialog.defaultProps;
|
||||
class CreateSourceDialog extends React.Component<Props, State> {
|
||||
static defaultProps = {
|
||||
types: [],
|
||||
helpTriggerPrefix: null,
|
||||
};
|
||||
state = {
|
||||
searchText: "",
|
||||
selectedType: null,
|
||||
savingSource: false,
|
||||
currentStep: StepEnum.SELECT_TYPE,
|
||||
};
|
||||
selectType = (selectedType: any) => {
|
||||
this.setState({ selectedType, currentStep: StepEnum.CONFIGURE_IT });
|
||||
};
|
||||
resetType = () => {
|
||||
if (this.state.currentStep === StepEnum.CONFIGURE_IT) {
|
||||
this.setState({ searchText: "", selectedType: null, currentStep: StepEnum.SELECT_TYPE });
|
||||
}
|
||||
};
|
||||
createSource = (values: any, successCallback: any, errorCallback: any) => {
|
||||
const { selectedType, savingSource } = this.state;
|
||||
if (!savingSource) {
|
||||
this.setState({ savingSource: true, currentStep: StepEnum.DONE });
|
||||
(this.props as any).onCreate(selectedType, values)
|
||||
.then((data: any) => {
|
||||
successCallback("Saved.");
|
||||
(this.props as any).dialog.close({ success: true, data });
|
||||
})
|
||||
.catch((error: any) => {
|
||||
this.setState({ savingSource: false, currentStep: StepEnum.CONFIGURE_IT });
|
||||
errorCallback(get(error, "response.data.message", "Failed saving."));
|
||||
});
|
||||
}
|
||||
};
|
||||
renderTypeSelector() {
|
||||
const { types } = this.props;
|
||||
const { searchText } = this.state;
|
||||
const filteredTypes = (types as any).filter((type: any) => isEmpty(searchText) || includes(type.name.toLowerCase(), searchText.toLowerCase()));
|
||||
return (<div className="m-t-10">
|
||||
<Search placeholder="Search..." onChange={e => this.setState({ searchText: e.target.value })} autoFocus data-test="SearchSource"/>
|
||||
<div className="scrollbox p-5 m-t-10" style={{ minHeight: "30vh", maxHeight: "40vh" }}>
|
||||
{isEmpty(filteredTypes) ? (<EmptyState className=""/>) : (<List size="small" dataSource={filteredTypes} renderItem={item => this.renderItem(item)}/>)}
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
renderForm() {
|
||||
const { imageFolder, helpTriggerPrefix } = this.props;
|
||||
const { selectedType } = this.state;
|
||||
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'null' is not assignable to param... Remove this comment to see the full error message
|
||||
const fields = helper.getFields(selectedType);
|
||||
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
|
||||
const helpTriggerType = `${helpTriggerPrefix}${toUpper(selectedType.type)}`;
|
||||
return (<div>
|
||||
<div className="d-flex justify-content-center align-items-center">
|
||||
{/* @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'. */}
|
||||
<img className="p-5" src={`${imageFolder}/${selectedType.type}.png`} alt={selectedType.name} width="48"/>
|
||||
{/* @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'. */}
|
||||
<h4 className="m-0">{selectedType.name}</h4>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{/* @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message */}
|
||||
{HELP_TRIGGER_TYPES[helpTriggerType] && (<HelpTrigger className="f-13" type={helpTriggerType}>
|
||||
Setup Instructions <i className="fa fa-question-circle"/>
|
||||
</HelpTrigger>)}
|
||||
</div>
|
||||
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'string' is not assignable to type 'never'. */}
|
||||
<DynamicForm id="sourceForm" fields={fields} onSubmit={this.createSource} feedbackIcons hideSubmitButton/>
|
||||
{/* @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'. */}
|
||||
{selectedType.type === "databricks" && (<small>
|
||||
By using the Databricks Data Source you agree to the Databricks JDBC/ODBC{" "}
|
||||
<Link href="https://databricks.com/spark/odbc-driver-download" target="_blank" rel="noopener noreferrer">
|
||||
Driver Download Terms and Conditions
|
||||
</Link>
|
||||
.
|
||||
</small>)}
|
||||
</div>);
|
||||
}
|
||||
renderItem(item: any) {
|
||||
const { imageFolder } = this.props;
|
||||
return (<List.Item className="p-l-10 p-r-10 clickable" onClick={() => this.selectType(item)}>
|
||||
<PreviewCard title={item.name} imageUrl={`${imageFolder}/${item.type}.png`} roundedImage={false} data-test="PreviewItem" data-test-type={item.type}>
|
||||
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'Element' is not assignable to type 'null | u... Remove this comment to see the full error message */}
|
||||
<i className="fa fa-angle-double-right"/>
|
||||
</PreviewCard>
|
||||
</List.Item>);
|
||||
}
|
||||
render() {
|
||||
const { currentStep, savingSource } = this.state;
|
||||
const { dialog, sourceType } = this.props;
|
||||
return (<Modal {...(dialog as any).props} title={`Create a New ${sourceType}`} footer={currentStep === StepEnum.SELECT_TYPE
|
||||
? [
|
||||
<Button key="cancel" onClick={() => (dialog as any).dismiss()} data-test="CreateSourceCancelButton">
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button key="submit" type="primary" disabled>
|
||||
Create
|
||||
</Button>,
|
||||
]
|
||||
: [
|
||||
<Button key="previous" onClick={this.resetType}>
|
||||
Previous
|
||||
</Button>,
|
||||
<Button key="submit" htmlType="submit" form="sourceForm" type="primary" loading={savingSource} data-test="CreateSourceSaveButton">
|
||||
Create
|
||||
</Button>,
|
||||
]}>
|
||||
<div data-test="CreateSourceDialog">
|
||||
<Steps className="hidden-xs m-b-10" size="small" current={currentStep} progressDot>
|
||||
{currentStep === StepEnum.CONFIGURE_IT ? (<Step title={<a>Type Selection</a>} className="clickable" onClick={this.resetType}/>) : (<Step title="Type Selection"/>)}
|
||||
<Step title="Configuration"/>
|
||||
<Step title="Done"/>
|
||||
</Steps>
|
||||
{currentStep === StepEnum.SELECT_TYPE && this.renderTypeSelector()}
|
||||
{currentStep !== StepEnum.SELECT_TYPE && this.renderForm()}
|
||||
</div>
|
||||
</Modal>);
|
||||
}
|
||||
}
|
||||
export default wrapDialog(CreateSourceDialog);
|
||||
@@ -1,43 +0,0 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import DatePicker from "antd/lib/date-picker";
|
||||
import { clientConfig } from "@/services/auth";
|
||||
import { Moment } from "@/components/proptypes";
|
||||
|
||||
const DateInput = React.forwardRef(({ defaultValue, value, onSelect, className, ...props }, ref) => {
|
||||
const format = clientConfig.dateFormat || "YYYY-MM-DD";
|
||||
const additionalAttributes = {};
|
||||
if (defaultValue && defaultValue.isValid()) {
|
||||
additionalAttributes.defaultValue = defaultValue;
|
||||
}
|
||||
if (value === null || (value && value.isValid())) {
|
||||
additionalAttributes.value = value;
|
||||
}
|
||||
return (
|
||||
<DatePicker
|
||||
ref={ref}
|
||||
className={className}
|
||||
{...additionalAttributes}
|
||||
format={format}
|
||||
placeholder="Select Date"
|
||||
onChange={onSelect}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DateInput.propTypes = {
|
||||
defaultValue: Moment,
|
||||
value: Moment,
|
||||
onSelect: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
DateInput.defaultProps = {
|
||||
defaultValue: null,
|
||||
value: undefined,
|
||||
onSelect: () => {},
|
||||
className: "",
|
||||
};
|
||||
|
||||
export default DateInput;
|
||||
31
client/app/components/DateInput.tsx
Normal file
31
client/app/components/DateInput.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
import DatePicker from "antd/lib/date-picker";
|
||||
import { clientConfig } from "@/services/auth";
|
||||
// @ts-expect-error ts-migrate(6133) FIXME: 'Moment' is declared but its value is never read.
|
||||
import { Moment } from "@/components/proptypes";
|
||||
type Props = {
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'Moment' refers to a value, but is being used as a... Remove this comment to see the full error message
|
||||
defaultValue?: Moment;
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'Moment' refers to a value, but is being used as a... Remove this comment to see the full error message
|
||||
value?: Moment;
|
||||
onSelect?: (...args: any[]) => any;
|
||||
className?: string;
|
||||
};
|
||||
const DateInput = React.forwardRef<any, Props>(({ defaultValue, value, onSelect, className, ...props }, ref) => {
|
||||
const format = (clientConfig as any).dateFormat || "YYYY-MM-DD";
|
||||
const additionalAttributes = {};
|
||||
if (defaultValue && defaultValue.isValid()) {
|
||||
(additionalAttributes as any).defaultValue = defaultValue;
|
||||
}
|
||||
if (value === null || (value && value.isValid())) {
|
||||
(additionalAttributes as any).value = value;
|
||||
}
|
||||
return (<DatePicker ref={ref} className={className} {...additionalAttributes} format={format} placeholder="Select Date" onChange={onSelect} {...props}/>);
|
||||
});
|
||||
DateInput.defaultProps = {
|
||||
defaultValue: null,
|
||||
value: undefined,
|
||||
onSelect: () => { },
|
||||
className: "",
|
||||
};
|
||||
export default DateInput;
|
||||
@@ -1,45 +0,0 @@
|
||||
import { isArray } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import DatePicker from "antd/lib/date-picker";
|
||||
import { clientConfig } from "@/services/auth";
|
||||
import { Moment } from "@/components/proptypes";
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const DateRangeInput = React.forwardRef(({ defaultValue, value, onSelect, className, ...props }, ref) => {
|
||||
const format = clientConfig.dateFormat || "YYYY-MM-DD";
|
||||
const additionalAttributes = {};
|
||||
if (isArray(defaultValue) && defaultValue[0].isValid() && defaultValue[1].isValid()) {
|
||||
additionalAttributes.defaultValue = defaultValue;
|
||||
}
|
||||
if (value === null || (isArray(value) && value[0].isValid() && value[1].isValid())) {
|
||||
additionalAttributes.value = value;
|
||||
}
|
||||
return (
|
||||
<RangePicker
|
||||
ref={ref}
|
||||
className={className}
|
||||
{...additionalAttributes}
|
||||
format={format}
|
||||
onChange={onSelect}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DateRangeInput.propTypes = {
|
||||
defaultValue: PropTypes.arrayOf(Moment),
|
||||
value: PropTypes.arrayOf(Moment),
|
||||
onSelect: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
DateRangeInput.defaultProps = {
|
||||
defaultValue: null,
|
||||
value: undefined,
|
||||
onSelect: () => {},
|
||||
className: "",
|
||||
};
|
||||
|
||||
export default DateRangeInput;
|
||||
34
client/app/components/DateRangeInput.tsx
Normal file
34
client/app/components/DateRangeInput.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { isArray } from "lodash";
|
||||
import React from "react";
|
||||
import DatePicker from "antd/lib/date-picker";
|
||||
import { clientConfig } from "@/services/auth";
|
||||
// @ts-expect-error ts-migrate(6133) FIXME: 'Moment' is declared but its value is never read.
|
||||
import { Moment } from "@/components/proptypes";
|
||||
const { RangePicker } = DatePicker;
|
||||
type Props = {
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'Moment' refers to a value, but is being used as a... Remove this comment to see the full error message
|
||||
defaultValue?: Moment[];
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'Moment' refers to a value, but is being used as a... Remove this comment to see the full error message
|
||||
value?: Moment[];
|
||||
onSelect?: (...args: any[]) => any;
|
||||
className?: string;
|
||||
};
|
||||
const DateRangeInput = React.forwardRef<any, Props>(({ defaultValue, value, onSelect, className, ...props }, ref) => {
|
||||
const format = (clientConfig as any).dateFormat || "YYYY-MM-DD";
|
||||
const additionalAttributes = {};
|
||||
if (isArray(defaultValue) && defaultValue[0].isValid() && defaultValue[1].isValid()) {
|
||||
(additionalAttributes as any).defaultValue = defaultValue;
|
||||
}
|
||||
if (value === null || (isArray(value) && value[0].isValid() && value[1].isValid())) {
|
||||
(additionalAttributes as any).value = value;
|
||||
}
|
||||
return (<RangePicker ref={ref} className={className} {...additionalAttributes} format={format} onChange={onSelect} {...props}/>);
|
||||
});
|
||||
DateRangeInput.defaultProps = {
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'null' is not assignable to type 'any[] | und... Remove this comment to see the full error message
|
||||
defaultValue: null,
|
||||
value: undefined,
|
||||
onSelect: () => { },
|
||||
className: "",
|
||||
};
|
||||
export default DateRangeInput;
|
||||
@@ -1,46 +0,0 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import DatePicker from "antd/lib/date-picker";
|
||||
import { clientConfig } from "@/services/auth";
|
||||
import { Moment } from "@/components/proptypes";
|
||||
|
||||
const DateTimeInput = React.forwardRef(({ defaultValue, value, withSeconds, onSelect, className, ...props }, ref) => {
|
||||
const format = (clientConfig.dateFormat || "YYYY-MM-DD") + (withSeconds ? " HH:mm:ss" : " HH:mm");
|
||||
const additionalAttributes = {};
|
||||
if (defaultValue && defaultValue.isValid()) {
|
||||
additionalAttributes.defaultValue = defaultValue;
|
||||
}
|
||||
if (value === null || (value && value.isValid())) {
|
||||
additionalAttributes.value = value;
|
||||
}
|
||||
return (
|
||||
<DatePicker
|
||||
ref={ref}
|
||||
className={className}
|
||||
showTime
|
||||
{...additionalAttributes}
|
||||
format={format}
|
||||
placeholder="Select Date and Time"
|
||||
onChange={onSelect}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DateTimeInput.propTypes = {
|
||||
defaultValue: Moment,
|
||||
value: Moment,
|
||||
withSeconds: PropTypes.bool,
|
||||
onSelect: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
DateTimeInput.defaultProps = {
|
||||
defaultValue: null,
|
||||
value: undefined,
|
||||
withSeconds: false,
|
||||
onSelect: () => {},
|
||||
className: "",
|
||||
};
|
||||
|
||||
export default DateTimeInput;
|
||||
33
client/app/components/DateTimeInput.tsx
Normal file
33
client/app/components/DateTimeInput.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
import DatePicker from "antd/lib/date-picker";
|
||||
import { clientConfig } from "@/services/auth";
|
||||
// @ts-expect-error ts-migrate(6133) FIXME: 'Moment' is declared but its value is never read.
|
||||
import { Moment } from "@/components/proptypes";
|
||||
type Props = {
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'Moment' refers to a value, but is being used as a... Remove this comment to see the full error message
|
||||
defaultValue?: Moment;
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'Moment' refers to a value, but is being used as a... Remove this comment to see the full error message
|
||||
value?: Moment;
|
||||
withSeconds?: boolean;
|
||||
onSelect?: (...args: any[]) => any;
|
||||
className?: string;
|
||||
};
|
||||
const DateTimeInput = React.forwardRef<any, Props>(({ defaultValue, value, withSeconds, onSelect, className, ...props }, ref) => {
|
||||
const format = ((clientConfig as any).dateFormat || "YYYY-MM-DD") + (withSeconds ? " HH:mm:ss" : " HH:mm");
|
||||
const additionalAttributes = {};
|
||||
if (defaultValue && defaultValue.isValid()) {
|
||||
(additionalAttributes as any).defaultValue = defaultValue;
|
||||
}
|
||||
if (value === null || (value && value.isValid())) {
|
||||
(additionalAttributes as any).value = value;
|
||||
}
|
||||
return (<DatePicker ref={ref} className={className} showTime {...additionalAttributes} format={format} placeholder="Select Date and Time" onChange={onSelect} {...props}/>);
|
||||
});
|
||||
DateTimeInput.defaultProps = {
|
||||
defaultValue: null,
|
||||
value: undefined,
|
||||
withSeconds: false,
|
||||
onSelect: () => { },
|
||||
className: "",
|
||||
};
|
||||
export default DateTimeInput;
|
||||
@@ -1,50 +0,0 @@
|
||||
import { isArray } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import DatePicker from "antd/lib/date-picker";
|
||||
import { clientConfig } from "@/services/auth";
|
||||
import { Moment } from "@/components/proptypes";
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const DateTimeRangeInput = React.forwardRef(
|
||||
({ defaultValue, value, withSeconds, onSelect, className, ...props }, ref) => {
|
||||
const format = (clientConfig.dateFormat || "YYYY-MM-DD") + (withSeconds ? " HH:mm:ss" : " HH:mm");
|
||||
const additionalAttributes = {};
|
||||
if (isArray(defaultValue) && defaultValue[0].isValid() && defaultValue[1].isValid()) {
|
||||
additionalAttributes.defaultValue = defaultValue;
|
||||
}
|
||||
if (value === null || (isArray(value) && value[0].isValid() && value[1].isValid())) {
|
||||
additionalAttributes.value = value;
|
||||
}
|
||||
return (
|
||||
<RangePicker
|
||||
ref={ref}
|
||||
className={className}
|
||||
showTime
|
||||
{...additionalAttributes}
|
||||
format={format}
|
||||
onChange={onSelect}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
DateTimeRangeInput.propTypes = {
|
||||
defaultValue: PropTypes.arrayOf(Moment),
|
||||
value: PropTypes.arrayOf(Moment),
|
||||
withSeconds: PropTypes.bool,
|
||||
onSelect: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
DateTimeRangeInput.defaultProps = {
|
||||
defaultValue: null,
|
||||
value: undefined,
|
||||
withSeconds: false,
|
||||
onSelect: () => {},
|
||||
className: "",
|
||||
};
|
||||
|
||||
export default DateTimeRangeInput;
|
||||
36
client/app/components/DateTimeRangeInput.tsx
Normal file
36
client/app/components/DateTimeRangeInput.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { isArray } from "lodash";
|
||||
import React from "react";
|
||||
import DatePicker from "antd/lib/date-picker";
|
||||
import { clientConfig } from "@/services/auth";
|
||||
// @ts-expect-error ts-migrate(6133) FIXME: 'Moment' is declared but its value is never read.
|
||||
import { Moment } from "@/components/proptypes";
|
||||
const { RangePicker } = DatePicker;
|
||||
type Props = {
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'Moment' refers to a value, but is being used as a... Remove this comment to see the full error message
|
||||
defaultValue?: Moment[];
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'Moment' refers to a value, but is being used as a... Remove this comment to see the full error message
|
||||
value?: Moment[];
|
||||
withSeconds?: boolean;
|
||||
onSelect?: (...args: any[]) => any;
|
||||
className?: string;
|
||||
};
|
||||
const DateTimeRangeInput = React.forwardRef<any, Props>(({ defaultValue, value, withSeconds, onSelect, className, ...props }, ref) => {
|
||||
const format = ((clientConfig as any).dateFormat || "YYYY-MM-DD") + (withSeconds ? " HH:mm:ss" : " HH:mm");
|
||||
const additionalAttributes = {};
|
||||
if (isArray(defaultValue) && defaultValue[0].isValid() && defaultValue[1].isValid()) {
|
||||
(additionalAttributes as any).defaultValue = defaultValue;
|
||||
}
|
||||
if (value === null || (isArray(value) && value[0].isValid() && value[1].isValid())) {
|
||||
(additionalAttributes as any).value = value;
|
||||
}
|
||||
return (<RangePicker ref={ref} className={className} showTime {...additionalAttributes} format={format} onChange={onSelect} {...props}/>);
|
||||
});
|
||||
DateTimeRangeInput.defaultProps = {
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'null' is not assignable to type 'any[] | und... Remove this comment to see the full error message
|
||||
defaultValue: null,
|
||||
value: undefined,
|
||||
withSeconds: false,
|
||||
onSelect: () => { },
|
||||
className: "",
|
||||
};
|
||||
export default DateTimeRangeInput;
|
||||
@@ -1,227 +0,0 @@
|
||||
import { isFunction } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
/**
|
||||
Wrapper for dialogs based on Ant's <Modal> component.
|
||||
|
||||
|
||||
Using wrapped dialogs
|
||||
=====================
|
||||
|
||||
Wrapped component is an object with two fields:
|
||||
|
||||
{
|
||||
showModal: (dialogProps) => object({
|
||||
close: (result) => void,
|
||||
dismiss: (reason) => void,
|
||||
onClose: (handler) => this,
|
||||
onDismiss: (handler) => this,
|
||||
}),
|
||||
Component: React.Component, // wrapped dialog component
|
||||
}
|
||||
|
||||
To open dialog, use `showModal` method; optionally you can pass additional properties that
|
||||
will be expanded on wrapped component:
|
||||
|
||||
const dialog = SomeWrappedDialog.showModal()
|
||||
|
||||
const dialog = SomeWrappedDialog.showModal({ greeting: 'Hello' })
|
||||
|
||||
To get result of modal, use `onClose`/`onDismiss` setters:
|
||||
|
||||
dialog
|
||||
.onClose(result => { ... }) // pressed OK button or used `close` method
|
||||
.onDismiss(result => { ... }) // pressed Cancel button or used `dismiss` method
|
||||
|
||||
If `onClose`/`onDismiss` returns a promise - dialog wrapper will stop handling further close/dismiss
|
||||
requests and will show loader on a corresponding button until that promise is fulfilled (either resolved or
|
||||
rejected). If that promise will be rejected - dialog close/dismiss will be abandoned. Use promise returned
|
||||
from `close`/`dismiss` methods to handle errors (if needed).
|
||||
|
||||
Also, dialog has `close` and `dismiss` methods that allows to close dialog by caller. Passed arguments
|
||||
will be passed to a corresponding handler. Both methods will return the promise returned from `onClose` and
|
||||
`onDismiss` callbacks. `update` method allows to pass new properties to dialog.
|
||||
|
||||
|
||||
Creating a dialog
|
||||
================
|
||||
|
||||
1. Add imports:
|
||||
|
||||
import { wrap as wrapDialog, DialogPropType } from 'path/to/DialogWrapper';
|
||||
|
||||
2. define a `dialog` property on your component:
|
||||
|
||||
propTypes = {
|
||||
dialog: DialogPropType.isRequired,
|
||||
};
|
||||
|
||||
`dialog` property is an object:
|
||||
|
||||
{
|
||||
props: object, // properties for <Modal> component;
|
||||
close: (result) => void, // method to confirm dialog; `result` will be returned to caller
|
||||
dismiss: (reason) => void, // method to reject dialog; `reason` will be returned to caller
|
||||
}
|
||||
|
||||
3. expand additional properties on <Modal> component:
|
||||
|
||||
render() {
|
||||
const { dialog } = this.props;
|
||||
return (
|
||||
<Modal {...dialog.props}>
|
||||
);
|
||||
}
|
||||
|
||||
4. wrap your component and export it:
|
||||
|
||||
export default wrapDialog(YourComponent).
|
||||
|
||||
Your component is ready to use. Wrapper will manage <Modal>'s visibility and events.
|
||||
If you want to override behavior of `onOk`/`onCancel` - don't forget to close dialog:
|
||||
|
||||
customOkHandler() {
|
||||
this.saveData().then(() => {
|
||||
this.props.dialog.close({ success: true }); // or dismiss();
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dialog } = this.props;
|
||||
return (
|
||||
<Modal {...dialog.props} onOk={() => this.customOkHandler()}>
|
||||
);
|
||||
}
|
||||
*/
|
||||
|
||||
export const DialogPropType = PropTypes.shape({
|
||||
props: PropTypes.shape({
|
||||
visible: PropTypes.bool,
|
||||
onOk: PropTypes.func,
|
||||
onCancel: PropTypes.func,
|
||||
afterClose: PropTypes.func,
|
||||
}).isRequired,
|
||||
close: PropTypes.func.isRequired,
|
||||
dismiss: PropTypes.func.isRequired,
|
||||
});
|
||||
|
||||
function openDialog(DialogComponent, props) {
|
||||
const dialog = {
|
||||
props: {
|
||||
visible: true,
|
||||
okButtonProps: {},
|
||||
cancelButtonProps: {},
|
||||
onOk: () => {},
|
||||
onCancel: () => {},
|
||||
afterClose: () => {},
|
||||
},
|
||||
close: () => {},
|
||||
dismiss: () => {},
|
||||
};
|
||||
|
||||
let pendingCloseTask = null;
|
||||
|
||||
const handlers = {
|
||||
onClose: () => {},
|
||||
onDismiss: () => {},
|
||||
};
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
||||
function render() {
|
||||
ReactDOM.render(<DialogComponent {...props} dialog={dialog} />, container);
|
||||
}
|
||||
|
||||
function destroyDialog() {
|
||||
// Allow calling chain to roll up, and then destroy component
|
||||
setTimeout(() => {
|
||||
ReactDOM.unmountComponentAtNode(container);
|
||||
document.body.removeChild(container);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
function processDialogClose(result, setAdditionalDialogProps) {
|
||||
dialog.props.okButtonProps = { disabled: true };
|
||||
dialog.props.cancelButtonProps = { disabled: true };
|
||||
setAdditionalDialogProps();
|
||||
render();
|
||||
|
||||
return Promise.resolve(result)
|
||||
.then(() => {
|
||||
dialog.props.visible = false;
|
||||
})
|
||||
.finally(() => {
|
||||
dialog.props.okButtonProps = {};
|
||||
dialog.props.cancelButtonProps = {};
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
function closeDialog(result) {
|
||||
if (!pendingCloseTask) {
|
||||
pendingCloseTask = processDialogClose(handlers.onClose(result), () => {
|
||||
dialog.props.okButtonProps.loading = true;
|
||||
}).finally(() => {
|
||||
pendingCloseTask = null;
|
||||
});
|
||||
}
|
||||
return pendingCloseTask;
|
||||
}
|
||||
|
||||
function dismissDialog(result) {
|
||||
if (!pendingCloseTask) {
|
||||
pendingCloseTask = processDialogClose(handlers.onDismiss(result), () => {
|
||||
dialog.props.cancelButtonProps.loading = true;
|
||||
}).finally(() => {
|
||||
pendingCloseTask = null;
|
||||
});
|
||||
}
|
||||
return pendingCloseTask;
|
||||
}
|
||||
|
||||
dialog.props.onOk = closeDialog;
|
||||
dialog.props.onCancel = dismissDialog;
|
||||
dialog.props.afterClose = destroyDialog;
|
||||
dialog.close = closeDialog;
|
||||
dialog.dismiss = dismissDialog;
|
||||
|
||||
const result = {
|
||||
close: closeDialog,
|
||||
dismiss: dismissDialog,
|
||||
update: newProps => {
|
||||
props = { ...props, ...newProps };
|
||||
render();
|
||||
},
|
||||
onClose: handler => {
|
||||
if (isFunction(handler)) {
|
||||
handlers.onClose = handler;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
onDismiss: handler => {
|
||||
if (isFunction(handler)) {
|
||||
handlers.onDismiss = handler;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
render(); // show it only when all structures initialized to avoid unnecessary re-rendering
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function wrap(DialogComponent) {
|
||||
return {
|
||||
Component: DialogComponent,
|
||||
showModal: props => openDialog(DialogComponent, props),
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
DialogPropType,
|
||||
wrap,
|
||||
};
|
||||
223
client/app/components/DialogWrapper.tsx
Normal file
223
client/app/components/DialogWrapper.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { isFunction } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import ReactDOM from "react-dom";
|
||||
type DialogPropType = {
|
||||
props: {
|
||||
visible?: boolean;
|
||||
onOk?: (...args: any[]) => any;
|
||||
onCancel?: (...args: any[]) => any;
|
||||
afterClose?: (...args: any[]) => any;
|
||||
};
|
||||
close: (...args: any[]) => any;
|
||||
dismiss: (...args: any[]) => any;
|
||||
};
|
||||
/**
|
||||
Wrapper for dialogs based on Ant's <Modal> component.
|
||||
|
||||
|
||||
Using wrapped dialogs
|
||||
=====================
|
||||
|
||||
Wrapped component is an object with two fields:
|
||||
|
||||
{
|
||||
showModal: (dialogProps) => object({
|
||||
close: (result) => void,
|
||||
dismiss: (reason) => void,
|
||||
onClose: (handler) => this,
|
||||
onDismiss: (handler) => this,
|
||||
}),
|
||||
Component: React.Component, // wrapped dialog component
|
||||
}
|
||||
|
||||
To open dialog, use `showModal` method; optionally you can pass additional properties that
|
||||
will be expanded on wrapped component:
|
||||
|
||||
const dialog = SomeWrappedDialog.showModal()
|
||||
|
||||
const dialog = SomeWrappedDialog.showModal({ greeting: 'Hello' })
|
||||
|
||||
To get result of modal, use `onClose`/`onDismiss` setters:
|
||||
|
||||
dialog
|
||||
.onClose(result => { ... }) // pressed OK button or used `close` method
|
||||
.onDismiss(result => { ... }) // pressed Cancel button or used `dismiss` method
|
||||
|
||||
If `onClose`/`onDismiss` returns a promise - dialog wrapper will stop handling further close/dismiss
|
||||
requests and will show loader on a corresponding button until that promise is fulfilled (either resolved or
|
||||
rejected). If that promise will be rejected - dialog close/dismiss will be abandoned. Use promise returned
|
||||
from `close`/`dismiss` methods to handle errors (if needed).
|
||||
|
||||
Also, dialog has `close` and `dismiss` methods that allows to close dialog by caller. Passed arguments
|
||||
will be passed to a corresponding handler. Both methods will return the promise returned from `onClose` and
|
||||
`onDismiss` callbacks. `update` method allows to pass new properties to dialog.
|
||||
|
||||
|
||||
Creating a dialog
|
||||
================
|
||||
|
||||
1. Add imports:
|
||||
|
||||
import { wrap as wrapDialog, DialogPropType } from 'path/to/DialogWrapper';
|
||||
|
||||
2. define a `dialog` property on your component:
|
||||
|
||||
propTypes = {
|
||||
dialog: DialogPropType.isRequired,
|
||||
};
|
||||
|
||||
`dialog` property is an object:
|
||||
|
||||
{
|
||||
props: object, // properties for <Modal> component;
|
||||
close: (result) => void, // method to confirm dialog; `result` will be returned to caller
|
||||
dismiss: (reason) => void, // method to reject dialog; `reason` will be returned to caller
|
||||
}
|
||||
|
||||
3. expand additional properties on <Modal> component:
|
||||
|
||||
render() {
|
||||
const { dialog } = this.props;
|
||||
return (
|
||||
<Modal {...dialog.props}>
|
||||
);
|
||||
}
|
||||
|
||||
4. wrap your component and it:
|
||||
|
||||
export default wrapDialog(YourComponent).
|
||||
|
||||
Your component is ready to use. Wrapper will manage <Modal>'s visibility and events.
|
||||
If you want to override behavior of `onOk`/`onCancel` - don't forget to close dialog:
|
||||
|
||||
customOkHandler() {
|
||||
this.saveData().then(() => {
|
||||
this.props.dialog.close({ success: true }); // or dismiss();
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dialog } = this.props;
|
||||
return (
|
||||
<Modal {...dialog.props} onOk={() => this.customOkHandler()}>
|
||||
);
|
||||
}
|
||||
*/
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'Requireable<InferProps<{ props: Validator<In... Remove this comment to see the full error message
|
||||
export const DialogPropType: PropTypes.Requireable<DialogPropType> = PropTypes.shape({
|
||||
props: PropTypes.shape({
|
||||
visible: PropTypes.bool,
|
||||
onOk: PropTypes.func,
|
||||
onCancel: PropTypes.func,
|
||||
afterClose: PropTypes.func,
|
||||
}).isRequired,
|
||||
close: PropTypes.func.isRequired,
|
||||
dismiss: PropTypes.func.isRequired,
|
||||
});
|
||||
|
||||
function openDialog(DialogComponent: any, props: any) {
|
||||
const dialog = {
|
||||
props: {
|
||||
visible: true,
|
||||
okButtonProps: {},
|
||||
cancelButtonProps: {},
|
||||
onOk: () => { },
|
||||
onCancel: () => { },
|
||||
afterClose: () => { },
|
||||
},
|
||||
close: () => { },
|
||||
dismiss: () => { },
|
||||
};
|
||||
let pendingCloseTask: any = null;
|
||||
const handlers = {
|
||||
onClose: () => { },
|
||||
onDismiss: () => { },
|
||||
};
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
function render() {
|
||||
ReactDOM.render(<DialogComponent {...props} dialog={dialog}/>, container);
|
||||
}
|
||||
function destroyDialog() {
|
||||
// Allow calling chain to roll up, and then destroy component
|
||||
setTimeout(() => {
|
||||
ReactDOM.unmountComponentAtNode(container);
|
||||
document.body.removeChild(container);
|
||||
}, 10);
|
||||
}
|
||||
function processDialogClose(result: any, setAdditionalDialogProps: any) {
|
||||
dialog.props.okButtonProps = { disabled: true };
|
||||
dialog.props.cancelButtonProps = { disabled: true };
|
||||
setAdditionalDialogProps();
|
||||
render();
|
||||
return Promise.resolve(result)
|
||||
.then(() => {
|
||||
dialog.props.visible = false;
|
||||
})
|
||||
.finally(() => {
|
||||
dialog.props.okButtonProps = {};
|
||||
dialog.props.cancelButtonProps = {};
|
||||
render();
|
||||
});
|
||||
}
|
||||
function closeDialog(result: any) {
|
||||
if (!pendingCloseTask) {
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 0 arguments, but got 1.
|
||||
pendingCloseTask = processDialogClose(handlers.onClose(result), () => {
|
||||
(dialog.props.okButtonProps as any).loading = true;
|
||||
}).finally(() => {
|
||||
pendingCloseTask = null;
|
||||
});
|
||||
}
|
||||
return pendingCloseTask;
|
||||
}
|
||||
function dismissDialog(result: any) {
|
||||
if (!pendingCloseTask) {
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 0 arguments, but got 1.
|
||||
pendingCloseTask = processDialogClose(handlers.onDismiss(result), () => {
|
||||
(dialog.props.cancelButtonProps as any).loading = true;
|
||||
}).finally(() => {
|
||||
pendingCloseTask = null;
|
||||
});
|
||||
}
|
||||
return pendingCloseTask;
|
||||
}
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type '(result: any) => any' is not assignable to t... Remove this comment to see the full error message
|
||||
dialog.props.onOk = closeDialog;
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type '(result: any) => any' is not assignable to t... Remove this comment to see the full error message
|
||||
dialog.props.onCancel = dismissDialog;
|
||||
dialog.props.afterClose = destroyDialog;
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type '(result: any) => any' is not assignable to t... Remove this comment to see the full error message
|
||||
dialog.close = closeDialog;
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type '(result: any) => any' is not assignable to t... Remove this comment to see the full error message
|
||||
dialog.dismiss = dismissDialog;
|
||||
const result = {
|
||||
close: closeDialog,
|
||||
dismiss: dismissDialog,
|
||||
update: (newProps: any) => {
|
||||
props = { ...props, ...newProps };
|
||||
render();
|
||||
},
|
||||
onClose: (handler: any) => {
|
||||
if (isFunction(handler)) {
|
||||
handlers.onClose = handler;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
onDismiss: (handler: any) => {
|
||||
if (isFunction(handler)) {
|
||||
handlers.onDismiss = handler;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
render(); // show it only when all structures initialized to avoid unnecessary re-rendering
|
||||
return result;
|
||||
}
|
||||
export function wrap(DialogComponent: any) {
|
||||
return {
|
||||
Component: DialogComponent,
|
||||
showModal: (props: any) => openDialog(DialogComponent, props),
|
||||
};
|
||||
}
|
||||
@@ -1,32 +1,35 @@
|
||||
import { isFunction, isString, isUndefined } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const componentsRegistry = new Map();
|
||||
const activeInstances = new Set();
|
||||
|
||||
export function registerComponent(name, component) {
|
||||
export function registerComponent(name: any, component: any) {
|
||||
if (isString(name) && name !== "") {
|
||||
componentsRegistry.set(name, isFunction(component) ? component : null);
|
||||
// Refresh active DynamicComponent instances which use this component
|
||||
activeInstances.forEach(dynamicComponent => {
|
||||
// @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'.
|
||||
if (dynamicComponent.props.name === name) {
|
||||
// @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'.
|
||||
dynamicComponent.forceUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function unregisterComponent(name) {
|
||||
export function unregisterComponent(name: any) {
|
||||
registerComponent(name, null);
|
||||
}
|
||||
|
||||
export default class DynamicComponent extends React.Component {
|
||||
static propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
fallback: PropTypes.node,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
type OwnProps = {
|
||||
name: string;
|
||||
fallback?: React.ReactNode;
|
||||
};
|
||||
|
||||
type Props = OwnProps & typeof DynamicComponent.defaultProps;
|
||||
|
||||
export default class DynamicComponent extends React.Component<Props> {
|
||||
|
||||
static defaultProps = {
|
||||
children: null,
|
||||
@@ -1,104 +0,0 @@
|
||||
import { trim } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import cx from "classnames";
|
||||
import Input from "antd/lib/input";
|
||||
|
||||
export default class EditInPlace extends React.Component {
|
||||
static propTypes = {
|
||||
ignoreBlanks: PropTypes.bool,
|
||||
isEditable: PropTypes.bool,
|
||||
placeholder: PropTypes.string,
|
||||
value: PropTypes.string,
|
||||
onDone: PropTypes.func.isRequired,
|
||||
onStopEditing: PropTypes.func,
|
||||
multiline: PropTypes.bool,
|
||||
editorProps: PropTypes.object,
|
||||
defaultEditing: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
ignoreBlanks: false,
|
||||
isEditable: true,
|
||||
placeholder: "",
|
||||
value: "",
|
||||
onStopEditing: () => {},
|
||||
multiline: false,
|
||||
editorProps: {},
|
||||
defaultEditing: false,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
editing: props.defaultEditing,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(_, prevState) {
|
||||
if (!this.state.editing && prevState.editing) {
|
||||
this.props.onStopEditing();
|
||||
}
|
||||
}
|
||||
|
||||
startEditing = () => {
|
||||
if (this.props.isEditable) {
|
||||
this.setState({ editing: true });
|
||||
}
|
||||
};
|
||||
|
||||
stopEditing = currentValue => {
|
||||
const newValue = trim(currentValue);
|
||||
const ignorableBlank = this.props.ignoreBlanks && newValue === "";
|
||||
if (!ignorableBlank && newValue !== this.props.value) {
|
||||
this.props.onDone(newValue);
|
||||
}
|
||||
this.setState({ editing: false });
|
||||
};
|
||||
|
||||
handleKeyDown = event => {
|
||||
if (event.keyCode === 13 && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
this.stopEditing(event.target.value);
|
||||
} else if (event.keyCode === 27) {
|
||||
this.setState({ editing: false });
|
||||
}
|
||||
};
|
||||
|
||||
renderNormal = () =>
|
||||
this.props.value ? (
|
||||
<span
|
||||
role="presentation"
|
||||
onFocus={this.startEditing}
|
||||
onClick={this.startEditing}
|
||||
className={this.props.isEditable ? "editable" : ""}>
|
||||
{this.props.value}
|
||||
</span>
|
||||
) : (
|
||||
<a className="clickable" onClick={this.startEditing}>
|
||||
{this.props.placeholder}
|
||||
</a>
|
||||
);
|
||||
|
||||
renderEdit = () => {
|
||||
const { multiline, value, editorProps } = this.props;
|
||||
const InputComponent = multiline ? Input.TextArea : Input;
|
||||
return (
|
||||
<InputComponent
|
||||
defaultValue={value}
|
||||
onBlur={e => this.stopEditing(e.target.value)}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
autoFocus
|
||||
{...editorProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<span className={cx("edit-in-place", { active: this.state.editing }, this.props.className)}>
|
||||
{this.state.editing ? this.renderEdit() : this.renderNormal()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
77
client/app/components/EditInPlace.tsx
Normal file
77
client/app/components/EditInPlace.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { trim } from "lodash";
|
||||
import React from "react";
|
||||
import cx from "classnames";
|
||||
import Input from "antd/lib/input";
|
||||
type OwnProps = {
|
||||
ignoreBlanks?: boolean;
|
||||
isEditable?: boolean;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
onDone: (...args: any[]) => any;
|
||||
onStopEditing?: (...args: any[]) => any;
|
||||
multiline?: boolean;
|
||||
editorProps?: any;
|
||||
defaultEditing?: boolean;
|
||||
};
|
||||
type State = any;
|
||||
type Props = OwnProps & typeof EditInPlace.defaultProps;
|
||||
export default class EditInPlace extends React.Component<Props, State> {
|
||||
static defaultProps = {
|
||||
ignoreBlanks: false,
|
||||
isEditable: true,
|
||||
placeholder: "",
|
||||
value: "",
|
||||
onStopEditing: () => { },
|
||||
multiline: false,
|
||||
editorProps: {},
|
||||
defaultEditing: false,
|
||||
};
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
editing: props.defaultEditing,
|
||||
};
|
||||
}
|
||||
componentDidUpdate(_: Props, prevState: State) {
|
||||
if (!this.state.editing && prevState.editing) {
|
||||
this.props.onStopEditing();
|
||||
}
|
||||
}
|
||||
startEditing = () => {
|
||||
if (this.props.isEditable) {
|
||||
this.setState({ editing: true });
|
||||
}
|
||||
};
|
||||
stopEditing = (currentValue: any) => {
|
||||
const newValue = trim(currentValue);
|
||||
const ignorableBlank = this.props.ignoreBlanks && newValue === "";
|
||||
if (!ignorableBlank && newValue !== this.props.value) {
|
||||
this.props.onDone(newValue);
|
||||
}
|
||||
this.setState({ editing: false });
|
||||
};
|
||||
handleKeyDown = (event: any) => {
|
||||
if (event.keyCode === 13 && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
this.stopEditing(event.target.value);
|
||||
}
|
||||
else if (event.keyCode === 27) {
|
||||
this.setState({ editing: false });
|
||||
}
|
||||
};
|
||||
renderNormal = () => this.props.value ? (<span role="presentation" onFocus={this.startEditing} onClick={this.startEditing} className={this.props.isEditable ? "editable" : ""}>
|
||||
{this.props.value}
|
||||
</span>) : (<a className="clickable" onClick={this.startEditing}>
|
||||
{this.props.placeholder}
|
||||
</a>);
|
||||
renderEdit = () => {
|
||||
const { multiline, value, editorProps } = this.props;
|
||||
const InputComponent = multiline ? Input.TextArea : Input;
|
||||
return (<InputComponent defaultValue={value} onBlur={(e: any) => this.stopEditing(e.target.value)} onKeyDown={this.handleKeyDown} autoFocus {...editorProps}/>);
|
||||
};
|
||||
render() {
|
||||
return (<span className={cx("edit-in-place", { active: this.state.editing }, (this.props as any).className)}>
|
||||
{this.state.editing ? this.renderEdit() : this.renderNormal()}
|
||||
</span>);
|
||||
}
|
||||
}
|
||||
193
client/app/components/EditParameterSettingsDialog.tsx
Normal file
193
client/app/components/EditParameterSettingsDialog.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { includes, words, capitalize, clone, isNull } from "lodash";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import Checkbox from "antd/lib/checkbox";
|
||||
import Modal from "antd/lib/modal";
|
||||
import Form from "antd/lib/form";
|
||||
import Button from "antd/lib/button";
|
||||
import Select from "antd/lib/select";
|
||||
import Input from "antd/lib/input";
|
||||
import Divider from "antd/lib/divider";
|
||||
// @ts-expect-error ts-migrate(6133) FIXME: 'DialogPropType' is declared but its value is neve... Remove this comment to see the full error message
|
||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||
import QuerySelector from "@/components/QuerySelector";
|
||||
import { Query } from "@/services/query";
|
||||
const { Option } = Select;
|
||||
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
|
||||
function getDefaultTitle(text: any) {
|
||||
return capitalize(words(text).join(" ")); // humanize
|
||||
}
|
||||
function isTypeDateRange(type: any) {
|
||||
return /-range/.test(type);
|
||||
}
|
||||
function joinExampleList(multiValuesOptions: any) {
|
||||
const { prefix, suffix } = multiValuesOptions;
|
||||
return ["value1", "value2", "value3"].map(value => `${prefix}${value}${suffix}`).join(",");
|
||||
}
|
||||
type NameInputProps = {
|
||||
name: string;
|
||||
onChange: (...args: any[]) => any;
|
||||
existingNames: string[];
|
||||
setValidation: (...args: any[]) => any;
|
||||
type: string;
|
||||
};
|
||||
function NameInput({ name, type, onChange, existingNames, setValidation }: NameInputProps) {
|
||||
let helpText = "";
|
||||
let validateStatus = "";
|
||||
if (!name) {
|
||||
helpText = "Choose a keyword for this parameter";
|
||||
setValidation(false);
|
||||
}
|
||||
else if (includes(existingNames, name)) {
|
||||
helpText = "Parameter with this name already exists";
|
||||
setValidation(false);
|
||||
validateStatus = "error";
|
||||
}
|
||||
else {
|
||||
if (isTypeDateRange(type)) {
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'Element' is not assignable to type 'string'.
|
||||
helpText = (<React.Fragment>
|
||||
Appears in query as{" "}
|
||||
<code style={{ display: "inline-block", color: "inherit" }}>{`{{${name}.start}} {{${name}.end}}`}</code>
|
||||
</React.Fragment>);
|
||||
}
|
||||
setValidation(true);
|
||||
}
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'string' is not assignable to type '"" | "err... Remove this comment to see the full error message
|
||||
return (<Form.Item required label="Keyword" help={helpText} validateStatus={validateStatus} {...formItemProps}>
|
||||
<Input onChange={e => onChange(e.target.value)} autoFocus/>
|
||||
</Form.Item>);
|
||||
}
|
||||
type OwnEditParameterSettingsDialogProps = {
|
||||
parameter: any;
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'DialogPropType' refers to a value, but is being u... Remove this comment to see the full error message
|
||||
dialog: DialogPropType;
|
||||
existingParams?: string[];
|
||||
};
|
||||
type EditParameterSettingsDialogProps = OwnEditParameterSettingsDialogProps & typeof EditParameterSettingsDialog.defaultProps;
|
||||
function EditParameterSettingsDialog(props: EditParameterSettingsDialogProps) {
|
||||
const [param, setParam] = useState(clone(props.parameter));
|
||||
const [isNameValid, setIsNameValid] = useState(true);
|
||||
const [initialQuery, setInitialQuery] = useState();
|
||||
const isNew = !props.parameter.name;
|
||||
// fetch query by id
|
||||
useEffect(() => {
|
||||
const queryId = props.parameter.queryId;
|
||||
if (queryId) {
|
||||
(Query as any).get({ id: queryId }).then(setInitialQuery);
|
||||
}
|
||||
}, [props.parameter.queryId]);
|
||||
function isFulfilled() {
|
||||
// name
|
||||
if (!isNameValid) {
|
||||
return false;
|
||||
}
|
||||
// title
|
||||
if (param.title === "") {
|
||||
return false;
|
||||
}
|
||||
// query
|
||||
if (param.type === "query" && !param.queryId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function onConfirm() {
|
||||
// update title to default
|
||||
if (!param.title) {
|
||||
// forced to do this cause param won't update in time for save
|
||||
param.title = getDefaultTitle(param.name);
|
||||
setParam(param);
|
||||
}
|
||||
props.dialog.close(param);
|
||||
}
|
||||
return (<Modal {...props.dialog.props} title={isNew ? "Add Parameter" : param.name} width={600} footer={[
|
||||
<Button key="cancel" onClick={props.dialog.dismiss}>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button key="submit" htmlType="submit" disabled={!isFulfilled()} type="primary" form="paramForm" data-test="SaveParameterSettings">
|
||||
{isNew ? "Add Parameter" : "OK"}
|
||||
</Button>,
|
||||
]}>
|
||||
<Form layout="horizontal" onFinish={onConfirm} id="paramForm">
|
||||
{isNew && (<NameInput name={param.name} onChange={name => setParam({ ...param, name })} setValidation={setIsNameValid} existingNames={props.existingParams} type={param.type}/>)}
|
||||
<Form.Item required label="Title" {...formItemProps}>
|
||||
<Input value={isNull(param.title) ? getDefaultTitle(param.name) : param.title} onChange={e => setParam({ ...param, title: e.target.value })} data-test="ParameterTitleInput"/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Type" {...formItemProps}>
|
||||
<Select value={param.type} onChange={type => setParam({ ...param, type })} data-test="ParameterTypeSelect">
|
||||
<Option value="text" data-test="TextParameterTypeOption">
|
||||
Text
|
||||
</Option>
|
||||
<Option value="number" data-test="NumberParameterTypeOption">
|
||||
Number
|
||||
</Option>
|
||||
<Option value="enum">Dropdown List</Option>
|
||||
<Option value="query">Query Based Dropdown List</Option>
|
||||
{/* @ts-expect-error ts-migrate(2741) FIXME: Property 'value' is missing in type '{ children: E... Remove this comment to see the full error message */}
|
||||
<Option disabled key="dv1">
|
||||
<Divider className="select-option-divider"/>
|
||||
</Option>
|
||||
<Option value="date" data-test="DateParameterTypeOption">
|
||||
Date
|
||||
</Option>
|
||||
<Option value="datetime-local" data-test="DateTimeParameterTypeOption">
|
||||
Date and Time
|
||||
</Option>
|
||||
<Option value="datetime-with-seconds">Date and Time (with seconds)</Option>
|
||||
{/* @ts-expect-error ts-migrate(2741) FIXME: Property 'value' is missing in type '{ children: E... Remove this comment to see the full error message */}
|
||||
<Option disabled key="dv2">
|
||||
<Divider className="select-option-divider"/>
|
||||
</Option>
|
||||
<Option value="date-range" data-test="DateRangeParameterTypeOption">
|
||||
Date Range
|
||||
</Option>
|
||||
<Option value="datetime-range">Date and Time Range</Option>
|
||||
<Option value="datetime-range-with-seconds">Date and Time Range (with seconds)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{param.type === "enum" && (<Form.Item label="Values" help="Dropdown list values (newline delimited)" {...formItemProps}>
|
||||
<Input.TextArea rows={3} value={param.enumOptions} onChange={e => setParam({ ...param, enumOptions: e.target.value })}/>
|
||||
</Form.Item>)}
|
||||
{param.type === "query" && (<Form.Item label="Query" help="Select query to load dropdown values from" {...formItemProps}>
|
||||
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'undefined' is not assignable to type 'never'... Remove this comment to see the full error message */}
|
||||
<QuerySelector selectedQuery={initialQuery} onChange={(q: any) => setParam({ ...param, queryId: q && q.id })} type="select"/>
|
||||
</Form.Item>)}
|
||||
{(param.type === "enum" || param.type === "query") && (<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
|
||||
<Checkbox defaultChecked={!!param.multiValuesOptions} onChange={e => setParam({
|
||||
...param,
|
||||
multiValuesOptions: e.target.checked
|
||||
? {
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
separator: ",",
|
||||
}
|
||||
: null,
|
||||
})} data-test="AllowMultipleValuesCheckbox">
|
||||
Allow multiple values
|
||||
</Checkbox>
|
||||
</Form.Item>)}
|
||||
{(param.type === "enum" || param.type === "query") && param.multiValuesOptions && (<Form.Item label="Quotation" help={<React.Fragment>
|
||||
Placed in query as: <code>{joinExampleList(param.multiValuesOptions)}</code>
|
||||
</React.Fragment>} {...formItemProps}>
|
||||
<Select value={param.multiValuesOptions.prefix} onChange={quoteOption => setParam({
|
||||
...param,
|
||||
multiValuesOptions: {
|
||||
...param.multiValuesOptions,
|
||||
prefix: quoteOption,
|
||||
suffix: quoteOption,
|
||||
},
|
||||
})} data-test="QuotationSelect">
|
||||
<Option value="">None (default)</Option>
|
||||
<Option value="'">Single Quotation Mark</Option>
|
||||
<Option value={'"'} data-test="DoubleQuotationMarkOption">
|
||||
Double Quotation Mark
|
||||
</Option>
|
||||
</Select>
|
||||
</Form.Item>)}
|
||||
</Form>
|
||||
</Modal>);
|
||||
}
|
||||
EditParameterSettingsDialog.defaultProps = {
|
||||
existingParams: [],
|
||||
};
|
||||
export default wrapDialog(EditParameterSettingsDialog);
|
||||
@@ -1,296 +0,0 @@
|
||||
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);
|
||||
@@ -1,134 +0,0 @@
|
||||
import React, { useState, useEffect, useRef, useReducer } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { values } from "lodash";
|
||||
import Button from "antd/lib/button";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
import Radio from "antd/lib/radio";
|
||||
import Typography from "antd/lib/typography/Typography";
|
||||
import ParameterValueInput from "@/components/ParameterValueInput";
|
||||
import InputPopover from "@/components/InputPopover";
|
||||
import Form from "antd/lib/form";
|
||||
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
|
||||
|
||||
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
|
||||
import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
|
||||
export default function QueryBasedParameterMappingEditor({ parameter, mapping, searchAvailable, onChange }) {
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
const [newMapping, setNewMapping] = useReducer((prevState, updates) => ({ ...prevState, ...updates }), mapping);
|
||||
|
||||
const newMappingRef = useRef(newMapping);
|
||||
useEffect(() => {
|
||||
if (
|
||||
mapping.mappingType !== newMappingRef.current.mappingType ||
|
||||
mapping.staticValue !== newMappingRef.current.staticValue
|
||||
) {
|
||||
setNewMapping(mapping);
|
||||
}
|
||||
}, [mapping]);
|
||||
|
||||
const parameterRef = useRef(parameter);
|
||||
useEffect(() => {
|
||||
parameterRef.current.setValue(mapping.staticValue);
|
||||
}, [mapping.staticValue]);
|
||||
|
||||
const onCancel = () => {
|
||||
setNewMapping(mapping);
|
||||
setShowPopover(false);
|
||||
};
|
||||
|
||||
const onOk = () => {
|
||||
onChange(newMapping);
|
||||
setShowPopover(false);
|
||||
};
|
||||
|
||||
let currentState = <Text type="secondary">Pick a type</Text>;
|
||||
if (mapping.mappingType === QueryBasedParameterMappingType.DROPDOWN_SEARCH) {
|
||||
currentState = "Dropdown Search";
|
||||
} else if (mapping.mappingType === QueryBasedParameterMappingType.STATIC) {
|
||||
currentState = `Value: ${mapping.staticValue}`;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{currentState}
|
||||
<InputPopover
|
||||
placement="left"
|
||||
trigger="click"
|
||||
header="Edit Parameter Source"
|
||||
okButtonProps={{
|
||||
disabled: newMapping.mappingType === QueryBasedParameterMappingType.STATIC && parameter.isEmpty,
|
||||
}}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
content={
|
||||
<Form>
|
||||
<Form.Item className="m-b-15" label="Source" {...formItemProps}>
|
||||
<Radio.Group
|
||||
value={newMapping.mappingType}
|
||||
onChange={({ target }) => setNewMapping({ mappingType: target.value })}>
|
||||
<Radio
|
||||
className="radio"
|
||||
value={QueryBasedParameterMappingType.DROPDOWN_SEARCH}
|
||||
disabled={!searchAvailable || parameter.type !== "text"}>
|
||||
Dropdown Search{" "}
|
||||
{(!searchAvailable || parameter.type !== "text") && (
|
||||
<Tooltip
|
||||
title={
|
||||
parameter.type !== "text"
|
||||
? "Dropdown Search is only available for Text Parameters"
|
||||
: "There is already a parameter mapped with the Dropdown Search type."
|
||||
}>
|
||||
<QuestionCircleFilledIcon />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Radio>
|
||||
<Radio className="radio" value={QueryBasedParameterMappingType.STATIC}>
|
||||
Static Value
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{newMapping.mappingType === QueryBasedParameterMappingType.STATIC && (
|
||||
<Form.Item label="Value" required {...formItemProps}>
|
||||
<ParameterValueInput
|
||||
type={parameter.type}
|
||||
value={parameter.normalizedValue}
|
||||
enumOptions={parameter.enumOptions}
|
||||
queryId={parameter.queryId}
|
||||
parameter={parameter}
|
||||
onSelect={value => {
|
||||
parameter.setValue(value);
|
||||
setNewMapping({ staticValue: parameter.getExecutionValue({ joinListValues: true }) });
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
}
|
||||
visible={showPopover}
|
||||
onVisibleChange={setShowPopover}>
|
||||
<Button className="m-l-5" size="small" type="dashed">
|
||||
<EditOutlinedIcon />
|
||||
</Button>
|
||||
</InputPopover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
QueryBasedParameterMappingEditor.propTypes = {
|
||||
parameter: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
mapping: PropTypes.shape({
|
||||
mappingType: PropTypes.oneOf(values(QueryBasedParameterMappingType)),
|
||||
staticValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
}),
|
||||
searchAvailable: PropTypes.bool,
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
QueryBasedParameterMappingEditor.defaultProps = {
|
||||
mapping: { mappingType: QueryBasedParameterMappingType.UNDEFINED, staticValue: undefined },
|
||||
searchAvailable: false,
|
||||
onChange: () => {},
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
import React from "react";
|
||||
import { findKey } from "lodash";
|
||||
import PropTypes from "prop-types";
|
||||
import Table from "antd/lib/table";
|
||||
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
|
||||
import QueryBasedParameterMappingEditor from "./QueryBasedParameterMappingEditor";
|
||||
|
||||
export default function QueryBasedParameterMappingTable({ param, mappingParameters, onChangeParam }) {
|
||||
return (
|
||||
<Table
|
||||
dataSource={mappingParameters}
|
||||
size="middle"
|
||||
pagination={false}
|
||||
rowKey={({ mappingParam }) => `param${mappingParam.name}`}>
|
||||
<Table.Column title="Title" key="title" render={({ mappingParam }) => mappingParam.getTitle()} />
|
||||
<Table.Column
|
||||
title="Keyword"
|
||||
key="keyword"
|
||||
className="keyword"
|
||||
render={({ mappingParam }) => <code>{`{{ ${mappingParam.name} }}`}</code>}
|
||||
/>
|
||||
<Table.Column
|
||||
title="Value Source"
|
||||
key="source"
|
||||
render={({ mappingParam, existingMapping }) => (
|
||||
<QueryBasedParameterMappingEditor
|
||||
parameter={mappingParam.setValue(existingMapping.staticValue)}
|
||||
mapping={existingMapping}
|
||||
searchAvailable={
|
||||
!findKey(param.parameterMapping, {
|
||||
mappingType: QueryBasedParameterMappingType.DROPDOWN_SEARCH,
|
||||
}) || existingMapping.mappingType === QueryBasedParameterMappingType.DROPDOWN_SEARCH
|
||||
}
|
||||
onChange={mapping =>
|
||||
onChangeParam({
|
||||
...param,
|
||||
parameterMapping: { ...param.parameterMapping, [mappingParam.name]: mapping },
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
QueryBasedParameterMappingTable.propTypes = {
|
||||
param: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
mappingParameters: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
|
||||
onChangeParam: PropTypes.func,
|
||||
};
|
||||
|
||||
QueryBasedParameterMappingTable.defaultProps = {
|
||||
mappingParameters: [],
|
||||
onChangeParam: () => {},
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Dropdown from "antd/lib/dropdown";
|
||||
import Menu from "antd/lib/menu";
|
||||
import Button from "antd/lib/button";
|
||||
import { 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: "",
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from "react";
|
||||
import Dropdown from "antd/lib/dropdown";
|
||||
import Menu from "antd/lib/menu";
|
||||
import Button from "antd/lib/button";
|
||||
import { clientConfig } from "@/services/auth";
|
||||
import PlusCircleFilledIcon from "@ant-design/icons/PlusCircleFilled";
|
||||
import ShareAltOutlinedIcon from "@ant-design/icons/ShareAltOutlined";
|
||||
import FileOutlinedIcon from "@ant-design/icons/FileOutlined";
|
||||
import FileExcelOutlinedIcon from "@ant-design/icons/FileExcelOutlined";
|
||||
import EllipsisOutlinedIcon from "@ant-design/icons/EllipsisOutlined";
|
||||
import QueryResultsLink from "./QueryResultsLink";
|
||||
type OwnProps = {
|
||||
query: any;
|
||||
queryResult?: any;
|
||||
queryExecuting: boolean;
|
||||
showEmbedDialog: (...args: any[]) => any;
|
||||
embed?: boolean;
|
||||
apiKey?: string;
|
||||
selectedTab?: string | number;
|
||||
openAddToDashboardForm: (...args: any[]) => any;
|
||||
};
|
||||
type Props = OwnProps & typeof QueryControlDropdown.defaultProps;
|
||||
export default function QueryControlDropdown(props: Props) {
|
||||
const menu = (<Menu>
|
||||
{!props.query.isNew() && (!props.query.is_draft || !props.query.is_archived) && (<Menu.Item>
|
||||
<a target="_self" onClick={() => props.openAddToDashboardForm(props.selectedTab)}>
|
||||
<PlusCircleFilledIcon /> Add to Dashboard
|
||||
</a>
|
||||
</Menu.Item>)}
|
||||
{!(clientConfig as any).disablePublicUrls && !props.query.isNew() && (<Menu.Item>
|
||||
<a onClick={() => props.showEmbedDialog(props.query, props.selectedTab)} data-test="ShowEmbedDialogButton">
|
||||
<ShareAltOutlinedIcon /> Embed Elsewhere
|
||||
</a>
|
||||
</Menu.Item>)}
|
||||
<Menu.Item>
|
||||
<QueryResultsLink fileType="csv" disabled={props.queryExecuting || !props.queryResult.getData || !props.queryResult.getData()} query={props.query} queryResult={props.queryResult} embed={props.embed} apiKey={props.apiKey}>
|
||||
<FileOutlinedIcon /> Download as CSV File
|
||||
</QueryResultsLink>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<QueryResultsLink fileType="tsv" disabled={props.queryExecuting || !props.queryResult.getData || !props.queryResult.getData()} query={props.query} queryResult={props.queryResult} embed={props.embed} apiKey={props.apiKey}>
|
||||
<FileOutlinedIcon /> Download as TSV File
|
||||
</QueryResultsLink>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<QueryResultsLink fileType="xlsx" disabled={props.queryExecuting || !props.queryResult.getData || !props.queryResult.getData()} query={props.query} queryResult={props.queryResult} embed={props.embed} apiKey={props.apiKey}>
|
||||
<FileExcelOutlinedIcon /> Download as Excel File
|
||||
</QueryResultsLink>
|
||||
</Menu.Item>
|
||||
</Menu>);
|
||||
return (<Dropdown trigger={["click"]} overlay={menu} overlayClassName="query-control-dropdown-overlay">
|
||||
<Button data-test="QueryControlDropdownButton">
|
||||
<EllipsisOutlinedIcon rotate={90}/>
|
||||
</Button>
|
||||
</Dropdown>);
|
||||
}
|
||||
QueryControlDropdown.defaultProps = {
|
||||
queryResult: {},
|
||||
embed: false,
|
||||
apiKey: "",
|
||||
selectedTab: "",
|
||||
};
|
||||
@@ -1,8 +1,19 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Link from "@/components/Link";
|
||||
|
||||
export default function QueryResultsLink(props) {
|
||||
type OwnProps = {
|
||||
query: any;
|
||||
queryResult?: any;
|
||||
fileType?: string;
|
||||
disabled: boolean;
|
||||
embed?: boolean;
|
||||
apiKey?: string;
|
||||
children: React.ReactNode[] | React.ReactNode;
|
||||
};
|
||||
|
||||
type Props = OwnProps & typeof QueryResultsLink.defaultProps;
|
||||
|
||||
export default function QueryResultsLink(props: Props) {
|
||||
let href = "";
|
||||
|
||||
const { query, queryResult, fileType } = props;
|
||||
@@ -24,16 +35,6 @@ export default function QueryResultsLink(props) {
|
||||
);
|
||||
}
|
||||
|
||||
QueryResultsLink.propTypes = {
|
||||
query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
queryResult: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
fileType: PropTypes.string,
|
||||
disabled: PropTypes.bool.isRequired,
|
||||
embed: PropTypes.bool,
|
||||
apiKey: PropTypes.string,
|
||||
children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired,
|
||||
};
|
||||
|
||||
QueryResultsLink.defaultProps = {
|
||||
queryResult: {},
|
||||
fileType: "csv",
|
||||
@@ -1,9 +1,15 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Button from "antd/lib/button";
|
||||
import FormOutlinedIcon from "@ant-design/icons/FormOutlined";
|
||||
|
||||
export default function EditVisualizationButton(props) {
|
||||
type OwnProps = {
|
||||
openVisualizationEditor: (...args: any[]) => any;
|
||||
selectedTab?: string | number;
|
||||
};
|
||||
|
||||
type Props = OwnProps & typeof EditVisualizationButton.defaultProps;
|
||||
|
||||
export default function EditVisualizationButton(props: Props) {
|
||||
return (
|
||||
<Button
|
||||
data-test="EditVisualization"
|
||||
@@ -15,11 +21,6 @@ export default function EditVisualizationButton(props) {
|
||||
);
|
||||
}
|
||||
|
||||
EditVisualizationButton.propTypes = {
|
||||
openVisualizationEditor: PropTypes.func.isRequired,
|
||||
selectedTab: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
};
|
||||
|
||||
EditVisualizationButton.defaultProps = {
|
||||
selectedTab: "",
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import cx from "classnames";
|
||||
import { clientConfig, currentUser } from "@/services/auth";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
import Alert from "antd/lib/alert";
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
|
||||
export default function EmailSettingsWarning({ featureName, className, mode, adminOnly }) {
|
||||
if (!clientConfig.mailSettingsMissing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (adminOnly && !currentUser.isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = (
|
||||
<span>
|
||||
Your mail server isn'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,
|
||||
};
|
||||
37
client/app/components/EmailSettingsWarning.tsx
Normal file
37
client/app/components/EmailSettingsWarning.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
import cx from "classnames";
|
||||
import { clientConfig, currentUser } from "@/services/auth";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
import Alert from "antd/lib/alert";
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
type OwnProps = {
|
||||
featureName: string;
|
||||
className?: string;
|
||||
mode?: "alert" | "icon";
|
||||
adminOnly?: boolean;
|
||||
};
|
||||
type Props = OwnProps & typeof EmailSettingsWarning.defaultProps;
|
||||
export default function EmailSettingsWarning({ featureName, className, mode, adminOnly }: Props) {
|
||||
if (!(clientConfig as any).mailSettingsMissing) {
|
||||
return null;
|
||||
}
|
||||
if (adminOnly && !currentUser.isAdmin) {
|
||||
return null;
|
||||
}
|
||||
const message = (<span>
|
||||
Your mail server isn'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,19 +1,21 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
export default class FavoritesControl extends React.Component {
|
||||
static propTypes = {
|
||||
item: PropTypes.shape({
|
||||
is_favorite: PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
type OwnProps = {
|
||||
item: {
|
||||
is_favorite: boolean;
|
||||
};
|
||||
onChange?: (...args: any[]) => any;
|
||||
};
|
||||
|
||||
type Props = OwnProps & typeof FavoritesControl.defaultProps;
|
||||
|
||||
export default class FavoritesControl extends React.Component<Props> {
|
||||
|
||||
static defaultProps = {
|
||||
onChange: () => {},
|
||||
};
|
||||
|
||||
toggleItem(event, item, callback) {
|
||||
toggleItem(event: any, item: any, callback: any) {
|
||||
const action = item.is_favorite ? item.unfavorite.bind(item) : item.favorite.bind(item);
|
||||
const savedIsFavorite = item.is_favorite;
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { isArray, indexOf, get, map, includes, every, some, toNumber } from "lodash";
|
||||
import moment from "moment";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Select from "antd/lib/select";
|
||||
import { formatColumnValue } from "@/lib/utils";
|
||||
|
||||
const ALL_VALUES = "###Redash::Filters::SelectAll###";
|
||||
const NONE_VALUES = "###Redash::Filters::Clear###";
|
||||
|
||||
export const FilterType = PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
friendlyName: PropTypes.string.isRequired,
|
||||
multiple: PropTypes.bool,
|
||||
current: PropTypes.oneOfType([PropTypes.any, PropTypes.arrayOf(PropTypes.any)]),
|
||||
values: PropTypes.arrayOf(PropTypes.any).isRequired,
|
||||
});
|
||||
|
||||
export const FiltersType = PropTypes.arrayOf(FilterType);
|
||||
|
||||
function createFilterChangeHandler(filters, onChange) {
|
||||
return (filter, values) => {
|
||||
if (isArray(values)) {
|
||||
values = map(values, value => filter.values[toNumber(value.key)] || value.key);
|
||||
} else {
|
||||
const _values = filter.values[toNumber(values.key)];
|
||||
values = _values !== undefined ? _values : values.key;
|
||||
}
|
||||
|
||||
if (filter.multiple && includes(values, ALL_VALUES)) {
|
||||
values = [...filter.values];
|
||||
}
|
||||
if (filter.multiple && includes(values, NONE_VALUES)) {
|
||||
values = [];
|
||||
}
|
||||
filters = map(filters, f => (f.name === filter.name ? { ...filter, current: values } : f));
|
||||
onChange(filters);
|
||||
};
|
||||
}
|
||||
|
||||
export function filterData(rows, filters = []) {
|
||||
if (!isArray(rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let result = rows;
|
||||
|
||||
if (isArray(filters) && filters.length > 0) {
|
||||
// "every" field's value should match "some" of corresponding filter's values
|
||||
result = result.filter(row =>
|
||||
every(filters, filter => {
|
||||
const rowValue = row[filter.name];
|
||||
const filterValues = isArray(filter.current) ? filter.current : [filter.current];
|
||||
return some(filterValues, filterValue => {
|
||||
if (moment.isMoment(rowValue)) {
|
||||
return rowValue.isSame(filterValue);
|
||||
}
|
||||
// We compare with either the value or the String representation of the value,
|
||||
// because Select2 casts true/false to "true"/"false".
|
||||
return filterValue === rowValue || String(rowValue) === filterValue;
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function Filters({ filters, onChange }) {
|
||||
if (filters.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
onChange = createFilterChangeHandler(filters, onChange);
|
||||
|
||||
return (
|
||||
<div className="filters-wrapper" data-test="Filters">
|
||||
<div className="container bg-white">
|
||||
<div className="row">
|
||||
{map(filters, filter => {
|
||||
const options = map(filter.values, (value, index) => (
|
||||
<Select.Option key={index}>{formatColumnValue(value, get(filter, "column.type"))}</Select.Option>
|
||||
));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={filter.name}
|
||||
className="col-sm-6 p-l-0 filter-container"
|
||||
data-test={`FilterName-${filter.name}`}>
|
||||
<label>{filter.friendlyName}</label>
|
||||
{options.length === 0 && <Select className="w-100" disabled value="No values" />}
|
||||
{options.length > 0 && (
|
||||
<Select
|
||||
labelInValue
|
||||
className="w-100"
|
||||
mode={filter.multiple ? "multiple" : "default"}
|
||||
value={
|
||||
isArray(filter.current)
|
||||
? map(filter.current, value => ({
|
||||
key: `${indexOf(filter.values, value)}`,
|
||||
label: formatColumnValue(value),
|
||||
}))
|
||||
: { key: `${indexOf(filter.values, filter.current)}`, label: formatColumnValue(filter.current) }
|
||||
}
|
||||
allowClear={filter.multiple}
|
||||
optionFilterProp="children"
|
||||
showSearch
|
||||
maxTagCount={3}
|
||||
maxTagTextLength={10}
|
||||
maxTagPlaceholder={num => `+${num.length} more`}
|
||||
onChange={values => onChange(filter, values)}>
|
||||
{!filter.multiple && options}
|
||||
{filter.multiple && [
|
||||
<Select.Option key={NONE_VALUES} data-test="ClearOption">
|
||||
<i className="fa fa-square-o m-r-5" />
|
||||
Clear
|
||||
</Select.Option>,
|
||||
<Select.Option key={ALL_VALUES} data-test="SelectAllOption">
|
||||
<i className="fa fa-check-square-o m-r-5" />
|
||||
Select All
|
||||
</Select.Option>,
|
||||
<Select.OptGroup key="Values" title="Values">
|
||||
{options}
|
||||
</Select.OptGroup>,
|
||||
]}
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Filters.propTypes = {
|
||||
filters: FiltersType.isRequired,
|
||||
onChange: PropTypes.func, // (name, value) => void
|
||||
};
|
||||
|
||||
Filters.defaultProps = {
|
||||
onChange: () => {},
|
||||
};
|
||||
|
||||
export default Filters;
|
||||
121
client/app/components/Filters.tsx
Normal file
121
client/app/components/Filters.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { isArray, indexOf, get, map, includes, every, some, toNumber } from "lodash";
|
||||
import moment from "moment";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Select from "antd/lib/select";
|
||||
import { formatColumnValue } from "@/lib/utils";
|
||||
const ALL_VALUES = "###Redash::Filters::SelectAll###";
|
||||
const NONE_VALUES = "###Redash::Filters::Clear###";
|
||||
type FilterType = {
|
||||
name: string;
|
||||
friendlyName: string;
|
||||
multiple?: boolean;
|
||||
current?: any | any[];
|
||||
values: any[];
|
||||
};
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'Requireable<InferProps<{ name: Validator<str... Remove this comment to see the full error message
|
||||
const FilterType: PropTypes.Requireable<FilterType> = PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
friendlyName: PropTypes.string.isRequired,
|
||||
multiple: PropTypes.bool,
|
||||
current: PropTypes.oneOfType([PropTypes.any, PropTypes.arrayOf(PropTypes.any)]),
|
||||
values: PropTypes.arrayOf(PropTypes.any).isRequired,
|
||||
});
|
||||
export { FilterType };
|
||||
export const FiltersType = PropTypes.arrayOf(FilterType);
|
||||
function createFilterChangeHandler(filters: any, onChange: any) {
|
||||
return (filter: any, values: any) => {
|
||||
if (isArray(values)) {
|
||||
values = map(values, value => filter.values[toNumber(value.key)] || value.key);
|
||||
}
|
||||
else {
|
||||
const _values = filter.values[toNumber(values.key)];
|
||||
values = _values !== undefined ? _values : values.key;
|
||||
}
|
||||
if (filter.multiple && includes(values, ALL_VALUES)) {
|
||||
values = [...filter.values];
|
||||
}
|
||||
if (filter.multiple && includes(values, NONE_VALUES)) {
|
||||
values = [];
|
||||
}
|
||||
filters = map(filters, f => (f.name === filter.name ? { ...filter, current: values } : f));
|
||||
onChange(filters);
|
||||
};
|
||||
}
|
||||
export function filterData(rows: any, filters = []) {
|
||||
if (!isArray(rows)) {
|
||||
return [];
|
||||
}
|
||||
let result = rows;
|
||||
if (isArray(filters) && filters.length > 0) {
|
||||
// "every" field's value should match "some" of corresponding filter's values
|
||||
result = result.filter(row => every(filters, filter => {
|
||||
const rowValue = row[(filter as any).name];
|
||||
const filterValues = isArray((filter as any).current) ? (filter as any).current : [(filter as any).current];
|
||||
return some(filterValues, filterValue => {
|
||||
if (moment.isMoment(rowValue)) {
|
||||
return rowValue.isSame(filterValue);
|
||||
}
|
||||
// We compare with either the value or the String representation of the value,
|
||||
// because Select2 casts true/false to "true"/"false".
|
||||
return filterValue === rowValue || String(rowValue) === filterValue;
|
||||
});
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
type OwnProps = {
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'FiltersType' refers to a value, but is being used... Remove this comment to see the full error message
|
||||
filters: FiltersType;
|
||||
onChange?: (...args: any[]) => any;
|
||||
};
|
||||
type Props = OwnProps & typeof Filters.defaultProps;
|
||||
function Filters({ filters, onChange }: Props) {
|
||||
if (filters.length === 0) {
|
||||
return null;
|
||||
}
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type '(filter: any, values: any) => void' is not a... Remove this comment to see the full error message
|
||||
onChange = createFilterChangeHandler(filters, onChange);
|
||||
return (<div className="filters-wrapper" data-test="Filters">
|
||||
<div className="container bg-white">
|
||||
<div className="row">
|
||||
{map(filters, filter => {
|
||||
// @ts-expect-error ts-migrate(2741) FIXME: Property 'value' is missing in type '{ children: a... Remove this comment to see the full error message
|
||||
const options = map(filter.values, (value, index) => (<Select.Option key={index}>{formatColumnValue(value, get(filter, "column.type"))}</Select.Option>));
|
||||
return (<div key={filter.name} className="col-sm-6 p-l-0 filter-container" data-test={`FilterName-${filter.name}`}>
|
||||
<label>{filter.friendlyName}</label>
|
||||
{options.length === 0 && <Select className="w-100" disabled value="No values"/>}
|
||||
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
|
||||
{options.length > 0 && (<Select labelInValue className="w-100" mode={filter.multiple ? "multiple" : "default"} value={isArray(filter.current)
|
||||
? map(filter.current, value => ({
|
||||
key: `${indexOf(filter.values, value)}`,
|
||||
label: formatColumnValue(value),
|
||||
}))
|
||||
: { key: `${indexOf(filter.values, filter.current)}`, label: formatColumnValue(filter.current) }} allowClear={filter.multiple} optionFilterProp="children" showSearch maxTagCount={3} maxTagTextLength={10} maxTagPlaceholder={num => `+${num.length} more`} onChange={values => onChange(filter, values)}>
|
||||
{!filter.multiple && options}
|
||||
{filter.multiple && [
|
||||
// @ts-expect-error ts-migrate(2741) FIXME: Property 'value' is missing in type '{ children: (... Remove this comment to see the full error message
|
||||
<Select.Option key={NONE_VALUES} data-test="ClearOption">
|
||||
<i className="fa fa-square-o m-r-5"/>
|
||||
Clear
|
||||
</Select.Option>,
|
||||
// @ts-expect-error ts-migrate(2741) FIXME: Property 'value' is missing in type '{ children: (... Remove this comment to see the full error message
|
||||
<Select.Option key={ALL_VALUES} data-test="SelectAllOption">
|
||||
<i className="fa fa-check-square-o m-r-5"/>
|
||||
Select All
|
||||
</Select.Option>,
|
||||
<Select.OptGroup key="Values" title="Values">
|
||||
{options}
|
||||
</Select.OptGroup>,
|
||||
]}
|
||||
</Select>)}
|
||||
</div>);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
Filters.defaultProps = {
|
||||
onChange: () => { },
|
||||
};
|
||||
export default Filters;
|
||||
@@ -1,257 +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]
|
||||
);
|
||||
|
||||
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;
|
||||
213
client/app/components/HelpTrigger.tsx
Normal file
213
client/app/components/HelpTrigger.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { startsWith, get, some, mapValues } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import cx from "classnames";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
import Drawer from "antd/lib/drawer";
|
||||
import Link from "@/components/Link";
|
||||
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
|
||||
import BigMessage from "@/components/BigMessage";
|
||||
import DynamicComponent, { registerComponent } from "@/components/DynamicComponent";
|
||||
import "./HelpTrigger.less";
|
||||
const DOMAIN = "https://redash.io";
|
||||
const HELP_PATH = "/help";
|
||||
const IFRAME_TIMEOUT = 20000;
|
||||
const IFRAME_URL_UPDATE_MESSAGE = "iframe_url";
|
||||
export const TYPES = mapValues({
|
||||
HOME: ["", "Help"],
|
||||
VALUE_SOURCE_OPTIONS: ["/user-guide/querying/query-parameters#Value-Source-Options", "Guide: Value Source Options"],
|
||||
SHARE_DASHBOARD: ["/user-guide/dashboards/sharing-dashboards", "Guide: Sharing and Embedding Dashboards"],
|
||||
AUTHENTICATION_OPTIONS: ["/user-guide/users/authentication-options", "Guide: Authentication Options"],
|
||||
USAGE_DATA_SHARING: ["/open-source/admin-guide/usage-data", "Help: Anonymous Usage Data Sharing"],
|
||||
DS_ATHENA: ["/data-sources/amazon-athena-setup", "Guide: Help Setting up Amazon Athena"],
|
||||
DS_BIGQUERY: ["/data-sources/bigquery-setup", "Guide: Help Setting up BigQuery"],
|
||||
DS_URL: ["/data-sources/querying-urls", "Guide: Help Setting up URL"],
|
||||
DS_MONGODB: ["/data-sources/mongodb-setup", "Guide: Help Setting up MongoDB"],
|
||||
DS_GOOGLE_SPREADSHEETS: [
|
||||
"/data-sources/querying-a-google-spreadsheet",
|
||||
"Guide: Help Setting up Google Spreadsheets",
|
||||
],
|
||||
DS_GOOGLE_ANALYTICS: ["/data-sources/google-analytics-setup", "Guide: Help Setting up Google Analytics"],
|
||||
DS_AXIBASETSD: ["/data-sources/axibase-time-series-database", "Guide: Help Setting up Axibase Time Series"],
|
||||
DS_RESULTS: ["/user-guide/querying/query-results-data-source", "Guide: Help Setting up Query Results"],
|
||||
ALERT_SETUP: ["/user-guide/alerts/setting-up-an-alert", "Guide: Setting Up a New Alert"],
|
||||
MAIL_CONFIG: ["/open-source/setup/#Mail-Configuration", "Guide: Mail Configuration"],
|
||||
ALERT_NOTIF_TEMPLATE_GUIDE: ["/user-guide/alerts/custom-alert-notifications", "Guide: Custom Alerts Notifications"],
|
||||
FAVORITES: ["/user-guide/querying/favorites-tagging/#Favorites", "Guide: Favorites"],
|
||||
MANAGE_PERMISSIONS: [
|
||||
"/user-guide/querying/writing-queries#Managing-Query-Permissions",
|
||||
"Guide: Managing Query Permissions",
|
||||
],
|
||||
NUMBER_FORMAT_SPECS: ["/user-guide/visualizations/formatting-numbers", "Formatting Numbers"],
|
||||
GETTING_STARTED: ["/user-guide/getting-started", "Guide: Getting Started"],
|
||||
DASHBOARDS: ["/user-guide/dashboards", "Guide: Dashboards"],
|
||||
QUERIES: ["/help/user-guide/querying", "Guide: Queries"],
|
||||
ALERTS: ["/user-guide/alerts", "Guide: Alerts"],
|
||||
}, ([url, title]) => [DOMAIN + HELP_PATH + url, title]);
|
||||
type OwnProps = {
|
||||
type?: string;
|
||||
href?: string;
|
||||
title?: React.ReactNode;
|
||||
className?: string;
|
||||
showTooltip?: boolean;
|
||||
renderAsLink?: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
const HelpTriggerPropTypes = {
|
||||
type: PropTypes.string,
|
||||
href: PropTypes.string,
|
||||
title: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
showTooltip: PropTypes.bool,
|
||||
renderAsLink: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
const HelpTriggerDefaultProps = {
|
||||
type: null,
|
||||
href: null,
|
||||
title: null,
|
||||
className: null,
|
||||
showTooltip: true,
|
||||
renderAsLink: false,
|
||||
children: <i className="fa fa-question-circle"/>,
|
||||
};
|
||||
export function helpTriggerWithTypes(types: any, allowedDomains = [], drawerClassName = null) {
|
||||
return class HelpTrigger extends React.Component {
|
||||
static propTypes = {
|
||||
...HelpTriggerPropTypes,
|
||||
type: PropTypes.oneOf(Object.keys(types)),
|
||||
};
|
||||
static defaultProps = HelpTriggerDefaultProps;
|
||||
iframeRef = React.createRef();
|
||||
iframeLoadingTimeout = null;
|
||||
state = {
|
||||
visible: false,
|
||||
loading: false,
|
||||
error: false,
|
||||
currentUrl: null,
|
||||
};
|
||||
componentDidMount() {
|
||||
window.addEventListener("message", this.onPostMessageReceived, false);
|
||||
}
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("message", this.onPostMessageReceived);
|
||||
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
|
||||
clearTimeout(this.iframeLoadingTimeout);
|
||||
}
|
||||
loadIframe = (url: any) => {
|
||||
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
|
||||
clearTimeout(this.iframeLoadingTimeout);
|
||||
this.setState({ loading: true, error: false });
|
||||
// @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'.
|
||||
this.iframeRef.current.src = url;
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'number' is not assignable to type 'null'.
|
||||
this.iframeLoadingTimeout = setTimeout(() => {
|
||||
this.setState({ error: url, loading: false });
|
||||
}, IFRAME_TIMEOUT); // safety
|
||||
};
|
||||
onIframeLoaded = () => {
|
||||
this.setState({ loading: false });
|
||||
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
|
||||
clearTimeout(this.iframeLoadingTimeout);
|
||||
};
|
||||
onPostMessageReceived = (event: any) => {
|
||||
if (!some(allowedDomains, domain => startsWith(event.origin, domain))) {
|
||||
return;
|
||||
}
|
||||
const { type, message: currentUrl } = event.data || {};
|
||||
if (type !== IFRAME_URL_UPDATE_MESSAGE) {
|
||||
return;
|
||||
}
|
||||
this.setState({ currentUrl });
|
||||
};
|
||||
getUrl = () => {
|
||||
const helpTriggerType = get(types, (this.props as any).type);
|
||||
return helpTriggerType ? helpTriggerType[0] : (this.props as any).href;
|
||||
};
|
||||
openDrawer = (e: any) => {
|
||||
// keep "open in new tab" behavior
|
||||
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
this.setState({ visible: true });
|
||||
// wait for drawer animation to complete so there's no animation jank
|
||||
setTimeout(() => this.loadIframe(this.getUrl()), 300);
|
||||
}
|
||||
};
|
||||
closeDrawer = (event: any) => {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
this.setState({ visible: false });
|
||||
this.setState({ visible: false, currentUrl: null });
|
||||
};
|
||||
render() {
|
||||
const targetUrl = this.getUrl();
|
||||
if (!targetUrl) {
|
||||
return null;
|
||||
}
|
||||
const tooltip = get(types, `${(this.props as any).type}[1]`, (this.props as any).title);
|
||||
const className = cx("help-trigger", (this.props as any).className);
|
||||
const url = this.state.currentUrl;
|
||||
const isAllowedDomain = some(allowedDomains, domain => startsWith(url || targetUrl, domain));
|
||||
const shouldRenderAsLink = (this.props as any).renderAsLink || !isAllowedDomain;
|
||||
return (<React.Fragment>
|
||||
<Tooltip title={(this.props as any).showTooltip ? (<>
|
||||
{tooltip}
|
||||
{shouldRenderAsLink && <i className="fa fa-external-link" style={{ marginLeft: 5 }}/>}
|
||||
</>) : null}>
|
||||
<Link href={url || this.getUrl()} className={className} rel="noopener noreferrer" target="_blank" onClick={shouldRenderAsLink ? () => { } : this.openDrawer}>
|
||||
{this.props.children}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
<Drawer placement="right" closable={false} onClose={this.closeDrawer} visible={this.state.visible} className={cx("help-drawer", drawerClassName)} destroyOnClose width={400}>
|
||||
<div className="drawer-wrapper">
|
||||
<div className="drawer-menu">
|
||||
{url && (<Tooltip title="Open page in a new window" placement="left">
|
||||
|
||||
<Link href={url} target="_blank">
|
||||
<i className="fa fa-external-link"/>
|
||||
</Link>
|
||||
</Tooltip>)}
|
||||
<Tooltip title="Close" placement="bottom">
|
||||
<a onClick={this.closeDrawer}>
|
||||
<CloseOutlinedIcon />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
|
||||
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'RefObject<unknown>' is not assignable to typ... Remove this comment to see the full error message */}
|
||||
{!this.state.error && (<iframe ref={this.iframeRef} title="Usage Help" src="about:blank" className={cx({ ready: !this.state.loading })} onLoad={this.onIframeLoaded}/>)}
|
||||
|
||||
|
||||
{this.state.loading && (<BigMessage icon="fa-spinner fa-2x fa-pulse" message="Loading..." className="help-message"/>)}
|
||||
|
||||
|
||||
{/* @ts-expect-error ts-migrate(2746) FIXME: This JSX tag's 'children' prop expects a single ch... Remove this comment to see the full error message */}
|
||||
{this.state.error && (<BigMessage icon="fa-exclamation-circle" className="help-message">
|
||||
Something went wrong.
|
||||
<br />
|
||||
|
||||
<Link href={this.state.error} target="_blank" rel="noopener">
|
||||
Click here
|
||||
</Link>{" "}
|
||||
to open the page in a new window.
|
||||
</BigMessage>)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
|
||||
<DynamicComponent name="HelpDrawerExtraContent" onLeave={this.closeDrawer} openPageUrl={this.loadIframe}/>
|
||||
</Drawer>
|
||||
</React.Fragment>);
|
||||
}
|
||||
};
|
||||
}
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'string' is not assignable to type 'never'.
|
||||
registerComponent("HelpTrigger", helpTriggerWithTypes(TYPES, [DOMAIN]));
|
||||
type Props = OwnProps & typeof HelpTriggerDefaultProps;
|
||||
export default function HelpTrigger(props: Props) {
|
||||
return <DynamicComponent {...props} name="HelpTrigger"/>;
|
||||
}
|
||||
HelpTrigger.defaultProps = HelpTriggerDefaultProps;
|
||||
@@ -1,57 +0,0 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Button from "antd/lib/button";
|
||||
import Popover from "antd/lib/popover";
|
||||
|
||||
import "./index.less";
|
||||
|
||||
export default function InputPopover({
|
||||
header,
|
||||
content,
|
||||
children,
|
||||
okButtonProps,
|
||||
cancelButtonProps,
|
||||
onCancel,
|
||||
onOk,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<Popover
|
||||
{...props}
|
||||
content={
|
||||
<div className="input-popover-content" data-test="InputPopoverContent">
|
||||
{header && <header>{header}</header>}
|
||||
{content}
|
||||
<footer>
|
||||
<Button onClick={onCancel} {...cancelButtonProps}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onOk} type="primary" {...okButtonProps}>
|
||||
OK
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
}>
|
||||
{children}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
InputPopover.propTypes = {
|
||||
header: PropTypes.node,
|
||||
content: PropTypes.node,
|
||||
children: PropTypes.node,
|
||||
okButtonProps: PropTypes.object,
|
||||
cancelButtonProps: PropTypes.object,
|
||||
onOk: PropTypes.func,
|
||||
onCancel: PropTypes.func,
|
||||
};
|
||||
|
||||
InputPopover.defaultProps = {
|
||||
header: null,
|
||||
children: null,
|
||||
okButtonProps: null,
|
||||
cancelButtonProps: null,
|
||||
onOk: () => {},
|
||||
onCancel: () => {},
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
@import "~antd/lib/modal/style/index"; // for ant @vars
|
||||
|
||||
.input-popover-content {
|
||||
width: 390px;
|
||||
|
||||
.radio {
|
||||
display: block;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 0 16px 10px;
|
||||
margin: 0 -16px 20px;
|
||||
border-bottom: @border-width-base @border-style-base @border-color-split;
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 500;
|
||||
color: @heading-color;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: @border-width-base @border-style-base @border-color-split;
|
||||
padding: 10px 16px 0;
|
||||
margin: 0 -16px;
|
||||
text-align: right;
|
||||
|
||||
button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,13 @@ import Input from "antd/lib/input";
|
||||
import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
|
||||
export default class InputWithCopy extends React.Component {
|
||||
constructor(props) {
|
||||
type State = any;
|
||||
|
||||
export default class InputWithCopy extends React.Component<{}, State> {
|
||||
copyFeatureSupported: any;
|
||||
ref: any;
|
||||
resetCopyState: any;
|
||||
constructor(props: {}) {
|
||||
super(props);
|
||||
this.state = { copied: null };
|
||||
this.ref = React.createRef();
|
||||
@@ -1,21 +1,21 @@
|
||||
import React from "react";
|
||||
import Button from "antd/lib/button";
|
||||
|
||||
function DefaultLinkComponent(props) {
|
||||
function DefaultLinkComponent(props: any) {
|
||||
return <a {...props} />; // eslint-disable-line jsx-a11y/anchor-has-content
|
||||
}
|
||||
|
||||
function Link(props) {
|
||||
function Link(props: any) {
|
||||
return <Link.Component {...props} />;
|
||||
}
|
||||
|
||||
Link.Component = DefaultLinkComponent;
|
||||
|
||||
function DefaultButtonLinkComponent(props) {
|
||||
function DefaultButtonLinkComponent(props: any) {
|
||||
return <Button role="button" {...props} />;
|
||||
}
|
||||
|
||||
function ButtonLink(props) {
|
||||
function ButtonLink(props: any) {
|
||||
return <ButtonLink.Component {...props} />;
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import BigMessage from "@/components/BigMessage";
|
||||
import { TagsControl } from "@/components/tags-control/TagsControl";
|
||||
|
||||
export default function NoTaggedObjectsFound({ objectType, tags }) {
|
||||
return (
|
||||
<BigMessage icon="fa-tags">
|
||||
No {objectType} found tagged with
|
||||
<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,
|
||||
};
|
||||
22
client/app/components/NoTaggedObjectsFound.tsx
Normal file
22
client/app/components/NoTaggedObjectsFound.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
import BigMessage from "@/components/BigMessage";
|
||||
import { TagsControl } from "@/components/tags-control/TagsControl";
|
||||
|
||||
type Props = {
|
||||
objectType: string;
|
||||
tags: any[] | {
|
||||
// @ts-expect-error ts-migrate(2314) FIXME: Generic type 'Set<T>' requires 1 type argument(s).
|
||||
[key: string]: Set;
|
||||
};
|
||||
};
|
||||
|
||||
export default function NoTaggedObjectsFound({ objectType, tags }: Props) {
|
||||
return (
|
||||
// @ts-expect-error ts-migrate(2746) FIXME: This JSX tag's 'children' prop expects a single ch... Remove this comment to see the full error message
|
||||
<BigMessage icon="fa-tags">
|
||||
No {objectType} found tagged with
|
||||
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
|
||||
<TagsControl className="inline-tags-control" tags={Array.from(tags)} tagSeparator={"+"} />.
|
||||
</BigMessage>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import "./index.less";
|
||||
|
||||
export default function PageHeader({ title, actions }) {
|
||||
type OwnProps = {
|
||||
title?: string;
|
||||
actions?: React.ReactNode;
|
||||
};
|
||||
|
||||
type Props = OwnProps & typeof PageHeader.defaultProps;
|
||||
|
||||
export default function PageHeader({ title, actions }: Props) {
|
||||
return (
|
||||
<div className="page-header-wrapper">
|
||||
<h3>{title}</h3>
|
||||
@@ -12,11 +18,6 @@ export default function PageHeader({ title, actions }) {
|
||||
);
|
||||
}
|
||||
|
||||
PageHeader.propTypes = {
|
||||
title: PropTypes.string,
|
||||
actions: PropTypes.node,
|
||||
};
|
||||
|
||||
PageHeader.defaultProps = {
|
||||
title: "",
|
||||
actions: null,
|
||||
@@ -1,10 +1,20 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Pagination from "antd/lib/pagination";
|
||||
|
||||
const MIN_ITEMS_PER_PAGE = 5;
|
||||
|
||||
export default function Paginator({ page, showPageSizeSelect, pageSize, onPageSizeChange, totalCount, onChange }) {
|
||||
type OwnProps = {
|
||||
page: number;
|
||||
showPageSizeSelect?: boolean;
|
||||
pageSize: number;
|
||||
totalCount: number;
|
||||
onPageSizeChange?: (...args: any[]) => any;
|
||||
onChange?: (...args: any[]) => any;
|
||||
};
|
||||
|
||||
type Props = OwnProps & typeof Paginator.defaultProps;
|
||||
|
||||
export default function Paginator({ page, showPageSizeSelect, pageSize, onPageSizeChange, totalCount, onChange }: Props) {
|
||||
if (totalCount <= (showPageSizeSelect ? MIN_ITEMS_PER_PAGE : pageSize)) {
|
||||
return null;
|
||||
}
|
||||
@@ -23,15 +33,6 @@ export default function Paginator({ page, showPageSizeSelect, pageSize, onPageSi
|
||||
);
|
||||
}
|
||||
|
||||
Paginator.propTypes = {
|
||||
page: PropTypes.number.isRequired,
|
||||
showPageSizeSelect: PropTypes.bool,
|
||||
pageSize: PropTypes.number.isRequired,
|
||||
totalCount: PropTypes.number.isRequired,
|
||||
onPageSizeChange: PropTypes.func,
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
Paginator.defaultProps = {
|
||||
showPageSizeSelect: false,
|
||||
onChange: () => {},
|
||||
@@ -1,11 +1,15 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Button from "antd/lib/button";
|
||||
import Badge from "antd/lib/badge";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
import KeyboardShortcuts from "@/services/KeyboardShortcuts";
|
||||
|
||||
function ParameterApplyButton({ paramCount, onClick }) {
|
||||
type Props = {
|
||||
onClick: (...args: any[]) => any;
|
||||
paramCount: number;
|
||||
};
|
||||
|
||||
function ParameterApplyButton({ paramCount, onClick }: Props) {
|
||||
// show spinner when count is empty so the fade out is consistent
|
||||
const icon = !paramCount ? "spinner fa-pulse" : "check";
|
||||
|
||||
@@ -24,9 +28,4 @@ function ParameterApplyButton({ paramCount, onClick }) {
|
||||
);
|
||||
}
|
||||
|
||||
ParameterApplyButton.propTypes = {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
paramCount: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default ParameterApplyButton;
|
||||
@@ -1,614 +0,0 @@
|
||||
/* eslint-disable react/no-multi-comp */
|
||||
|
||||
import { isString, extend, each, has, map, includes, findIndex, find, fromPairs, clone, isEmpty } from "lodash";
|
||||
import React, { Fragment } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import classNames from "classnames";
|
||||
import Select from "antd/lib/select";
|
||||
import Table from "antd/lib/table";
|
||||
import Popover from "antd/lib/popover";
|
||||
import Button from "antd/lib/button";
|
||||
import Tag from "antd/lib/tag";
|
||||
import Input from "antd/lib/input";
|
||||
import Radio from "antd/lib/radio";
|
||||
import Form from "antd/lib/form";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
import ParameterValueInput from "@/components/ParameterValueInput";
|
||||
import { ParameterMappingType } from "@/services/widget";
|
||||
import { Parameter, cloneParameter } from "@/services/parameters";
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
import 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 {
|
||||
.keyword {
|
||||
@@ -22,13 +22,48 @@
|
||||
}
|
||||
}
|
||||
|
||||
.parameter-mapping-editor {
|
||||
width: 390px;
|
||||
|
||||
.radio {
|
||||
display: block;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 0 16px 10px;
|
||||
margin: 0 -16px 20px;
|
||||
border-bottom: @border-width-base @border-style-base @border-color-split;
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 500;
|
||||
color: @heading-color;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: @border-width-base @border-style-base @border-color-split;
|
||||
padding: 10px 16px 0;
|
||||
margin: 0 -16px;
|
||||
text-align: right;
|
||||
|
||||
button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.parameter-mapping-title {
|
||||
.text {
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
&.disabled,
|
||||
.fa {
|
||||
&.disabled, .fa {
|
||||
color: #a4a4a4;
|
||||
}
|
||||
|
||||
|
||||
467
client/app/components/ParameterMappingInput.tsx
Normal file
467
client/app/components/ParameterMappingInput.tsx
Normal file
@@ -0,0 +1,467 @@
|
||||
/* eslint-disable react/no-multi-comp */
|
||||
import { isString, extend, each, has, map, includes, findIndex, find, fromPairs, clone, isEmpty } from "lodash";
|
||||
import React, { Fragment } from "react";
|
||||
import classNames from "classnames";
|
||||
import Select from "antd/lib/select";
|
||||
import Table from "antd/lib/table";
|
||||
import Popover from "antd/lib/popover";
|
||||
import Button from "antd/lib/button";
|
||||
import Tag from "antd/lib/tag";
|
||||
import Input from "antd/lib/input";
|
||||
import Radio from "antd/lib/radio";
|
||||
import Form from "antd/lib/form";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
import ParameterValueInput from "@/components/ParameterValueInput";
|
||||
import { ParameterMappingType } from "@/services/widget";
|
||||
import { Parameter, cloneParameter } from "@/services/parameters";
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
|
||||
import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
|
||||
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
|
||||
import CheckOutlinedIcon from "@ant-design/icons/CheckOutlined";
|
||||
import "./ParameterMappingInput.less";
|
||||
export const MappingType = {
|
||||
DashboardAddNew: "dashboard-add-new",
|
||||
DashboardMapToExisting: "dashboard-map-to-existing",
|
||||
WidgetLevel: "widget-level",
|
||||
StaticValue: "static-value",
|
||||
};
|
||||
export function parameterMappingsToEditableMappings(mappings: any, parameters: any, existingParameterNames = []) {
|
||||
return map(mappings, mapping => {
|
||||
const result = extend({}, mapping);
|
||||
const alreadyExists = includes(existingParameterNames, mapping.mapTo);
|
||||
result.param = find(parameters, p => p.name === mapping.name);
|
||||
switch (mapping.type) {
|
||||
case ParameterMappingType.DashboardLevel:
|
||||
result.type = alreadyExists ? MappingType.DashboardMapToExisting : MappingType.DashboardAddNew;
|
||||
result.value = null;
|
||||
break;
|
||||
case ParameterMappingType.StaticValue:
|
||||
result.type = MappingType.StaticValue;
|
||||
result.param = cloneParameter(result.param);
|
||||
result.param.setValue(result.value);
|
||||
break;
|
||||
case ParameterMappingType.WidgetLevel:
|
||||
result.type = MappingType.WidgetLevel;
|
||||
result.value = null;
|
||||
break;
|
||||
// no default
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
export function editableMappingsToParameterMappings(mappings: any) {
|
||||
return fromPairs(map(
|
||||
// convert to map
|
||||
mappings, mapping => {
|
||||
const result = extend({}, mapping);
|
||||
switch (mapping.type) {
|
||||
case MappingType.DashboardAddNew:
|
||||
result.type = ParameterMappingType.DashboardLevel;
|
||||
result.value = null;
|
||||
break;
|
||||
case MappingType.DashboardMapToExisting:
|
||||
result.type = ParameterMappingType.DashboardLevel;
|
||||
result.value = null;
|
||||
break;
|
||||
case MappingType.StaticValue:
|
||||
result.type = ParameterMappingType.StaticValue;
|
||||
result.param = cloneParameter(mapping.param);
|
||||
result.param.setValue(result.value);
|
||||
result.value = result.param.value;
|
||||
break;
|
||||
case MappingType.WidgetLevel:
|
||||
result.type = ParameterMappingType.WidgetLevel;
|
||||
result.value = null;
|
||||
break;
|
||||
// no default
|
||||
}
|
||||
delete result.param;
|
||||
return [result.name, result];
|
||||
}));
|
||||
}
|
||||
export function synchronizeWidgetTitles(sourceMappings: any, widgets: any) {
|
||||
const affectedWidgets: any = [];
|
||||
each(sourceMappings, sourceMapping => {
|
||||
if (sourceMapping.type === ParameterMappingType.DashboardLevel) {
|
||||
each(widgets, widget => {
|
||||
const widgetMappings = widget.options.parameterMappings;
|
||||
each(widgetMappings, widgetMapping => {
|
||||
// check if mapped to the same dashboard-level parameter
|
||||
if (widgetMapping.type === ParameterMappingType.DashboardLevel &&
|
||||
widgetMapping.mapTo === sourceMapping.mapTo) {
|
||||
// dirty check - update only when needed
|
||||
if (widgetMapping.title !== sourceMapping.title) {
|
||||
widgetMapping.title = sourceMapping.title;
|
||||
affectedWidgets.push(widget);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
return affectedWidgets;
|
||||
}
|
||||
type OwnParameterMappingInputProps = {
|
||||
mapping?: any;
|
||||
existingParamNames?: string[];
|
||||
onChange?: (...args: any[]) => any;
|
||||
inputError?: string;
|
||||
};
|
||||
type ParameterMappingInputProps = OwnParameterMappingInputProps & typeof ParameterMappingInput.defaultProps;
|
||||
export class ParameterMappingInput extends React.Component<ParameterMappingInputProps> {
|
||||
static defaultProps = {
|
||||
mapping: {},
|
||||
existingParamNames: [],
|
||||
onChange: () => { },
|
||||
inputError: null,
|
||||
};
|
||||
formItemProps = {
|
||||
labelCol: { span: 5 },
|
||||
wrapperCol: { span: 16 },
|
||||
className: "form-item",
|
||||
};
|
||||
updateSourceType = (type: any) => {
|
||||
let { mapping: { mapTo }, } = this.props;
|
||||
const { existingParamNames } = this.props;
|
||||
// if mapped name doesn't already exists
|
||||
// default to first select option
|
||||
if (type === MappingType.DashboardMapToExisting && !includes(existingParamNames, mapTo)) {
|
||||
mapTo = existingParamNames[0];
|
||||
}
|
||||
this.updateParamMapping({ type, mapTo });
|
||||
};
|
||||
updateParamMapping = (update: any) => {
|
||||
const { onChange, mapping } = this.props;
|
||||
const newMapping = extend({}, mapping, update);
|
||||
if ((newMapping as any).value !== (mapping as any).value) {
|
||||
(newMapping as any).param = cloneParameter((newMapping as any).param);
|
||||
(newMapping as any).param.setValue((newMapping as any).value);
|
||||
}
|
||||
if (has(update, "type")) {
|
||||
if (update.type === MappingType.StaticValue) {
|
||||
(newMapping as any).value = (newMapping as any).param.value;
|
||||
}
|
||||
else {
|
||||
(newMapping as any).value = null;
|
||||
}
|
||||
}
|
||||
// @ts-expect-error ts-migrate(2349) FIXME: This expression is not callable.
|
||||
onChange(newMapping);
|
||||
};
|
||||
renderMappingTypeSelector() {
|
||||
const noExisting = isEmpty((this.props as any).existingParamNames);
|
||||
return (<Radio.Group value={(this.props as any).mapping.type} onChange={e => this.updateSourceType(e.target.value)}>
|
||||
<Radio className="radio" value={MappingType.DashboardAddNew} data-test="NewDashboardParameterOption">
|
||||
New dashboard parameter
|
||||
</Radio>
|
||||
<Radio className="radio" value={MappingType.DashboardMapToExisting} disabled={noExisting}>
|
||||
Existing dashboard parameter{" "}
|
||||
{noExisting ? (<Tooltip title="There are no dashboard parameters corresponding to this data type">
|
||||
<QuestionCircleFilledIcon />
|
||||
</Tooltip>) : null}
|
||||
</Radio>
|
||||
<Radio className="radio" value={MappingType.WidgetLevel} data-test="WidgetParameterOption">
|
||||
Widget parameter
|
||||
</Radio>
|
||||
<Radio className="radio" value={MappingType.StaticValue} data-test="StaticValueOption">
|
||||
Static value
|
||||
</Radio>
|
||||
</Radio.Group>);
|
||||
}
|
||||
renderDashboardAddNew() {
|
||||
const { mapping: { mapTo }, } = this.props;
|
||||
return <Input value={mapTo} onChange={e => this.updateParamMapping({ mapTo: e.target.value })}/>;
|
||||
}
|
||||
renderDashboardMapToExisting() {
|
||||
const { mapping, existingParamNames } = this.props;
|
||||
const options = map(existingParamNames, paramName => ({ label: paramName, value: paramName }));
|
||||
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
|
||||
return <Select value={(mapping as any).mapTo} onChange={mapTo => this.updateParamMapping({ mapTo })} options={options}/>;
|
||||
}
|
||||
renderStaticValue() {
|
||||
const { mapping } = this.props;
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'any' is not assignable to type 'never'.
|
||||
return (<ParameterValueInput type={(mapping as any).param.type} value={(mapping as any).param.normalizedValue} enumOptions={(mapping as any).param.enumOptions} queryId={(mapping as any).param.queryId} parameter={(mapping as any).param} onSelect={(value: any) => this.updateParamMapping({ value })}/>);
|
||||
}
|
||||
renderInputBlock() {
|
||||
const { mapping } = this.props;
|
||||
switch ((mapping as any).type) {
|
||||
case MappingType.DashboardAddNew:
|
||||
return ["Key", "Enter a new parameter keyword", this.renderDashboardAddNew()];
|
||||
case MappingType.DashboardMapToExisting:
|
||||
return ["Key", "Select from a list of existing parameters", this.renderDashboardMapToExisting()];
|
||||
case MappingType.StaticValue:
|
||||
return ["Value", null, this.renderStaticValue()];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
render() {
|
||||
const { inputError } = this.props;
|
||||
const [label, help, input] = this.renderInputBlock();
|
||||
return (<Form layout="horizontal">
|
||||
<Form.Item label="Source" {...this.formItemProps}>
|
||||
{this.renderMappingTypeSelector()}
|
||||
</Form.Item>
|
||||
<Form.Item style={{ height: 60, visibility: input ? "visible" : "hidden" }} label={label} {...this.formItemProps} validateStatus={inputError ? "error" : ""} help={inputError || help} // empty space so line doesn't collapse
|
||||
>
|
||||
{input}
|
||||
</Form.Item>
|
||||
</Form>);
|
||||
}
|
||||
}
|
||||
type MappingEditorProps = {
|
||||
mapping: any;
|
||||
existingParamNames: string[];
|
||||
onChange: (...args: any[]) => any;
|
||||
};
|
||||
type MappingEditorState = any;
|
||||
class MappingEditor extends React.Component<MappingEditorProps, MappingEditorState> {
|
||||
constructor(props: MappingEditorProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
visible: false,
|
||||
mapping: clone(this.props.mapping),
|
||||
inputError: null,
|
||||
};
|
||||
}
|
||||
onVisibleChange = (visible: any) => {
|
||||
if (visible)
|
||||
this.show();
|
||||
else
|
||||
this.hide();
|
||||
};
|
||||
onChange = (mapping: any) => {
|
||||
let inputError = null;
|
||||
if (mapping.type === MappingType.DashboardAddNew) {
|
||||
if (isEmpty(mapping.mapTo)) {
|
||||
inputError = "Keyword must have a value";
|
||||
}
|
||||
else if (includes(this.props.existingParamNames, mapping.mapTo)) {
|
||||
inputError = "A parameter with this name already exists";
|
||||
}
|
||||
}
|
||||
this.setState({ mapping, inputError });
|
||||
};
|
||||
save = () => {
|
||||
this.props.onChange(this.props.mapping, this.state.mapping);
|
||||
this.hide();
|
||||
};
|
||||
show = () => {
|
||||
this.setState({
|
||||
visible: true,
|
||||
mapping: clone(this.props.mapping),
|
||||
});
|
||||
};
|
||||
hide = () => {
|
||||
this.setState({ visible: false });
|
||||
};
|
||||
renderContent() {
|
||||
const { mapping, inputError } = this.state;
|
||||
return (<div className="parameter-mapping-editor" data-test="EditParamMappingPopover">
|
||||
<header>
|
||||
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'string' is not assignable to type 'never'. */}
|
||||
Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS"/>
|
||||
</header>
|
||||
{/* @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. */}
|
||||
<ParameterMappingInput mapping={mapping} existingParamNames={this.props.existingParamNames} onChange={this.onChange} inputError={inputError}/>
|
||||
<footer>
|
||||
<Button onClick={this.hide}>Cancel</Button>
|
||||
<Button onClick={this.save} disabled={!!inputError} type="primary">
|
||||
OK
|
||||
</Button>
|
||||
</footer>
|
||||
</div>);
|
||||
}
|
||||
render() {
|
||||
const { visible, mapping } = this.state;
|
||||
return (<Popover placement="left" trigger="click" content={this.renderContent()} visible={visible} onVisibleChange={this.onVisibleChange}>
|
||||
<Button size="small" type="dashed" data-test={`EditParamMappingButton-${mapping.param.name}`}>
|
||||
<EditOutlinedIcon />
|
||||
</Button>
|
||||
</Popover>);
|
||||
}
|
||||
}
|
||||
type OwnTitleEditorProps = {
|
||||
existingParams?: any[];
|
||||
mapping: any;
|
||||
onChange: (...args: any[]) => any;
|
||||
};
|
||||
type TitleEditorState = any;
|
||||
type TitleEditorProps = OwnTitleEditorProps & typeof TitleEditor.defaultProps;
|
||||
class TitleEditor extends React.Component<TitleEditorProps, TitleEditorState> {
|
||||
static defaultProps = {
|
||||
existingParams: [],
|
||||
};
|
||||
state = {
|
||||
showPopup: false,
|
||||
title: "",
|
||||
};
|
||||
onPopupVisibleChange = (showPopup: any) => {
|
||||
this.setState({
|
||||
showPopup,
|
||||
title: showPopup ? this.getMappingTitle() : "",
|
||||
});
|
||||
};
|
||||
onEditingTitleChange = (event: any) => {
|
||||
this.setState({ title: event.target.value });
|
||||
};
|
||||
getMappingTitle() {
|
||||
let { mapping } = this.props;
|
||||
if (isString(mapping.title) && mapping.title !== "") {
|
||||
return mapping.title;
|
||||
}
|
||||
// if mapped to dashboard, find source param and return it's title
|
||||
if (mapping.type === MappingType.DashboardMapToExisting) {
|
||||
const source = find(this.props.existingParams, { name: mapping.mapTo });
|
||||
if (source) {
|
||||
mapping = source;
|
||||
}
|
||||
}
|
||||
return mapping.title || mapping.param.title;
|
||||
}
|
||||
save = () => {
|
||||
const newMapping = extend({}, this.props.mapping, { title: this.state.title });
|
||||
this.props.onChange(newMapping);
|
||||
this.hide();
|
||||
};
|
||||
hide = () => {
|
||||
this.setState({ showPopup: false });
|
||||
};
|
||||
renderPopover() {
|
||||
const { param: { title: paramTitle }, } = this.props.mapping;
|
||||
return (<div className="parameter-mapping-title-editor">
|
||||
<Input size="small" value={this.state.title} placeholder={paramTitle} onChange={this.onEditingTitleChange} onPressEnter={this.save} maxLength={100} autoFocus/>
|
||||
<Button size="small" type="dashed" onClick={this.hide}>
|
||||
<CloseOutlinedIcon />
|
||||
</Button>
|
||||
<Button size="small" type="dashed" onClick={this.save}>
|
||||
<CheckOutlinedIcon />
|
||||
</Button>
|
||||
</div>);
|
||||
}
|
||||
renderEditButton() {
|
||||
const { mapping } = this.props;
|
||||
if (mapping.type === MappingType.StaticValue) {
|
||||
return (<Tooltip placement="right" title="Titles for static values don't appear in widgets">
|
||||
<i className="fa fa-eye-slash"/>
|
||||
</Tooltip>);
|
||||
}
|
||||
return (<Popover placement="right" trigger="click" content={this.renderPopover()} visible={this.state.showPopup} onVisibleChange={this.onPopupVisibleChange}>
|
||||
<Button size="small" type="dashed">
|
||||
<EditOutlinedIcon />
|
||||
</Button>
|
||||
</Popover>);
|
||||
}
|
||||
render() {
|
||||
const { mapping } = this.props;
|
||||
// static value are non-editable hence disabled
|
||||
const disabled = mapping.type === MappingType.StaticValue;
|
||||
return (<div className={classNames("parameter-mapping-title", { disabled })}>
|
||||
<span className="text">{this.getMappingTitle()}</span>
|
||||
{this.renderEditButton()}
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
type OwnParameterMappingListInputProps = {
|
||||
mappings?: any[];
|
||||
existingParams?: any[];
|
||||
onChange?: (...args: any[]) => any;
|
||||
};
|
||||
type ParameterMappingListInputProps = OwnParameterMappingListInputProps & typeof ParameterMappingListInput.defaultProps;
|
||||
export class ParameterMappingListInput extends React.Component<ParameterMappingListInputProps> {
|
||||
static defaultProps = {
|
||||
mappings: [],
|
||||
existingParams: [],
|
||||
onChange: () => { },
|
||||
};
|
||||
// @ts-expect-error ts-migrate(7023) FIXME: 'getStringValue' implicitly has return type 'any' ... Remove this comment to see the full error message
|
||||
static getStringValue(value: any) {
|
||||
// null
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
// range
|
||||
if (value instanceof Object && "start" in value && "end" in value) {
|
||||
return `${value.start} ~ ${value.end}`;
|
||||
}
|
||||
// just to be safe, array or object
|
||||
if (typeof value === "object") {
|
||||
return map(value, v => this.getStringValue(v)).join(", ");
|
||||
}
|
||||
// rest
|
||||
return value.toString();
|
||||
}
|
||||
static getDefaultValue(mapping: any, existingParams: any) {
|
||||
const { type, mapTo, name } = mapping;
|
||||
let { param } = mapping;
|
||||
// if mapped to another param, swap 'em
|
||||
if (type === MappingType.DashboardMapToExisting && mapTo !== name) {
|
||||
const mappedTo = find(existingParams, { name: mapTo });
|
||||
if (mappedTo) {
|
||||
// just being safe
|
||||
param = mappedTo;
|
||||
}
|
||||
// static type is different since it's fed param.normalizedValue
|
||||
}
|
||||
else if (type === MappingType.StaticValue) {
|
||||
param = cloneParameter(param).setValue(mapping.value);
|
||||
}
|
||||
let value = Parameter.getExecutionValue(param);
|
||||
// in case of dynamic value display the name instead of value
|
||||
if (param.hasDynamicValue) {
|
||||
value = param.normalizedValue.name;
|
||||
}
|
||||
return this.getStringValue(value);
|
||||
}
|
||||
static getSourceTypeLabel({ type, mapTo }: any) {
|
||||
switch (type) {
|
||||
case MappingType.DashboardAddNew:
|
||||
case MappingType.DashboardMapToExisting:
|
||||
return (<Fragment>
|
||||
Dashboard <Tag className="tag">{mapTo}</Tag>
|
||||
</Fragment>);
|
||||
case MappingType.WidgetLevel:
|
||||
return "Widget parameter";
|
||||
case MappingType.StaticValue:
|
||||
return "Static value";
|
||||
default:
|
||||
return ""; // won't happen (typescript-ftw)
|
||||
}
|
||||
}
|
||||
updateParamMapping(oldMapping: any, newMapping: any) {
|
||||
const mappings = [...this.props.mappings];
|
||||
const index = findIndex(mappings, oldMapping);
|
||||
if (index >= 0) {
|
||||
// This should be the only possible case, but need to handle `else` too
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'any' is not assignable to type 'never'.
|
||||
mappings[index] = newMapping;
|
||||
}
|
||||
else {
|
||||
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'any' is not assignable to parame... Remove this comment to see the full error message
|
||||
mappings.push(newMapping);
|
||||
}
|
||||
this.props.onChange(mappings);
|
||||
}
|
||||
render() {
|
||||
const { existingParams } = this.props; // eslint-disable-line react/prop-types
|
||||
const dataSource = this.props.mappings.map(mapping => ({ mapping }));
|
||||
return (<div className="parameters-mapping-list">
|
||||
<Table dataSource={dataSource} size="middle" pagination={false} rowKey={(record, idx) => `row${idx}`}>
|
||||
<Table.Column title="Title" dataIndex="mapping" key="title" render={mapping => (<TitleEditor existingParams={existingParams} mapping={mapping} onChange={newMapping => this.updateParamMapping(mapping, newMapping)}/>)}/>
|
||||
<Table.Column title="Keyword" dataIndex="mapping" key="keyword" className="keyword" render={mapping => <code>{`{{ ${mapping.name} }}`}</code>}/>
|
||||
<Table.Column title="Default Value" dataIndex="mapping" key="value" render={mapping => (this.constructor as any).getDefaultValue(mapping, this.props.existingParams)}/>
|
||||
<Table.Column title="Value Source" dataIndex="mapping" key="source" render={mapping => {
|
||||
const existingParamsNames = existingParams
|
||||
.filter(({ type }) => type === mapping.param.type) // exclude mismatching param types
|
||||
.map(({ name }) => name); // keep names only
|
||||
return (<Fragment>
|
||||
{(this.constructor as any).getSourceTypeLabel(mapping)}{" "}
|
||||
<MappingEditor mapping={mapping} existingParamNames={existingParamsNames} onChange={(oldMapping, newMapping) => this.updateParamMapping(oldMapping, newMapping)}/>
|
||||
</Fragment>);
|
||||
}}/>
|
||||
</Table>
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
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}-select-selector,
|
||||
.@{ant-prefix}-picker {
|
||||
background-color: @input-dirty !important;
|
||||
background-color: @input-dirty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
124
client/app/components/ParameterValueInput.tsx
Normal file
124
client/app/components/ParameterValueInput.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { isEqual, isEmpty, map } from "lodash";
|
||||
import React from "react";
|
||||
import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll";
|
||||
import Input from "antd/lib/input";
|
||||
import InputNumber from "antd/lib/input-number";
|
||||
import DateParameter from "@/components/dynamic-parameters/DateParameter";
|
||||
import DateRangeParameter from "@/components/dynamic-parameters/DateRangeParameter";
|
||||
import QueryBasedParameterInput from "./QueryBasedParameterInput";
|
||||
import "./ParameterValueInput.less";
|
||||
const multipleValuesProps = {
|
||||
maxTagCount: 3,
|
||||
maxTagTextLength: 10,
|
||||
maxTagPlaceholder: (num: any) => `+${num.length} more`,
|
||||
};
|
||||
type OwnProps = {
|
||||
type?: string;
|
||||
value?: any;
|
||||
enumOptions?: string;
|
||||
queryId?: number;
|
||||
parameter?: any;
|
||||
onSelect?: (...args: any[]) => any;
|
||||
className?: string;
|
||||
};
|
||||
type State = any;
|
||||
type Props = OwnProps & typeof ParameterValueInput.defaultProps;
|
||||
class ParameterValueInput extends React.Component<Props, State> {
|
||||
static defaultProps = {
|
||||
type: "text",
|
||||
value: null,
|
||||
enumOptions: "",
|
||||
queryId: null,
|
||||
parameter: null,
|
||||
onSelect: () => { },
|
||||
className: "",
|
||||
};
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
value: (props as any).parameter.hasPendingValue ? (props as any).parameter.pendingValue : (props as any).value,
|
||||
isDirty: (props as any).parameter.hasPendingValue,
|
||||
};
|
||||
}
|
||||
componentDidUpdate = (prevProps: any) => {
|
||||
const { value, parameter } = this.props;
|
||||
// if value prop updated, reset dirty state
|
||||
if (prevProps.value !== value || prevProps.parameter !== parameter) {
|
||||
this.setState({
|
||||
value: (parameter as any).hasPendingValue ? (parameter as any).pendingValue : value,
|
||||
isDirty: (parameter as any).hasPendingValue,
|
||||
});
|
||||
}
|
||||
};
|
||||
onSelect = (value: any) => {
|
||||
const isDirty = !isEqual(value, (this.props as any).value);
|
||||
this.setState({ value, isDirty });
|
||||
(this.props as any).onSelect(value, isDirty);
|
||||
};
|
||||
renderDateParameter() {
|
||||
const { type, parameter } = this.props;
|
||||
const { value } = this.state;
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type '(value: any) => void' is not assignable to t... Remove this comment to see the full error message
|
||||
return (<DateParameter type={type} className={(this.props as any).className} value={value} parameter={parameter} onSelect={this.onSelect}/>);
|
||||
}
|
||||
renderDateRangeParameter() {
|
||||
const { type, parameter } = this.props;
|
||||
const { value } = this.state;
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type '(value: any) => void' is not assignable to t... Remove this comment to see the full error message
|
||||
return (<DateRangeParameter type={type} className={(this.props as any).className} value={value} parameter={parameter} onSelect={this.onSelect}/>);
|
||||
}
|
||||
renderEnumInput() {
|
||||
const { enumOptions, parameter } = this.props;
|
||||
const { value } = this.state;
|
||||
const enumOptionsArray = (enumOptions as any).split("\n").filter((v: any) => v !== "");
|
||||
// Antd Select doesn't handle null in multiple mode
|
||||
const normalize = (val: any) => (parameter as any).multiValuesOptions && val === null ? [] : val;
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type '"multiple" | "default"' is not assignable to... Remove this comment to see the full error message
|
||||
return (<SelectWithVirtualScroll className={(this.props as any).className} mode={(parameter as any).multiValuesOptions ? "multiple" : "default"} optionFilterProp="children" value={normalize(value)} onChange={this.onSelect} options={map(enumOptionsArray, opt => ({ label: String(opt), value: opt }))} showSearch showArrow notFoundContent={isEmpty(enumOptionsArray) ? "No options available" : null} {...multipleValuesProps}/>);
|
||||
}
|
||||
renderQueryBasedInput() {
|
||||
const { queryId, parameter } = this.props;
|
||||
const { value } = this.state;
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'any' is not assignable to type 'never'.
|
||||
return (<QueryBasedParameterInput className={(this.props as any).className} mode={(parameter as any).multiValuesOptions ? "multiple" : "default"} optionFilterProp="children" parameter={parameter} value={value} queryId={queryId} onSelect={this.onSelect} style={{ minWidth: 60 }} {...multipleValuesProps}/>);
|
||||
}
|
||||
renderNumberInput() {
|
||||
const { className } = this.props;
|
||||
const { value } = this.state;
|
||||
const normalize = (val: any) => isNaN(val) ? undefined : val;
|
||||
return (<InputNumber className={className} value={normalize(value)} onChange={val => this.onSelect(normalize(val))}/>);
|
||||
}
|
||||
renderTextInput() {
|
||||
const { className } = this.props;
|
||||
const { value } = this.state;
|
||||
return (<Input className={className} value={value} data-test="TextParamInput" onChange={e => this.onSelect(e.target.value)}/>);
|
||||
}
|
||||
renderInput() {
|
||||
const { type } = this.props;
|
||||
switch (type) {
|
||||
case "datetime-with-seconds":
|
||||
case "datetime-local":
|
||||
case "date":
|
||||
return this.renderDateParameter();
|
||||
case "datetime-range-with-seconds":
|
||||
case "datetime-range":
|
||||
case "date-range":
|
||||
return this.renderDateRangeParameter();
|
||||
case "enum":
|
||||
return this.renderEnumInput();
|
||||
case "query":
|
||||
return this.renderQueryBasedInput();
|
||||
case "number":
|
||||
return this.renderNumberInput();
|
||||
default:
|
||||
return this.renderTextInput();
|
||||
}
|
||||
}
|
||||
render() {
|
||||
const { isDirty } = this.state;
|
||||
return (<div className="parameter-input" data-dirty={isDirty || null} data-test="ParameterValueInput">
|
||||
{this.renderInput()}
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
export default ParameterValueInput;
|
||||
@@ -1,33 +1,37 @@
|
||||
import { size, filter, forEach, extend } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { SortableContainer, SortableElement, DragHandle } from "@redash/viz/lib/components/sortable";
|
||||
import location from "@/services/location";
|
||||
import { Parameter, createParameter } from "@/services/parameters";
|
||||
import { createParameter } from "@/services/parameters";
|
||||
import ParameterApplyButton from "@/components/ParameterApplyButton";
|
||||
import ParameterValueInput from "@/components/ParameterValueInput";
|
||||
import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
|
||||
import { toHuman } from "@/lib/utils";
|
||||
|
||||
import "./Parameters.less";
|
||||
|
||||
function updateUrl(parameters) {
|
||||
function updateUrl(parameters: any) {
|
||||
const params = extend({}, location.search);
|
||||
parameters.forEach(param => {
|
||||
parameters.forEach((param: any) => {
|
||||
extend(params, param.toUrlParams());
|
||||
});
|
||||
location.setSearch(params, true);
|
||||
}
|
||||
|
||||
export default class Parameters extends React.Component {
|
||||
static propTypes = {
|
||||
parameters: PropTypes.arrayOf(PropTypes.instanceOf(Parameter)),
|
||||
editable: PropTypes.bool,
|
||||
disableUrlUpdate: PropTypes.bool,
|
||||
onValuesChange: PropTypes.func,
|
||||
onPendingValuesChange: PropTypes.func,
|
||||
onParametersEdit: PropTypes.func,
|
||||
};
|
||||
type OwnProps = {
|
||||
parameters?: any[]; // TODO: PropTypes.instanceOf(Parameter)
|
||||
editable?: boolean;
|
||||
disableUrlUpdate?: boolean;
|
||||
onValuesChange?: (...args: any[]) => any;
|
||||
onPendingValuesChange?: (...args: any[]) => any;
|
||||
onParametersEdit?: (...args: any[]) => any;
|
||||
};
|
||||
|
||||
type State = any;
|
||||
|
||||
type Props = OwnProps & typeof Parameters.defaultProps;
|
||||
|
||||
export default class Parameters extends React.Component<Props, State> {
|
||||
static defaultProps = {
|
||||
parameters: [],
|
||||
editable: false,
|
||||
@@ -37,7 +41,9 @@ export default class Parameters extends React.Component {
|
||||
onParametersEdit: () => {},
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
onBeforeSortStart: any;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
const { parameters } = props;
|
||||
this.state = { parameters };
|
||||
@@ -46,7 +52,7 @@ export default class Parameters extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate = prevProps => {
|
||||
componentDidUpdate = (prevProps: any) => {
|
||||
const { parameters, disableUrlUpdate } = this.props;
|
||||
const parametersChanged = prevProps.parameters !== parameters;
|
||||
const disableUrlUpdateChanged = prevProps.disableUrlUpdate !== disableUrlUpdate;
|
||||
@@ -58,7 +64,7 @@ export default class Parameters extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyDown = e => {
|
||||
handleKeyDown = (e: any) => {
|
||||
// Cmd/Ctrl/Alt + Enter
|
||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey || e.altKey)) {
|
||||
e.stopPropagation();
|
||||
@@ -66,9 +72,11 @@ export default class Parameters extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
setPendingValue = (param, value, isDirty) => {
|
||||
setPendingValue = (param: any, value: any, isDirty: any) => {
|
||||
const { onPendingValuesChange } = this.props;
|
||||
this.setState(({ parameters }) => {
|
||||
this.setState(({
|
||||
parameters
|
||||
}: any) => {
|
||||
if (isDirty) {
|
||||
param.setPendingValue(value);
|
||||
} else {
|
||||
@@ -79,10 +87,15 @@ export default class Parameters extends React.Component {
|
||||
});
|
||||
};
|
||||
|
||||
moveParameter = ({ oldIndex, newIndex }) => {
|
||||
moveParameter = ({
|
||||
oldIndex,
|
||||
newIndex
|
||||
}: any) => {
|
||||
const { onParametersEdit } = this.props;
|
||||
if (oldIndex !== newIndex) {
|
||||
this.setState(({ parameters }) => {
|
||||
this.setState(({
|
||||
parameters
|
||||
}: any) => {
|
||||
parameters.splice(newIndex, 0, parameters.splice(oldIndex, 1)[0]);
|
||||
onParametersEdit();
|
||||
return { parameters };
|
||||
@@ -92,8 +105,10 @@ export default class Parameters extends React.Component {
|
||||
|
||||
applyChanges = () => {
|
||||
const { onValuesChange, disableUrlUpdate } = this.props;
|
||||
this.setState(({ parameters }) => {
|
||||
const parametersWithPendingValues = parameters.filter(p => p.hasPendingValue);
|
||||
this.setState(({
|
||||
parameters
|
||||
}: any) => {
|
||||
const parametersWithPendingValues = parameters.filter((p: any) => p.hasPendingValue);
|
||||
forEach(parameters, p => p.applyPendingValue());
|
||||
if (!disableUrlUpdate) {
|
||||
updateUrl(parameters);
|
||||
@@ -103,10 +118,12 @@ export default class Parameters extends React.Component {
|
||||
});
|
||||
};
|
||||
|
||||
showParameterSettings = (parameter, index) => {
|
||||
showParameterSettings = (parameter: any, index: any) => {
|
||||
const { onParametersEdit } = this.props;
|
||||
EditParameterSettingsDialog.showModal({ parameter }).onClose(updated => {
|
||||
this.setState(({ parameters }) => {
|
||||
EditParameterSettingsDialog.showModal({ parameter }).onClose((updated: any) => {
|
||||
this.setState(({
|
||||
parameters
|
||||
}: any) => {
|
||||
const updatedParameter = extend(parameter, updated);
|
||||
parameters[index] = createParameter(updatedParameter, updatedParameter.parentQueryId);
|
||||
onParametersEdit();
|
||||
@@ -115,12 +132,12 @@ export default class Parameters extends React.Component {
|
||||
});
|
||||
};
|
||||
|
||||
renderParameter(param, index) {
|
||||
renderParameter(param: any, index: any) {
|
||||
const { editable } = this.props;
|
||||
return (
|
||||
<div key={param.name} className="di-block" data-test={`ParameterName-${param.name}`}>
|
||||
<div className="parameter-heading">
|
||||
<label>{param.getTitle()}</label>
|
||||
<label>{param.title || toHuman(param.name)}</label>
|
||||
{editable && (
|
||||
<button
|
||||
className="btn btn-default btn-xs m-l-5"
|
||||
@@ -132,12 +149,18 @@ export default class Parameters extends React.Component {
|
||||
)}
|
||||
</div>
|
||||
<ParameterValueInput
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'any' is not assignable to type 'never'.
|
||||
type={param.type}
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'any' is not assignable to type 'never'.
|
||||
value={param.normalizedValue}
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'any' is not assignable to type 'never'.
|
||||
parameter={param}
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'any' is not assignable to type 'never'.
|
||||
enumOptions={param.enumOptions}
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'any' is not assignable to type 'never'.
|
||||
queryId={param.queryId}
|
||||
onSelect={(value, isDirty) => this.setPendingValue(param, value, isDirty)}
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type '(value: any, isDirty: any) => void' is not a... Remove this comment to see the full error message
|
||||
onSelect={(value: any, isDirty: any) => this.setPendingValue(param, value, isDirty)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -148,6 +171,7 @@ export default class Parameters extends React.Component {
|
||||
const { editable } = this.props;
|
||||
const dirtyParamCount = size(filter(parameters, "hasPendingValue"));
|
||||
return (
|
||||
// @ts-expect-error ts-migrate(2746) FIXME: This JSX tag's 'children' prop expects a single ch... Remove this comment to see the full error message
|
||||
<SortableContainer
|
||||
disabled={!editable}
|
||||
axis="xy"
|
||||
@@ -160,7 +184,7 @@ export default class Parameters extends React.Component {
|
||||
className: "parameter-container",
|
||||
onKeyDown: dirtyParamCount ? this.handleKeyDown : null,
|
||||
}}>
|
||||
{parameters.map((param, index) => (
|
||||
{parameters.map((param: any, index: any) => (
|
||||
<SortableElement key={param.name} index={index}>
|
||||
<div className="parameter-block" data-editable={editable || null}>
|
||||
{editable && <DragHandle data-test={`DragHandle-${param.name}`} />}
|
||||
@@ -1,196 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { axios } from "@/services/axios";
|
||||
import PropTypes from "prop-types";
|
||||
import { each, debounce, get, find } from "lodash";
|
||||
import Button from "antd/lib/button";
|
||||
import List from "antd/lib/list";
|
||||
import Modal from "antd/lib/modal";
|
||||
import Select from "antd/lib/select";
|
||||
import Tag from "antd/lib/tag";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||
import { toHuman } from "@/lib/utils";
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
import { UserPreviewCard } from "@/components/PreviewCard";
|
||||
import notification from "@/services/notification";
|
||||
import User from "@/services/user";
|
||||
|
||||
import "./index.less";
|
||||
|
||||
const { Option } = Select;
|
||||
const DEBOUNCE_SEARCH_DURATION = 200;
|
||||
|
||||
function useGrantees(url) {
|
||||
const loadGrantees = useCallback(
|
||||
() =>
|
||||
axios.get(url).then(data => {
|
||||
const resultGrantees = [];
|
||||
each(data, (grantees, accessType) => {
|
||||
grantees.forEach(grantee => {
|
||||
grantee.accessType = toHuman(accessType);
|
||||
resultGrantees.push(grantee);
|
||||
});
|
||||
});
|
||||
return resultGrantees;
|
||||
}),
|
||||
[url]
|
||||
);
|
||||
|
||||
const addPermission = useCallback(
|
||||
(userId, accessType = "modify") =>
|
||||
axios
|
||||
.post(url, { access_type: accessType, user_id: userId })
|
||||
.catch(() => notification.error("Could not grant permission to the user")),
|
||||
[url]
|
||||
);
|
||||
|
||||
const removePermission = useCallback(
|
||||
(userId, accessType = "modify") =>
|
||||
axios
|
||||
.delete(url, { data: { access_type: accessType, user_id: userId } })
|
||||
.catch(() => notification.error("Could not remove permission from the user")),
|
||||
[url]
|
||||
);
|
||||
|
||||
return { loadGrantees, addPermission, removePermission };
|
||||
}
|
||||
|
||||
const searchUsers = searchTerm =>
|
||||
User.query({ q: searchTerm })
|
||||
.then(({ results }) => results)
|
||||
.catch(() => []);
|
||||
|
||||
function PermissionsEditorDialogHeader({ context }) {
|
||||
return (
|
||||
<>
|
||||
Manage Permissions
|
||||
<div className="modal-header-desc">
|
||||
{`Editing this ${context} is enabled for the users in this list and for admins. `}
|
||||
<HelpTrigger type="MANAGE_PERMISSIONS" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
PermissionsEditorDialogHeader.propTypes = { context: PropTypes.oneOf(["query", "dashboard"]) };
|
||||
PermissionsEditorDialogHeader.defaultProps = { context: "query" };
|
||||
|
||||
function UserSelect({ onSelect, shouldShowUser }) {
|
||||
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const debouncedSearchUsers = useCallback(
|
||||
debounce(
|
||||
search =>
|
||||
searchUsers(search)
|
||||
.then(setUsers)
|
||||
.finally(() => setLoadingUsers(false)),
|
||||
DEBOUNCE_SEARCH_DURATION
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingUsers(true);
|
||||
debouncedSearchUsers(searchTerm);
|
||||
}, [debouncedSearchUsers, searchTerm]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
className="w-100 m-b-10"
|
||||
placeholder="Add users..."
|
||||
showSearch
|
||||
onSearch={setSearchTerm}
|
||||
suffixIcon={loadingUsers ? <i className="fa fa-spinner fa-pulse" /> : <i className="fa fa-search" />}
|
||||
filterOption={false}
|
||||
notFoundContent={null}
|
||||
value={undefined}
|
||||
getPopupContainer={trigger => trigger.parentNode}
|
||||
onSelect={onSelect}>
|
||||
{users.filter(shouldShowUser).map(user => (
|
||||
<Option key={user.id} value={user.id}>
|
||||
<UserPreviewCard user={user} />
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
UserSelect.propTypes = {
|
||||
onSelect: PropTypes.func,
|
||||
shouldShowUser: PropTypes.func,
|
||||
};
|
||||
UserSelect.defaultProps = { onSelect: () => {}, shouldShowUser: () => true };
|
||||
|
||||
function PermissionsEditorDialog({ dialog, author, context, aclUrl }) {
|
||||
const [loadingGrantees, setLoadingGrantees] = useState(true);
|
||||
const [grantees, setGrantees] = useState([]);
|
||||
const { loadGrantees, addPermission, removePermission } = useGrantees(aclUrl);
|
||||
const loadUsersWithPermissions = useCallback(() => {
|
||||
setLoadingGrantees(true);
|
||||
loadGrantees()
|
||||
.then(setGrantees)
|
||||
.catch(() => notification.error("Failed to load grantees list"))
|
||||
.finally(() => setLoadingGrantees(false));
|
||||
}, [loadGrantees]);
|
||||
|
||||
const userHasPermission = useCallback(
|
||||
user => user.id === author.id || !!get(find(grantees, { id: user.id }), "accessType"),
|
||||
[author.id, grantees]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsersWithPermissions();
|
||||
}, [aclUrl, loadUsersWithPermissions]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
{...dialog.props}
|
||||
className="permissions-editor-dialog"
|
||||
title={<PermissionsEditorDialogHeader context={context} />}
|
||||
footer={<Button onClick={dialog.dismiss}>Close</Button>}>
|
||||
<UserSelect
|
||||
onSelect={userId => addPermission(userId).then(loadUsersWithPermissions)}
|
||||
shouldShowUser={user => !userHasPermission(user)}
|
||||
/>
|
||||
<div className="d-flex align-items-center m-t-5">
|
||||
<h5 className="flex-fill">Users with permissions</h5>
|
||||
{loadingGrantees && <i className="fa fa-spinner fa-pulse" />}
|
||||
</div>
|
||||
<div className="scrollbox p-5" style={{ maxHeight: "40vh" }}>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={[author, ...grantees]}
|
||||
renderItem={user => (
|
||||
<List.Item>
|
||||
<UserPreviewCard key={user.id} user={user}>
|
||||
{user.id === author.id ? (
|
||||
<Tag className="m-0">Author</Tag>
|
||||
) : (
|
||||
<Tooltip title="Remove user permissions">
|
||||
<i
|
||||
className="fa fa-remove clickable"
|
||||
onClick={() => removePermission(user.id).then(loadUsersWithPermissions)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</UserPreviewCard>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
PermissionsEditorDialog.propTypes = {
|
||||
dialog: DialogPropType.isRequired,
|
||||
author: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
context: PropTypes.oneOf(["query", "dashboard"]),
|
||||
aclUrl: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
PermissionsEditorDialog.defaultProps = { context: "query" };
|
||||
|
||||
export default wrapDialog(PermissionsEditorDialog);
|
||||
127
client/app/components/PermissionsEditorDialog/index.tsx
Normal file
127
client/app/components/PermissionsEditorDialog/index.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { axios } from "@/services/axios";
|
||||
import { each, debounce, get, find } from "lodash";
|
||||
import Button from "antd/lib/button";
|
||||
import List from "antd/lib/list";
|
||||
import Modal from "antd/lib/modal";
|
||||
import Select from "antd/lib/select";
|
||||
import Tag from "antd/lib/tag";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
// @ts-expect-error ts-migrate(6133) FIXME: 'DialogPropType' is declared but its value is neve... Remove this comment to see the full error message
|
||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||
import { toHuman } from "@/lib/utils";
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
import { UserPreviewCard } from "@/components/PreviewCard";
|
||||
import notification from "@/services/notification";
|
||||
import User from "@/services/user";
|
||||
import "./index.less";
|
||||
const { Option } = Select;
|
||||
const DEBOUNCE_SEARCH_DURATION = 200;
|
||||
function useGrantees(url: any) {
|
||||
const loadGrantees = useCallback(() => axios.get(url).then(data => {
|
||||
const resultGrantees: any = [];
|
||||
each(data, (grantees, accessType) => {
|
||||
grantees.forEach((grantee: any) => {
|
||||
grantee.accessType = toHuman(accessType);
|
||||
resultGrantees.push(grantee);
|
||||
});
|
||||
});
|
||||
return resultGrantees;
|
||||
}), [url]);
|
||||
const addPermission = useCallback((userId, accessType = "modify") => axios
|
||||
.post(url, { access_type: accessType, user_id: userId })
|
||||
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string' is not assignable to par... Remove this comment to see the full error message
|
||||
.catch(() => notification.error("Could not grant permission to the user")), [url]);
|
||||
const removePermission = useCallback((userId, accessType = "modify") => axios
|
||||
.delete(url, { data: { access_type: accessType, user_id: userId } })
|
||||
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string' is not assignable to par... Remove this comment to see the full error message
|
||||
.catch(() => notification.error("Could not remove permission from the user")), [url]);
|
||||
return { loadGrantees, addPermission, removePermission };
|
||||
}
|
||||
const searchUsers = (searchTerm: any) => User.query({ q: searchTerm })
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'results' does not exist on type 'AxiosRe... Remove this comment to see the full error message
|
||||
.then(({ results }) => results)
|
||||
.catch(() => []);
|
||||
type OwnPermissionsEditorDialogHeaderProps = {
|
||||
context?: "query" | "dashboard";
|
||||
};
|
||||
type PermissionsEditorDialogHeaderProps = OwnPermissionsEditorDialogHeaderProps & typeof PermissionsEditorDialogHeader.defaultProps;
|
||||
function PermissionsEditorDialogHeader({ context }: PermissionsEditorDialogHeaderProps) {
|
||||
return (<>
|
||||
Manage Permissions
|
||||
<div className="modal-header-desc">
|
||||
{`Editing this ${context} is enabled for the users in this list and for admins. `}
|
||||
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'string' is not assignable to type 'never'. */}
|
||||
<HelpTrigger type="MANAGE_PERMISSIONS"/>
|
||||
</div>
|
||||
</>);
|
||||
}
|
||||
PermissionsEditorDialogHeader.defaultProps = { context: "query" };
|
||||
type OwnUserSelectProps = {
|
||||
onSelect?: (...args: any[]) => any;
|
||||
shouldShowUser?: (...args: any[]) => any;
|
||||
};
|
||||
type UserSelectProps = OwnUserSelectProps & typeof UserSelect.defaultProps;
|
||||
function UserSelect({ onSelect, shouldShowUser }: UserSelectProps) {
|
||||
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const debouncedSearchUsers = useCallback(debounce((search: any) => searchUsers(search)
|
||||
.then(setUsers)
|
||||
.finally(() => setLoadingUsers(false)), DEBOUNCE_SEARCH_DURATION), []);
|
||||
useEffect(() => {
|
||||
setLoadingUsers(true);
|
||||
debouncedSearchUsers(searchTerm);
|
||||
}, [debouncedSearchUsers, searchTerm]);
|
||||
return (<Select className="w-100 m-b-10" placeholder="Add users..." showSearch onSearch={setSearchTerm} suffixIcon={loadingUsers ? <i className="fa fa-spinner fa-pulse"/> : <i className="fa fa-search"/>} filterOption={false} notFoundContent={null} value={undefined} getPopupContainer={trigger => trigger.parentNode} onSelect={onSelect}>
|
||||
{users.filter(shouldShowUser).map(user => (<Option key={(user as any).id} value={(user as any).id}>
|
||||
<UserPreviewCard user={user}/>
|
||||
</Option>))}
|
||||
</Select>);
|
||||
}
|
||||
UserSelect.defaultProps = { onSelect: () => { }, shouldShowUser: () => true };
|
||||
type OwnPermissionsEditorDialogProps = {
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'DialogPropType' refers to a value, but is being u... Remove this comment to see the full error message
|
||||
dialog: DialogPropType;
|
||||
author: any;
|
||||
context?: "query" | "dashboard";
|
||||
aclUrl: string;
|
||||
};
|
||||
type PermissionsEditorDialogProps = OwnPermissionsEditorDialogProps & typeof PermissionsEditorDialog.defaultProps;
|
||||
function PermissionsEditorDialog({ dialog, author, context, aclUrl }: PermissionsEditorDialogProps) {
|
||||
const [loadingGrantees, setLoadingGrantees] = useState(true);
|
||||
const [grantees, setGrantees] = useState([]);
|
||||
const { loadGrantees, addPermission, removePermission } = useGrantees(aclUrl);
|
||||
const loadUsersWithPermissions = useCallback(() => {
|
||||
setLoadingGrantees(true);
|
||||
loadGrantees()
|
||||
.then(setGrantees)
|
||||
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string' is not assignable to par... Remove this comment to see the full error message
|
||||
.catch(() => notification.error("Failed to load grantees list"))
|
||||
.finally(() => setLoadingGrantees(false));
|
||||
}, [loadGrantees]);
|
||||
const userHasPermission = useCallback(user => user.id === author.id || !!get(find(grantees, { id: user.id }), "accessType"), [author.id, grantees]);
|
||||
useEffect(() => {
|
||||
loadUsersWithPermissions();
|
||||
}, [aclUrl, loadUsersWithPermissions]);
|
||||
return (<Modal {...dialog.props} className="permissions-editor-dialog" title={<PermissionsEditorDialogHeader context={context}/>} footer={<Button onClick={dialog.dismiss}>Close</Button>}>
|
||||
{/* @ts-expect-error ts-migrate(2322) FIXME: Type '(userId: any) => Promise<void>' is not assig... Remove this comment to see the full error message */}
|
||||
<UserSelect onSelect={(userId: any) => addPermission(userId).then(loadUsersWithPermissions)} shouldShowUser={(user: any) => !userHasPermission(user)}/>
|
||||
<div className="d-flex align-items-center m-t-5">
|
||||
<h5 className="flex-fill">Users with permissions</h5>
|
||||
{loadingGrantees && <i className="fa fa-spinner fa-pulse"/>}
|
||||
</div>
|
||||
<div className="scrollbox p-5" style={{ maxHeight: "40vh" }}>
|
||||
<List size="small" dataSource={[author, ...grantees]} renderItem={user => (<List.Item>
|
||||
<UserPreviewCard key={user.id} user={user}>
|
||||
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'Element' is not assignable to type 'null | u... Remove this comment to see the full error message */}
|
||||
{user.id === author.id ? (<Tag className="m-0">Author</Tag>) : (<Tooltip title="Remove user permissions">
|
||||
<i className="fa fa-remove clickable" onClick={() => removePermission(user.id).then(loadUsersWithPermissions)}/>
|
||||
</Tooltip>)}
|
||||
</UserPreviewCard>
|
||||
</List.Item>)}/>
|
||||
</div>
|
||||
</Modal>);
|
||||
}
|
||||
PermissionsEditorDialog.defaultProps = { context: "query" };
|
||||
export default wrapDialog(PermissionsEditorDialog);
|
||||
@@ -1,93 +0,0 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import classNames from "classnames";
|
||||
import Link from "@/components/Link";
|
||||
|
||||
// PreviewCard
|
||||
|
||||
export function PreviewCard({ imageUrl, roundedImage, title, body, children, className, ...props }) {
|
||||
return (
|
||||
<div {...props} className={className + " w-100 d-flex align-items-center"}>
|
||||
<img
|
||||
src={imageUrl}
|
||||
width="32"
|
||||
height="32"
|
||||
className={classNames({ "profile__image--settings": roundedImage }, "m-r-5")}
|
||||
alt="Logo/Avatar"
|
||||
/>
|
||||
<div className="flex-fill">
|
||||
<div>{title}</div>
|
||||
{body && <div className="text-muted">{body}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PreviewCard.propTypes = {
|
||||
imageUrl: PropTypes.string.isRequired,
|
||||
title: PropTypes.node.isRequired,
|
||||
body: PropTypes.node,
|
||||
roundedImage: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
PreviewCard.defaultProps = {
|
||||
body: null,
|
||||
roundedImage: true,
|
||||
className: "",
|
||||
children: null,
|
||||
};
|
||||
|
||||
// UserPreviewCard
|
||||
|
||||
export function UserPreviewCard({ user, withLink, children, ...props }) {
|
||||
const title = withLink ? <Link href={"users/" + user.id}>{user.name}</Link> : user.name;
|
||||
return (
|
||||
<PreviewCard {...props} imageUrl={user.profile_image_url} title={title} body={user.email}>
|
||||
{children}
|
||||
</PreviewCard>
|
||||
);
|
||||
}
|
||||
|
||||
UserPreviewCard.propTypes = {
|
||||
user: PropTypes.shape({
|
||||
profile_image_url: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
email: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
withLink: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
UserPreviewCard.defaultProps = {
|
||||
withLink: false,
|
||||
children: null,
|
||||
};
|
||||
|
||||
// DataSourcePreviewCard
|
||||
|
||||
export function DataSourcePreviewCard({ dataSource, withLink, children, ...props }) {
|
||||
const imageUrl = `static/images/db-logos/${dataSource.type}.png`;
|
||||
const title = withLink ? <Link href={"data_sources/" + dataSource.id}>{dataSource.name}</Link> : dataSource.name;
|
||||
return (
|
||||
<PreviewCard {...props} imageUrl={imageUrl} title={title}>
|
||||
{children}
|
||||
</PreviewCard>
|
||||
);
|
||||
}
|
||||
|
||||
DataSourcePreviewCard.propTypes = {
|
||||
dataSource: PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
type: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
withLink: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
DataSourcePreviewCard.defaultProps = {
|
||||
withLink: false,
|
||||
children: null,
|
||||
};
|
||||
72
client/app/components/PreviewCard.tsx
Normal file
72
client/app/components/PreviewCard.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from "react";
|
||||
import classNames from "classnames";
|
||||
import Link from "@/components/Link";
|
||||
type OwnPreviewCardProps = {
|
||||
imageUrl: string;
|
||||
title: React.ReactNode;
|
||||
body?: React.ReactNode;
|
||||
roundedImage?: boolean;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
type PreviewCardProps = OwnPreviewCardProps & typeof PreviewCard.defaultProps;
|
||||
// PreviewCard
|
||||
export function PreviewCard({ imageUrl, roundedImage, title, body, children, className, ...props }: PreviewCardProps) {
|
||||
return (<div {...props} className={className + " w-100 d-flex align-items-center"}>
|
||||
<img src={imageUrl} width="32" height="32" className={classNames({ "profile__image--settings": roundedImage }, "m-r-5")} alt="Logo/Avatar"/>
|
||||
<div className="flex-fill">
|
||||
<div>{title}</div>
|
||||
{body && <div className="text-muted">{body}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</div>);
|
||||
}
|
||||
PreviewCard.defaultProps = {
|
||||
body: null,
|
||||
roundedImage: true,
|
||||
className: "",
|
||||
children: null,
|
||||
};
|
||||
type OwnUserPreviewCardProps = {
|
||||
user: {
|
||||
profile_image_url: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
withLink?: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
type UserPreviewCardProps = OwnUserPreviewCardProps & typeof UserPreviewCard.defaultProps;
|
||||
// UserPreviewCard
|
||||
export function UserPreviewCard({ user, withLink, children, ...props }: UserPreviewCardProps) {
|
||||
const title = withLink ? <Link href={"users/" + (user as any).id}>{user.name}</Link> : user.name;
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'string' is not assignable to type 'null | un... Remove this comment to see the full error message
|
||||
return (<PreviewCard {...props} imageUrl={user.profile_image_url} title={title} body={user.email}>
|
||||
{children}
|
||||
</PreviewCard>);
|
||||
}
|
||||
UserPreviewCard.defaultProps = {
|
||||
withLink: false,
|
||||
children: null,
|
||||
};
|
||||
type OwnDataSourcePreviewCardProps = {
|
||||
dataSource: {
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
withLink?: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
type DataSourcePreviewCardProps = OwnDataSourcePreviewCardProps & typeof DataSourcePreviewCard.defaultProps;
|
||||
// DataSourcePreviewCard
|
||||
export function DataSourcePreviewCard({ dataSource, withLink, children, ...props }: DataSourcePreviewCardProps) {
|
||||
const imageUrl = `static/images/db-logos/${dataSource.type}.png`;
|
||||
const title = withLink ? <Link href={"data_sources/" + (dataSource as any).id}>{dataSource.name}</Link> : dataSource.name;
|
||||
return (<PreviewCard {...props} imageUrl={imageUrl} title={title}>
|
||||
{children}
|
||||
</PreviewCard>);
|
||||
}
|
||||
DataSourcePreviewCard.defaultProps = {
|
||||
withLink: false,
|
||||
children: null,
|
||||
};
|
||||
@@ -1,145 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
79
client/app/components/QueryBasedParameterInput.tsx
Normal file
79
client/app/components/QueryBasedParameterInput.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { find, isArray, get, first, map, intersection, isEqual, isEmpty } from "lodash";
|
||||
import React from "react";
|
||||
import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll";
|
||||
type OwnProps = {
|
||||
parameter?: any;
|
||||
value?: any;
|
||||
mode?: "default" | "multiple";
|
||||
queryId?: number;
|
||||
onSelect?: (...args: any[]) => any;
|
||||
className?: string;
|
||||
};
|
||||
type State = any;
|
||||
type Props = OwnProps & typeof QueryBasedParameterInput.defaultProps;
|
||||
export default class QueryBasedParameterInput extends React.Component<Props, State> {
|
||||
static defaultProps = {
|
||||
value: null,
|
||||
mode: "default",
|
||||
parameter: null,
|
||||
queryId: null,
|
||||
onSelect: () => { },
|
||||
className: "",
|
||||
};
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
options: [],
|
||||
value: null,
|
||||
loading: false,
|
||||
};
|
||||
}
|
||||
componentDidMount() {
|
||||
this._loadOptions((this.props as any).queryId);
|
||||
}
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if ((this.props as any).queryId !== (prevProps as any).queryId) {
|
||||
this._loadOptions((this.props as any).queryId);
|
||||
}
|
||||
if ((this.props as any).value !== (prevProps as any).value) {
|
||||
this.setValue((this.props as any).value);
|
||||
}
|
||||
}
|
||||
setValue(value: any) {
|
||||
const { options } = this.state;
|
||||
if ((this.props as any).mode === "multiple") {
|
||||
value = isArray(value) ? value : [value];
|
||||
const optionValues = map(options, option => option.value);
|
||||
const validValues = intersection(value, optionValues);
|
||||
this.setState({ value: validValues });
|
||||
return validValues;
|
||||
}
|
||||
const found = find(options, option => option.value === (this.props as any).value) !== undefined;
|
||||
value = found ? value : get(first(options), "value");
|
||||
this.setState({ value });
|
||||
return value;
|
||||
}
|
||||
async _loadOptions(queryId: any) {
|
||||
if (queryId && queryId !== this.state.queryId) {
|
||||
this.setState({ loading: true });
|
||||
const options = await (this.props as any).parameter.loadDropdownValues();
|
||||
// stale queryId check
|
||||
if ((this.props as any).queryId === queryId) {
|
||||
this.setState({ options, loading: false }, () => {
|
||||
const updatedValue = this.setValue((this.props as any).value);
|
||||
if (!isEqual(updatedValue, (this.props as any).value)) {
|
||||
(this.props as any).onSelect(updatedValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
render() {
|
||||
// @ts-expect-error ts-migrate(2700) FIXME: Rest types may only be created from object types.
|
||||
const { className, mode, onSelect, queryId, value, ...otherProps } = this.props;
|
||||
const { loading, options } = this.state;
|
||||
return (<span>
|
||||
<SelectWithVirtualScroll className={className} disabled={loading} loading={loading} mode={mode} value={this.state.value} onChange={onSelect} options={map(options, ({ value, name }) => ({ label: String(name), value }))} optionFilterProp="children" showSearch showArrow notFoundContent={isEmpty(options) ? "No options available" : null} {...otherProps}/>
|
||||
</span>);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { VisualizationType } from "@redash/viz/lib";
|
||||
import Link from "@/components/Link";
|
||||
import VisualizationName from "@/components/visualizations/VisualizationName";
|
||||
|
||||
import "./QueryLink.less";
|
||||
|
||||
function QueryLink({ query, visualization, readOnly }) {
|
||||
const getUrl = () => {
|
||||
let hash = null;
|
||||
if (visualization) {
|
||||
if (visualization.type === "TABLE") {
|
||||
// link to hard-coded table tab instead of the (hidden) visualization tab
|
||||
hash = "table";
|
||||
} else {
|
||||
hash = visualization.id;
|
||||
}
|
||||
}
|
||||
|
||||
return query.getUrl(false, hash);
|
||||
};
|
||||
|
||||
return (
|
||||
<Link href={readOnly ? null : getUrl()} className="query-link">
|
||||
<VisualizationName visualization={visualization} /> <span>{query.name}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
QueryLink.propTypes = {
|
||||
query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
visualization: VisualizationType,
|
||||
readOnly: PropTypes.bool,
|
||||
};
|
||||
|
||||
QueryLink.defaultProps = {
|
||||
visualization: null,
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
export default QueryLink;
|
||||
34
client/app/components/QueryLink.tsx
Normal file
34
client/app/components/QueryLink.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import { VisualizationType } from "@redash/viz/lib";
|
||||
import Link from "@/components/Link";
|
||||
import VisualizationName from "@/components/visualizations/VisualizationName";
|
||||
import "./QueryLink.less";
|
||||
type OwnProps = {
|
||||
query: any;
|
||||
visualization?: VisualizationType;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
type Props = OwnProps & typeof QueryLink.defaultProps;
|
||||
function QueryLink({ query, visualization, readOnly }: Props) {
|
||||
const getUrl = () => {
|
||||
let hash = null;
|
||||
if (visualization) {
|
||||
if ((visualization as any).type === "TABLE") {
|
||||
// link to hard-coded table tab instead of the (hidden) visualization tab
|
||||
hash = "table";
|
||||
}
|
||||
else {
|
||||
hash = (visualization as any).id;
|
||||
}
|
||||
}
|
||||
return (query as any).getUrl(false, hash);
|
||||
};
|
||||
return (<Link href={readOnly ? null : getUrl()} className="query-link">
|
||||
<VisualizationName visualization={visualization}/> <span>{(query as any).name}</span>
|
||||
</Link>);
|
||||
}
|
||||
QueryLink.defaultProps = {
|
||||
visualization: null,
|
||||
readOnly: false,
|
||||
};
|
||||
export default QueryLink;
|
||||
@@ -1,159 +0,0 @@
|
||||
import { find } from "lodash";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import cx from "classnames";
|
||||
import Input from "antd/lib/input";
|
||||
import Select from "antd/lib/select";
|
||||
import { Query } from "@/services/query";
|
||||
import notification from "@/services/notification";
|
||||
import { QueryTagsControl } from "@/components/tags-control/TagsControl";
|
||||
import useSearchResults from "@/lib/hooks/useSearchResults";
|
||||
|
||||
const { Option } = Select;
|
||||
function search(term) {
|
||||
if (term === null) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
// get recent
|
||||
if (!term) {
|
||||
return Query.recent().then(results => results.filter(item => !item.is_draft)); // filter out draft
|
||||
}
|
||||
|
||||
// search by query
|
||||
return Query.query({ q: term }).then(({ results }) => results);
|
||||
}
|
||||
|
||||
export default function QuerySelector(props) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedQuery, setSelectedQuery] = useState();
|
||||
const [doSearch, searchResults, searching] = useSearchResults(search, { initialResults: [] });
|
||||
|
||||
const placeholder = "Search a query by name";
|
||||
const clearIcon = <i className="fa fa-times hide-in-percy" onClick={() => selectQuery(null)} />;
|
||||
const spinIcon = <i className={cx("fa fa-spinner fa-pulse hide-in-percy", { hidden: !searching })} />;
|
||||
|
||||
useEffect(() => {
|
||||
doSearch(searchTerm);
|
||||
}, [doSearch, searchTerm]);
|
||||
|
||||
// set selected from prop
|
||||
useEffect(() => {
|
||||
if (props.selectedQuery) {
|
||||
setSelectedQuery(props.selectedQuery);
|
||||
}
|
||||
}, [props.selectedQuery]);
|
||||
|
||||
function selectQuery(queryId) {
|
||||
let query = null;
|
||||
if (queryId) {
|
||||
query = find(searchResults, { id: queryId });
|
||||
if (!query) {
|
||||
// shouldn't happen
|
||||
notification.error("Something went wrong...", "Couldn't select query");
|
||||
}
|
||||
}
|
||||
|
||||
setSearchTerm(query ? null : ""); // empty string triggers recent fetch
|
||||
setSelectedQuery(query);
|
||||
props.onChange(query);
|
||||
}
|
||||
|
||||
function renderResults() {
|
||||
if (!searchResults.length) {
|
||||
return <div className="text-muted">No results matching search term.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="list-group">
|
||||
{searchResults.map(q => (
|
||||
<a
|
||||
className={cx("query-selector-result", "list-group-item", { inactive: q.is_draft })}
|
||||
key={q.id}
|
||||
onClick={() => selectQuery(q.id)}
|
||||
data-test={`QueryId${q.id}`}>
|
||||
{q.name} <QueryTagsControl isDraft={q.is_draft} tags={q.tags} className="inline-tags-control" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.disabled) {
|
||||
return <Input value={selectedQuery && selectedQuery.name} placeholder={placeholder} disabled />;
|
||||
}
|
||||
|
||||
if (props.type === "select") {
|
||||
const suffixIcon = selectedQuery ? clearIcon : null;
|
||||
const value = selectedQuery ? selectedQuery.name : searchTerm;
|
||||
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
dropdownMatchSelectWidth={false}
|
||||
placeholder={placeholder}
|
||||
value={value || undefined} // undefined for the placeholder to show
|
||||
onSearch={setSearchTerm}
|
||||
onChange={selectQuery}
|
||||
suffixIcon={searching ? spinIcon : suffixIcon}
|
||||
notFoundContent={null}
|
||||
filterOption={false}
|
||||
defaultActiveFirstOption={false}
|
||||
className={props.className}
|
||||
data-test="QuerySelector">
|
||||
{searchResults &&
|
||||
searchResults.map(q => {
|
||||
const disabled = q.is_draft;
|
||||
return (
|
||||
<Option
|
||||
value={q.id}
|
||||
key={q.id}
|
||||
disabled={disabled}
|
||||
className="query-selector-result"
|
||||
data-test={`QueryId${q.id}`}>
|
||||
{q.name}{" "}
|
||||
<QueryTagsControl
|
||||
isDraft={q.is_draft}
|
||||
tags={q.tags}
|
||||
className={cx("inline-tags-control", { disabled })}
|
||||
/>
|
||||
</Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span data-test="QuerySelector">
|
||||
{selectedQuery ? (
|
||||
<Input value={selectedQuery.name} suffix={clearIcon} readOnly />
|
||||
) : (
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
suffix={spinIcon}
|
||||
/>
|
||||
)}
|
||||
<div className="scrollbox" style={{ maxHeight: "50vh", marginTop: 15 }}>
|
||||
{searchResults && renderResults()}
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
QuerySelector.propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
selectedQuery: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
type: PropTypes.oneOf(["select", "default"]),
|
||||
className: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
QuerySelector.defaultProps = {
|
||||
selectedQuery: null,
|
||||
type: "default",
|
||||
className: null,
|
||||
disabled: false,
|
||||
};
|
||||
110
client/app/components/QuerySelector.tsx
Normal file
110
client/app/components/QuerySelector.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { find } from "lodash";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import cx from "classnames";
|
||||
import Input from "antd/lib/input";
|
||||
import Select from "antd/lib/select";
|
||||
import { Query } from "@/services/query";
|
||||
import notification from "@/services/notification";
|
||||
import { QueryTagsControl } from "@/components/tags-control/TagsControl";
|
||||
import useSearchResults from "@/lib/hooks/useSearchResults";
|
||||
const { Option } = Select;
|
||||
function search(term: any) {
|
||||
if (term === null) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
// get recent
|
||||
if (!term) {
|
||||
return (Query as any).recent().then((results: any) => results.filter((item: any) => !item.is_draft)); // filter out draft
|
||||
}
|
||||
// search by query
|
||||
return (Query as any).query({ q: term }).then(({ results }: any) => results);
|
||||
}
|
||||
type OwnProps = {
|
||||
onChange: (...args: any[]) => any;
|
||||
selectedQuery?: any;
|
||||
type?: "select" | "default";
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
type Props = OwnProps & typeof QuerySelector.defaultProps;
|
||||
export default function QuerySelector(props: Props) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedQuery, setSelectedQuery] = useState();
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'never[]' is not assignable to type 'null | u... Remove this comment to see the full error message
|
||||
const [doSearch, searchResults, searching] = useSearchResults(search, { initialResults: [] });
|
||||
const placeholder = "Search a query by name";
|
||||
const clearIcon = <i className="fa fa-times hide-in-percy" onClick={() => selectQuery(null)}/>;
|
||||
const spinIcon = <i className={cx("fa fa-spinner fa-pulse hide-in-percy", { hidden: !searching })}/>;
|
||||
useEffect(() => {
|
||||
// @ts-expect-error ts-migrate(2349) FIXME: This expression is not callable.
|
||||
doSearch(searchTerm);
|
||||
}, [doSearch, searchTerm]);
|
||||
// set selected from prop
|
||||
useEffect(() => {
|
||||
if ((props as any).selectedQuery) {
|
||||
setSelectedQuery((props as any).selectedQuery);
|
||||
}
|
||||
}, [props]);
|
||||
function selectQuery(queryId: any) {
|
||||
let query = null;
|
||||
if (queryId) {
|
||||
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
|
||||
query = find(searchResults, { id: queryId });
|
||||
if (!query) {
|
||||
// shouldn't happen
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 1 arguments, but got 2.
|
||||
notification.error("Something went wrong...", "Couldn't select query");
|
||||
}
|
||||
}
|
||||
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | null' is not assignable... Remove this comment to see the full error message
|
||||
setSearchTerm(query ? null : ""); // empty string triggers recent fetch
|
||||
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'unknown' is not assignable to pa... Remove this comment to see the full error message
|
||||
setSelectedQuery(query);
|
||||
(props as any).onChange(query);
|
||||
}
|
||||
function renderResults() {
|
||||
if (!(searchResults as any).length) {
|
||||
return <div className="text-muted">No results matching search term.</div>;
|
||||
}
|
||||
return (<div className="list-group">
|
||||
{(searchResults as any).map((q: any) => <a className={cx("query-selector-result", "list-group-item", { inactive: q.is_draft })} key={q.id} onClick={() => selectQuery(q.id)} data-test={`QueryId${q.id}`}>
|
||||
{/* @ts-expect-error ts-migrate(2322) FIXME: Type '{ isDraft: any; tags: any; className: string... Remove this comment to see the full error message */}
|
||||
{q.name} <QueryTagsControl isDraft={q.is_draft} tags={q.tags} className="inline-tags-control"/>
|
||||
</a>)}
|
||||
</div>);
|
||||
}
|
||||
if ((props as any).disabled) {
|
||||
// @ts-expect-error ts-migrate(2532) FIXME: Object is possibly 'undefined'.
|
||||
return <Input value={selectedQuery && selectedQuery.name} placeholder={placeholder} disabled/>;
|
||||
}
|
||||
if ((props as any).type === "select") {
|
||||
const suffixIcon = selectedQuery ? clearIcon : null;
|
||||
// @ts-expect-error ts-migrate(2532) FIXME: Object is possibly 'undefined'.
|
||||
const value = selectedQuery ? selectedQuery.name : searchTerm;
|
||||
return (<Select showSearch dropdownMatchSelectWidth={false} placeholder={placeholder} value={value || undefined} // undefined for the placeholder to show
|
||||
onSearch={setSearchTerm} onChange={selectQuery} suffixIcon={searching ? spinIcon : suffixIcon} notFoundContent={null} filterOption={false} defaultActiveFirstOption={false} className={(props as any).className} data-test="QuerySelector">
|
||||
{searchResults &&
|
||||
(searchResults as any).map((q: any) => {
|
||||
const disabled = q.is_draft;
|
||||
return (<Option value={q.id} key={q.id} disabled={disabled} className="query-selector-result" data-test={`QueryId${q.id}`}>
|
||||
{q.name}{" "}
|
||||
{/* @ts-expect-error ts-migrate(2322) FIXME: Type '{ isDraft: any; tags: any; className: string... Remove this comment to see the full error message */}
|
||||
<QueryTagsControl isDraft={q.is_draft} tags={q.tags} className={cx("inline-tags-control", { disabled })}/>
|
||||
</Option>);
|
||||
})}
|
||||
</Select>);
|
||||
}
|
||||
return (<span data-test="QuerySelector">
|
||||
{/* @ts-expect-error ts-migrate(2532) FIXME: Object is possibly 'undefined'. */}
|
||||
{selectedQuery ? (<Input value={selectedQuery.name} suffix={clearIcon} readOnly/>) : (<Input placeholder={placeholder} value={searchTerm} onChange={e => setSearchTerm(e.target.value)} suffix={spinIcon}/>)}
|
||||
<div className="scrollbox" style={{ maxHeight: "50vh", marginTop: 15 }}>
|
||||
{searchResults && renderResults()}
|
||||
</div>
|
||||
</span>);
|
||||
}
|
||||
QuerySelector.defaultProps = {
|
||||
selectedQuery: null,
|
||||
type: "default",
|
||||
className: null,
|
||||
disabled: false,
|
||||
};
|
||||
@@ -1,163 +0,0 @@
|
||||
import d3 from "d3";
|
||||
import React, { useRef, useMemo, useCallback, useState, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Resizable as ReactResizable } from "react-resizable";
|
||||
import KeyboardShortcuts from "@/services/KeyboardShortcuts";
|
||||
|
||||
import "./index.less";
|
||||
|
||||
export default function Resizable({ toggleShortcut, direction, sizeAttribute, children }) {
|
||||
const [size, setSize] = useState(0);
|
||||
const elementRef = useRef();
|
||||
const wasUsingTouchEventsRef = useRef(false);
|
||||
const wasResizedRef = useRef(false);
|
||||
|
||||
const sizeProp = direction === "horizontal" ? "width" : "height";
|
||||
sizeAttribute = sizeAttribute || sizeProp;
|
||||
|
||||
const getElementSize = useCallback(() => {
|
||||
if (!elementRef.current) {
|
||||
return 0;
|
||||
}
|
||||
return Math.floor(elementRef.current.getBoundingClientRect()[sizeProp]);
|
||||
}, [sizeProp]);
|
||||
|
||||
const savedSize = useRef(null);
|
||||
const toggle = useCallback(() => {
|
||||
if (!elementRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = d3.select(elementRef.current);
|
||||
let targetSize;
|
||||
if (savedSize.current === null) {
|
||||
targetSize = "0px";
|
||||
savedSize.current = `${getElementSize()}px`;
|
||||
} else {
|
||||
targetSize = savedSize.current;
|
||||
savedSize.current = null;
|
||||
}
|
||||
|
||||
element
|
||||
.style(sizeAttribute, savedSize.current || "0px")
|
||||
.transition()
|
||||
.duration(200)
|
||||
.ease("swing")
|
||||
.style(sizeAttribute, targetSize);
|
||||
|
||||
// update state to new element's size
|
||||
setSize(parseInt(targetSize) || 0);
|
||||
}, [getElementSize, sizeAttribute]);
|
||||
|
||||
const resizeHandle = useMemo(
|
||||
() => (
|
||||
<span
|
||||
className={`react-resizable-handle react-resizable-handle-${direction}`}
|
||||
onClick={() => {
|
||||
// On desktops resize uses `mousedown`/`mousemove`/`mouseup` events, and there is a conflict
|
||||
// with this `click` handler: after user releases mouse - this handler will be executed.
|
||||
// So we use `wasResized` flag to check if there was actual resize or user just pressed and released
|
||||
// left mouse button (see also resize event handlers where ths flag is set).
|
||||
// On mobile devices `touchstart`/`touchend` events wll be used, so it's safe to just execute this handler.
|
||||
// To detect which set of events was actually used during particular resize operation, we pass
|
||||
// `onMouseDown` handler to draggable core and check event type there (see also that handler's code).
|
||||
if (wasUsingTouchEventsRef.current || !wasResizedRef.current) {
|
||||
toggle();
|
||||
}
|
||||
wasUsingTouchEventsRef.current = false;
|
||||
wasResizedRef.current = false;
|
||||
}}
|
||||
/>
|
||||
),
|
||||
[direction, toggle]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (toggleShortcut) {
|
||||
const shortcuts = {
|
||||
[toggleShortcut]: toggle,
|
||||
};
|
||||
|
||||
KeyboardShortcuts.bind(shortcuts);
|
||||
return () => {
|
||||
KeyboardShortcuts.unbind(shortcuts);
|
||||
};
|
||||
}
|
||||
}, [toggleShortcut, toggle]);
|
||||
|
||||
const resizeEventHandlers = useMemo(
|
||||
() => ({
|
||||
onResizeStart: () => {
|
||||
// use element's size as initial value (it will also check constraints set in CSS)
|
||||
// updated here and in `draggableCore::onMouseDown` handler to ensure that right value will be used
|
||||
setSize(getElementSize());
|
||||
},
|
||||
onResize: (unused, data) => {
|
||||
// update element directly for better UI responsiveness
|
||||
d3.select(elementRef.current).style(sizeAttribute, `${data.size[sizeProp]}px`);
|
||||
setSize(data.size[sizeProp]);
|
||||
wasResizedRef.current = true;
|
||||
},
|
||||
onResizeStop: () => {
|
||||
if (wasResizedRef.current) {
|
||||
savedSize.current = null;
|
||||
}
|
||||
},
|
||||
}),
|
||||
[sizeProp, getElementSize, sizeAttribute]
|
||||
);
|
||||
|
||||
const draggableCoreOptions = useMemo(
|
||||
() => ({
|
||||
onMouseDown: e => {
|
||||
// In some cases this handler is executed twice during the same resize operation - first time
|
||||
// with `touchstart` event and second time with `mousedown` (probably emulated by browser).
|
||||
// Therefore we set the flag only when we receive `touchstart` because in ths case it's definitely
|
||||
// mobile browser (desktop browsers will also send `mousedown` but never `touchstart`).
|
||||
if (e.type === "touchstart") {
|
||||
wasUsingTouchEventsRef.current = true;
|
||||
}
|
||||
|
||||
// use element's size as initial value (it will also check constraints set in CSS)
|
||||
// updated here and in `onResizeStart` handler to ensure that right value will be used
|
||||
setSize(getElementSize());
|
||||
},
|
||||
}),
|
||||
[getElementSize]
|
||||
);
|
||||
|
||||
if (!children) {
|
||||
return null;
|
||||
}
|
||||
|
||||
children = React.createElement(children.type, { ...children.props, ref: elementRef });
|
||||
|
||||
return (
|
||||
<ReactResizable
|
||||
className="resizable-component"
|
||||
axis={direction === "horizontal" ? "x" : "y"}
|
||||
resizeHandles={[direction === "horizontal" ? "e" : "s"]}
|
||||
handle={resizeHandle}
|
||||
width={direction === "horizontal" ? size : 0}
|
||||
height={direction === "vertical" ? size : 0}
|
||||
minConstraints={[0, 0]}
|
||||
{...resizeEventHandlers}
|
||||
draggableOpts={draggableCoreOptions}>
|
||||
{children}
|
||||
</ReactResizable>
|
||||
);
|
||||
}
|
||||
|
||||
Resizable.propTypes = {
|
||||
direction: PropTypes.oneOf(["horizontal", "vertical"]),
|
||||
sizeAttribute: PropTypes.string,
|
||||
toggleShortcut: PropTypes.string,
|
||||
children: PropTypes.element,
|
||||
};
|
||||
|
||||
Resizable.defaultProps = {
|
||||
direction: "horizontal",
|
||||
sizeAttribute: null, // "width"/"height" - depending on `direction`
|
||||
toggleShortcut: null,
|
||||
children: null,
|
||||
};
|
||||
128
client/app/components/Resizable/index.tsx
Normal file
128
client/app/components/Resizable/index.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import d3 from "d3";
|
||||
import React, { useRef, useMemo, useCallback, useState, useEffect } from "react";
|
||||
import { Resizable as ReactResizable } from "react-resizable";
|
||||
import KeyboardShortcuts from "@/services/KeyboardShortcuts";
|
||||
import "./index.less";
|
||||
type OwnProps = {
|
||||
direction?: "horizontal" | "vertical";
|
||||
sizeAttribute?: string;
|
||||
toggleShortcut?: string;
|
||||
children?: React.ReactElement;
|
||||
};
|
||||
type Props = OwnProps & typeof Resizable.defaultProps;
|
||||
export default function Resizable({ toggleShortcut, direction, sizeAttribute, children }: Props) {
|
||||
const [size, setSize] = useState(0);
|
||||
const elementRef = useRef();
|
||||
const wasUsingTouchEventsRef = useRef(false);
|
||||
const wasResizedRef = useRef(false);
|
||||
const sizeProp = direction === "horizontal" ? "width" : "height";
|
||||
sizeAttribute = sizeAttribute || sizeProp;
|
||||
const getElementSize = useCallback(() => {
|
||||
if (!elementRef.current) {
|
||||
return 0;
|
||||
}
|
||||
// @ts-expect-error ts-migrate(2532) FIXME: Object is possibly 'undefined'.
|
||||
return Math.floor(elementRef.current.getBoundingClientRect()[sizeProp]);
|
||||
}, [sizeProp]);
|
||||
const savedSize = useRef(null);
|
||||
const toggle = useCallback(() => {
|
||||
if (!elementRef.current) {
|
||||
return;
|
||||
}
|
||||
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
|
||||
const element = d3.select(elementRef.current);
|
||||
let targetSize;
|
||||
if (savedSize.current === null) {
|
||||
targetSize = "0px";
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'string' is not assignable to type 'null'.
|
||||
savedSize.current = `${getElementSize()}px`;
|
||||
}
|
||||
else {
|
||||
targetSize = savedSize.current;
|
||||
savedSize.current = null;
|
||||
}
|
||||
element
|
||||
.style(sizeAttribute, savedSize.current || "0px")
|
||||
.transition()
|
||||
.duration(200)
|
||||
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string' is not assignable to par... Remove this comment to see the full error message
|
||||
.ease("swing")
|
||||
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
|
||||
.style(sizeAttribute, targetSize);
|
||||
// update state to new element's size
|
||||
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | null' is not assignable... Remove this comment to see the full error message
|
||||
setSize(parseInt(targetSize) || 0);
|
||||
}, [getElementSize, sizeAttribute]);
|
||||
const resizeHandle = useMemo(() => (<span className={`react-resizable-handle react-resizable-handle-${direction}`} onClick={() => {
|
||||
// On desktops resize uses `mousedown`/`mousemove`/`mouseup` events, and there is a conflict
|
||||
// with this `click` handler: after user releases mouse - this handler will be executed.
|
||||
// So we use `wasResized` flag to check if there was actual resize or user just pressed and released
|
||||
// left mouse button (see also resize event handlers where ths flag is set).
|
||||
// On mobile devices `touchstart`/`touchend` events wll be used, so it's safe to just execute this handler.
|
||||
// To detect which set of events was actually used during particular resize operation, we pass
|
||||
// `onMouseDown` handler to draggable core and check event type there (see also that handler's code).
|
||||
if (wasUsingTouchEventsRef.current || !wasResizedRef.current) {
|
||||
toggle();
|
||||
}
|
||||
wasUsingTouchEventsRef.current = false;
|
||||
wasResizedRef.current = false;
|
||||
}}/>), [direction, toggle]);
|
||||
useEffect(() => {
|
||||
if (toggleShortcut) {
|
||||
const shortcuts = {
|
||||
[toggleShortcut]: toggle,
|
||||
};
|
||||
KeyboardShortcuts.bind(shortcuts);
|
||||
return () => {
|
||||
KeyboardShortcuts.unbind(shortcuts);
|
||||
};
|
||||
}
|
||||
}, [toggleShortcut, toggle]);
|
||||
const resizeEventHandlers = useMemo(() => ({
|
||||
onResizeStart: () => {
|
||||
// use element's size as initial value (it will also check constraints set in CSS)
|
||||
// updated here and in `draggableCore::onMouseDown` handler to ensure that right value will be used
|
||||
setSize(getElementSize());
|
||||
},
|
||||
onResize: (unused: any, data: any) => {
|
||||
// update element directly for better UI responsiveness
|
||||
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
|
||||
d3.select(elementRef.current).style(sizeAttribute, `${data.size[sizeProp]}px`);
|
||||
setSize(data.size[sizeProp]);
|
||||
wasResizedRef.current = true;
|
||||
},
|
||||
onResizeStop: () => {
|
||||
if (wasResizedRef.current) {
|
||||
savedSize.current = null;
|
||||
}
|
||||
},
|
||||
}), [sizeProp, getElementSize, sizeAttribute]);
|
||||
const draggableCoreOptions = useMemo(() => ({
|
||||
onMouseDown: (e: any) => {
|
||||
// In some cases this handler is executed twice during the same resize operation - first time
|
||||
// with `touchstart` event and second time with `mousedown` (probably emulated by browser).
|
||||
// Therefore we set the flag only when we receive `touchstart` because in ths case it's definitely
|
||||
// mobile browser (desktop browsers will also send `mousedown` but never `touchstart`).
|
||||
if (e.type === "touchstart") {
|
||||
wasUsingTouchEventsRef.current = true;
|
||||
}
|
||||
// use element's size as initial value (it will also check constraints set in CSS)
|
||||
// updated here and in `onResizeStart` handler to ensure that right value will be used
|
||||
setSize(getElementSize());
|
||||
},
|
||||
}), [getElementSize]);
|
||||
if (!children) {
|
||||
return null;
|
||||
}
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'CElement<any, Component<any, any, any>>' is ... Remove this comment to see the full error message
|
||||
children = React.createElement((children as any).type, { ...(children as any).props, ref: elementRef });
|
||||
return (<ReactResizable className="resizable-component" axis={direction === "horizontal" ? "x" : "y"} resizeHandles={[direction === "horizontal" ? "e" : "s"]} handle={resizeHandle} width={direction === "horizontal" ? size : 0} height={direction === "vertical" ? size : 0} minConstraints={[0, 0]} {...resizeEventHandlers} draggableOpts={draggableCoreOptions}>
|
||||
{children}
|
||||
</ReactResizable>);
|
||||
}
|
||||
Resizable.defaultProps = {
|
||||
direction: "horizontal",
|
||||
sizeAttribute: null,
|
||||
toggleShortcut: null,
|
||||
children: null,
|
||||
};
|
||||
@@ -1,192 +0,0 @@
|
||||
import { filter, find, isEmpty, size } from "lodash";
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import classNames from "classnames";
|
||||
import Modal from "antd/lib/modal";
|
||||
import Input from "antd/lib/input";
|
||||
import List from "antd/lib/list";
|
||||
import Button from "antd/lib/button";
|
||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||
import BigMessage from "@/components/BigMessage";
|
||||
import LoadingState from "@/components/items-list/components/LoadingState";
|
||||
import notification from "@/services/notification";
|
||||
import useSearchResults from "@/lib/hooks/useSearchResults";
|
||||
|
||||
function ItemsList({ items, renderItem, onItemClick }) {
|
||||
const renderListItem = useCallback(
|
||||
item => {
|
||||
const { content, className, isDisabled } = renderItem(item);
|
||||
return (
|
||||
<List.Item
|
||||
className={classNames("p-l-10", "p-r-10", { clickable: !isDisabled, disabled: isDisabled }, className)}
|
||||
onClick={isDisabled ? null : () => onItemClick(item)}>
|
||||
{content}
|
||||
</List.Item>
|
||||
);
|
||||
},
|
||||
[renderItem, onItemClick]
|
||||
);
|
||||
|
||||
return <List size="small" dataSource={items} renderItem={renderListItem} />;
|
||||
}
|
||||
|
||||
ItemsList.propTypes = {
|
||||
items: PropTypes.array,
|
||||
renderItem: PropTypes.func,
|
||||
onItemClick: PropTypes.func,
|
||||
};
|
||||
|
||||
ItemsList.defaultProps = {
|
||||
items: [],
|
||||
renderItem: () => {},
|
||||
onItemClick: () => {},
|
||||
};
|
||||
|
||||
function SelectItemsDialog({
|
||||
dialog,
|
||||
dialogTitle,
|
||||
inputPlaceholder,
|
||||
itemKey,
|
||||
renderItem,
|
||||
renderStagedItem,
|
||||
searchItems,
|
||||
selectedItemsTitle,
|
||||
width,
|
||||
showCount,
|
||||
extraFooterContent,
|
||||
}) {
|
||||
const [selectedItems, setSelectedItems] = useState([]);
|
||||
const [search, items, isLoading] = useSearchResults(searchItems, { initialResults: [] });
|
||||
const hasResults = items.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
search();
|
||||
}, [search]);
|
||||
|
||||
const isItemSelected = useCallback(
|
||||
item => {
|
||||
const key = itemKey(item);
|
||||
return !!find(selectedItems, i => itemKey(i) === key);
|
||||
},
|
||||
[selectedItems, itemKey]
|
||||
);
|
||||
|
||||
const toggleItem = useCallback(
|
||||
item => {
|
||||
if (isItemSelected(item)) {
|
||||
const key = itemKey(item);
|
||||
setSelectedItems(filter(selectedItems, i => itemKey(i) !== key));
|
||||
} else {
|
||||
setSelectedItems([...selectedItems, item]);
|
||||
}
|
||||
},
|
||||
[selectedItems, itemKey, isItemSelected]
|
||||
);
|
||||
|
||||
const save = useCallback(() => {
|
||||
dialog.close(selectedItems).catch(error => {
|
||||
if (error) {
|
||||
notification.error("Failed to save some of selected items.");
|
||||
}
|
||||
});
|
||||
}, [dialog, selectedItems]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
{...dialog.props}
|
||||
className="select-items-dialog"
|
||||
width={width}
|
||||
title={dialogTitle}
|
||||
footer={
|
||||
<div className="d-flex align-items-center">
|
||||
<span className="flex-fill m-r-5" style={{ textAlign: "left", color: "rgba(0, 0, 0, 0.5)" }}>
|
||||
{extraFooterContent}
|
||||
</span>
|
||||
<Button {...dialog.props.cancelButtonProps} onClick={dialog.dismiss}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
{...dialog.props.okButtonProps}
|
||||
onClick={save}
|
||||
disabled={selectedItems.length === 0 || dialog.props.okButtonProps.disabled}
|
||||
type="primary">
|
||||
Save
|
||||
{showCount && !isEmpty(selectedItems) ? ` (${size(selectedItems)})` : null}
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
<div className="d-flex align-items-center m-b-10">
|
||||
<div className="flex-fill">
|
||||
<Input.Search onChange={event => search(event.target.value)} placeholder={inputPlaceholder} autoFocus />
|
||||
</div>
|
||||
{renderStagedItem && (
|
||||
<div className="w-50 m-l-20">
|
||||
<h5 className="m-0">{selectedItemsTitle}</h5>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="d-flex align-items-stretch" style={{ minHeight: "30vh", maxHeight: "50vh" }}>
|
||||
<div className="flex-fill scrollbox">
|
||||
{isLoading && <LoadingState className="" />}
|
||||
{!isLoading && !hasResults && (
|
||||
<BigMessage icon="fa-search" message="No items match your search." className="" />
|
||||
)}
|
||||
{!isLoading && hasResults && (
|
||||
<ItemsList
|
||||
items={items}
|
||||
renderItem={item => renderItem(item, { isSelected: isItemSelected(item) })}
|
||||
onItemClick={toggleItem}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{renderStagedItem && (
|
||||
<div className="w-50 m-l-20 scrollbox">
|
||||
{selectedItems.length > 0 && (
|
||||
<ItemsList
|
||||
items={selectedItems}
|
||||
renderItem={item => renderStagedItem(item, { isSelected: true })}
|
||||
onItemClick={toggleItem}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
SelectItemsDialog.propTypes = {
|
||||
dialog: DialogPropType.isRequired,
|
||||
dialogTitle: PropTypes.string,
|
||||
inputPlaceholder: PropTypes.string,
|
||||
selectedItemsTitle: PropTypes.string,
|
||||
searchItems: PropTypes.func.isRequired, // (searchTerm: string): Promise<Items[]> if `searchTerm === ''` load all
|
||||
itemKey: PropTypes.func, // (item) => string|number - return key of item (by default `id`)
|
||||
// left list
|
||||
// (item, { isSelected }) => {
|
||||
// content: node, // item contents
|
||||
// className: string = '', // additional class for item wrapper
|
||||
// isDisabled: bool = false, // is item clickable or disabled
|
||||
// }
|
||||
renderItem: PropTypes.func,
|
||||
// right list; args/results save as for `renderItem`. if not specified - `renderItem` will be used
|
||||
renderStagedItem: PropTypes.func,
|
||||
width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
extraFooterContent: PropTypes.node,
|
||||
showCount: PropTypes.bool,
|
||||
};
|
||||
|
||||
SelectItemsDialog.defaultProps = {
|
||||
dialogTitle: "Add Items",
|
||||
inputPlaceholder: "Search...",
|
||||
selectedItemsTitle: "Selected items",
|
||||
itemKey: item => item.id,
|
||||
renderItem: () => "",
|
||||
renderStagedItem: null, // hidden by default
|
||||
width: "80%",
|
||||
extraFooterContent: null,
|
||||
showCount: false,
|
||||
};
|
||||
|
||||
export default wrapDialog(SelectItemsDialog);
|
||||
132
client/app/components/SelectItemsDialog.tsx
Normal file
132
client/app/components/SelectItemsDialog.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { filter, find, isEmpty, size } from "lodash";
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import classNames from "classnames";
|
||||
import Modal from "antd/lib/modal";
|
||||
import Input from "antd/lib/input";
|
||||
import List from "antd/lib/list";
|
||||
import Button from "antd/lib/button";
|
||||
// @ts-expect-error ts-migrate(6133) FIXME: 'DialogPropType' is declared but its value is neve... Remove this comment to see the full error message
|
||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||
import BigMessage from "@/components/BigMessage";
|
||||
import LoadingState from "@/components/items-list/components/LoadingState";
|
||||
import notification from "@/services/notification";
|
||||
import useSearchResults from "@/lib/hooks/useSearchResults";
|
||||
type OwnItemsListProps = {
|
||||
items?: any[];
|
||||
renderItem?: (...args: any[]) => any;
|
||||
onItemClick?: (...args: any[]) => any;
|
||||
};
|
||||
type ItemsListProps = OwnItemsListProps & typeof ItemsList.defaultProps;
|
||||
function ItemsList({ items, renderItem, onItemClick }: ItemsListProps) {
|
||||
const renderListItem = useCallback(item => {
|
||||
const { content, className, isDisabled } = renderItem(item);
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type '(() => any) | null' is not assignable to typ... Remove this comment to see the full error message
|
||||
return (<List.Item className={classNames("p-l-10", "p-r-10", { clickable: !isDisabled, disabled: isDisabled }, className)} onClick={isDisabled ? null : () => onItemClick(item)}>
|
||||
{content}
|
||||
</List.Item>);
|
||||
}, [renderItem, onItemClick]);
|
||||
return <List size="small" dataSource={items} renderItem={renderListItem}/>;
|
||||
}
|
||||
ItemsList.defaultProps = {
|
||||
items: [],
|
||||
renderItem: () => { },
|
||||
onItemClick: () => { },
|
||||
};
|
||||
type OwnSelectItemsDialogProps = {
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'DialogPropType' refers to a value, but is being u... Remove this comment to see the full error message
|
||||
dialog: DialogPropType;
|
||||
dialogTitle?: string;
|
||||
inputPlaceholder?: string;
|
||||
selectedItemsTitle?: string;
|
||||
searchItems: (...args: any[]) => any;
|
||||
itemKey?: (...args: any[]) => any;
|
||||
renderItem?: (...args: any[]) => any;
|
||||
renderStagedItem?: (...args: any[]) => any;
|
||||
width?: string | number;
|
||||
extraFooterContent?: React.ReactNode;
|
||||
showCount?: boolean;
|
||||
};
|
||||
type SelectItemsDialogProps = OwnSelectItemsDialogProps & typeof SelectItemsDialog.defaultProps;
|
||||
function SelectItemsDialog({ dialog, dialogTitle, inputPlaceholder, itemKey, renderItem, renderStagedItem, searchItems, selectedItemsTitle, width, showCount, extraFooterContent, }: SelectItemsDialogProps) {
|
||||
const [selectedItems, setSelectedItems] = useState([]);
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'never[]' is not assignable to type 'null | u... Remove this comment to see the full error message
|
||||
const [search, items, isLoading] = useSearchResults(searchItems, { initialResults: [] });
|
||||
const hasResults = (items as any).length > 0;
|
||||
useEffect(() => {
|
||||
// @ts-expect-error ts-migrate(2349) FIXME: This expression is not callable.
|
||||
search();
|
||||
}, [search]);
|
||||
const isItemSelected = useCallback(item => {
|
||||
// @ts-expect-error ts-migrate(2349) FIXME: This expression is not callable.
|
||||
const key = itemKey(item);
|
||||
// @ts-expect-error ts-migrate(2349) FIXME: This expression is not callable.
|
||||
return !!find(selectedItems, i => itemKey(i) === key);
|
||||
}, [selectedItems, itemKey]);
|
||||
const toggleItem = useCallback(item => {
|
||||
if (isItemSelected(item)) {
|
||||
// @ts-expect-error ts-migrate(2349) FIXME: This expression is not callable.
|
||||
const key = itemKey(item);
|
||||
// @ts-expect-error ts-migrate(2349) FIXME: This expression is not callable.
|
||||
setSelectedItems(filter(selectedItems, i => itemKey(i) !== key));
|
||||
}
|
||||
else {
|
||||
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'any[]' is not assignable to para... Remove this comment to see the full error message
|
||||
setSelectedItems([...selectedItems, item]);
|
||||
}
|
||||
}, [selectedItems, itemKey, isItemSelected]);
|
||||
const save = useCallback(() => {
|
||||
(dialog as any).close(selectedItems).catch((error: any) => {
|
||||
if (error) {
|
||||
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string' is not assignable to par... Remove this comment to see the full error message
|
||||
notification.error("Failed to save some of selected items.");
|
||||
}
|
||||
});
|
||||
}, [dialog, selectedItems]);
|
||||
return (<Modal {...(dialog as any).props} className="select-items-dialog" width={width} title={dialogTitle} footer={<div className="d-flex align-items-center">
|
||||
<span className="flex-fill m-r-5" style={{ textAlign: "left", color: "rgba(0, 0, 0, 0.5)" }}>
|
||||
{extraFooterContent}
|
||||
</span>
|
||||
<Button {...(dialog as any).props.cancelButtonProps} onClick={(dialog as any).dismiss}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button {...(dialog as any).props.okButtonProps} onClick={save} disabled={selectedItems.length === 0 || (dialog as any).props.okButtonProps.disabled} type="primary">
|
||||
Save
|
||||
{showCount && !isEmpty(selectedItems) ? ` (${size(selectedItems)})` : null}
|
||||
</Button>
|
||||
</div>}>
|
||||
<div className="d-flex align-items-center m-b-10">
|
||||
<div className="flex-fill">
|
||||
{/* @ts-expect-error ts-migrate(2349) FIXME: This expression is not callable. */}
|
||||
<Input.Search onChange={event => search(event.target.value)} placeholder={inputPlaceholder} autoFocus/>
|
||||
</div>
|
||||
{renderStagedItem && (<div className="w-50 m-l-20">
|
||||
<h5 className="m-0">{selectedItemsTitle}</h5>
|
||||
</div>)}
|
||||
</div>
|
||||
|
||||
<div className="d-flex align-items-stretch" style={{ minHeight: "30vh", maxHeight: "50vh" }}>
|
||||
<div className="flex-fill scrollbox">
|
||||
{isLoading && <LoadingState className=""/>}
|
||||
{!isLoading && !hasResults && (<BigMessage icon="fa-search" message="No items match your search." className=""/>)}
|
||||
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'boolean | ((searchTerm: any) => void) | null... Remove this comment to see the full error message */}
|
||||
{!isLoading && hasResults && (<ItemsList items={items} renderItem={(item: any) => renderItem(item, { isSelected: isItemSelected(item) })} onItemClick={toggleItem}/>)}
|
||||
</div>
|
||||
{renderStagedItem && (<div className="w-50 m-l-20 scrollbox">
|
||||
{/* @ts-expect-error ts-migrate(2322) FIXME: Type '(item: any) => any' is not assignable to typ... Remove this comment to see the full error message */}
|
||||
{selectedItems.length > 0 && (<ItemsList items={selectedItems} renderItem={(item: any) => renderStagedItem(item, { isSelected: true })} onItemClick={toggleItem}/>)}
|
||||
</div>)}
|
||||
</div>
|
||||
</Modal>);
|
||||
}
|
||||
SelectItemsDialog.defaultProps = {
|
||||
dialogTitle: "Add Items",
|
||||
inputPlaceholder: "Search...",
|
||||
selectedItemsTitle: "Selected items",
|
||||
itemKey: (item: any) => item.id,
|
||||
renderItem: () => "",
|
||||
renderStagedItem: null,
|
||||
width: "80%",
|
||||
extraFooterContent: null,
|
||||
showCount: false,
|
||||
};
|
||||
export default wrapDialog(SelectItemsDialog);
|
||||
@@ -1,39 +0,0 @@
|
||||
import React from "react";
|
||||
import Menu from "antd/lib/menu";
|
||||
import PageHeader from "@/components/PageHeader";
|
||||
import Link from "@/components/Link";
|
||||
import location from "@/services/location";
|
||||
import settingsMenu from "@/services/settingsMenu";
|
||||
|
||||
function wrapSettingsTab(id, options, WrappedComponent) {
|
||||
settingsMenu.add(id, options);
|
||||
|
||||
return function SettingsTab(props) {
|
||||
const activeItem = settingsMenu.getActiveItem(location.path);
|
||||
return (
|
||||
<div className="settings-screen">
|
||||
<div className="container">
|
||||
<PageHeader title="Settings" />
|
||||
<div className="bg-white tiled">
|
||||
<Menu selectedKeys={[activeItem && activeItem.title]} selectable={false} mode="horizontal">
|
||||
{settingsMenu.getAvailableItems().map(item => (
|
||||
<Menu.Item key={item.title}>
|
||||
<Link href={item.path} data-test="SettingsScreenItem">
|
||||
{item.title}
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
<div className="p-15">
|
||||
<div>
|
||||
<WrappedComponent {...props} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default wrapSettingsTab;
|
||||
32
client/app/components/SettingsWrapper.tsx
Normal file
32
client/app/components/SettingsWrapper.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import Menu from "antd/lib/menu";
|
||||
import PageHeader from "@/components/PageHeader";
|
||||
import Link from "@/components/Link";
|
||||
import location from "@/services/location";
|
||||
import settingsMenu from "@/services/settingsMenu";
|
||||
function wrapSettingsTab(id: any, options: any, WrappedComponent: any) {
|
||||
settingsMenu.add(id, options);
|
||||
return function SettingsTab(props: any) {
|
||||
const activeItem = settingsMenu.getActiveItem(location.path);
|
||||
return (<div className="settings-screen">
|
||||
<div className="container">
|
||||
<PageHeader title="Settings"/>
|
||||
<div className="bg-white tiled">
|
||||
<Menu selectedKeys={[activeItem && (activeItem as any).title]} selectable={false} mode="horizontal">
|
||||
{settingsMenu.getAvailableItems().map(item => (<Menu.Item key={(item as any).title}>
|
||||
<Link href={(item as any).path} data-test="SettingsScreenItem">
|
||||
{(item as any).title}
|
||||
</Link>
|
||||
</Menu.Item>))}
|
||||
</Menu>
|
||||
<div className="p-15">
|
||||
<div>
|
||||
<WrappedComponent {...props}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>);
|
||||
};
|
||||
}
|
||||
export default wrapSettingsTab;
|
||||
@@ -1,56 +0,0 @@
|
||||
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,
|
||||
};
|
||||
47
client/app/components/TimeAgo.tsx
Normal file
47
client/app/components/TimeAgo.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
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,9 +1,16 @@
|
||||
import React, { useMemo, useState, useEffect } from "react";
|
||||
import moment from "moment";
|
||||
import PropTypes from "prop-types";
|
||||
// @ts-expect-error ts-migrate(6133) FIXME: 'Moment' is declared but its value is never read.
|
||||
import { Moment } from "@/components/proptypes";
|
||||
|
||||
export default function Timer({ from }) {
|
||||
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
|
||||
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 [value, setValue] = useState(null);
|
||||
|
||||
@@ -11,6 +18,7 @@ export default function Timer({ from }) {
|
||||
function update() {
|
||||
const diff = moment.now() - startTime;
|
||||
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));
|
||||
}
|
||||
update();
|
||||
@@ -22,10 +30,6 @@ export default function Timer({ from }) {
|
||||
return <span className="rd-timer">{value}</span>;
|
||||
}
|
||||
|
||||
Timer.propTypes = {
|
||||
from: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.instanceOf(Date), Moment]),
|
||||
};
|
||||
|
||||
Timer.defaultProps = {
|
||||
from: null,
|
||||
};
|
||||
@@ -1,12 +1,21 @@
|
||||
import { map } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Tag from "antd/lib/tag";
|
||||
import Link from "@/components/Link";
|
||||
|
||||
import "./UserGroups.less";
|
||||
|
||||
export default function UserGroups({ groups, linkGroups, ...props }) {
|
||||
type OwnProps = {
|
||||
groups?: {
|
||||
id: number | string;
|
||||
name?: string;
|
||||
}[];
|
||||
linkGroups?: boolean;
|
||||
};
|
||||
|
||||
type Props = OwnProps & typeof UserGroups.defaultProps;
|
||||
|
||||
export default function UserGroups({ groups, linkGroups, ...props }: Props) {
|
||||
return (
|
||||
<div className="user-groups" {...props}>
|
||||
{map(groups, group => (
|
||||
@@ -16,16 +25,6 @@ export default function UserGroups({ groups, linkGroups, ...props }) {
|
||||
);
|
||||
}
|
||||
|
||||
UserGroups.propTypes = {
|
||||
groups: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
|
||||
name: PropTypes.string,
|
||||
})
|
||||
),
|
||||
linkGroups: PropTypes.bool,
|
||||
};
|
||||
|
||||
UserGroups.defaultProps = {
|
||||
groups: [],
|
||||
linkGroups: true,
|
||||
@@ -1,12 +1,18 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Menu from "antd/lib/menu";
|
||||
import PageHeader from "@/components/PageHeader";
|
||||
import Link from "@/components/Link";
|
||||
|
||||
import "./layout.less";
|
||||
|
||||
export default function Layout({ activeTab, children }) {
|
||||
type OwnProps = {
|
||||
activeTab?: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
type Props = OwnProps & typeof Layout.defaultProps;
|
||||
|
||||
export default function Layout({ activeTab, children }: Props) {
|
||||
return (
|
||||
<div className="admin-page-layout">
|
||||
<div className="container">
|
||||
@@ -30,11 +36,6 @@ export default function Layout({ activeTab, children }) {
|
||||
);
|
||||
}
|
||||
|
||||
Layout.propTypes = {
|
||||
activeTab: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
Layout.defaultProps = {
|
||||
activeTab: "system_status",
|
||||
children: null,
|
||||
@@ -1,6 +1,5 @@
|
||||
import { map } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import Badge from "antd/lib/badge";
|
||||
import Card from "antd/lib/card";
|
||||
@@ -8,9 +7,17 @@ import Spin from "antd/lib/spin";
|
||||
import Table from "antd/lib/table";
|
||||
import { Columns } from "@/components/items-list/components/ItemsTable";
|
||||
|
||||
type OwnCounterCardProps = {
|
||||
title: string;
|
||||
value?: number | string;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
type CounterCardProps = OwnCounterCardProps & typeof CounterCard.defaultProps;
|
||||
|
||||
// CounterCard
|
||||
|
||||
export function CounterCard({ title, value, loading }) {
|
||||
export function CounterCard({ title, value, loading }: CounterCardProps) {
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<Card>
|
||||
@@ -21,12 +28,6 @@ export function CounterCard({ title, value, loading }) {
|
||||
);
|
||||
}
|
||||
|
||||
CounterCard.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
|
||||
loading: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
CounterCard.defaultProps = {
|
||||
value: "",
|
||||
};
|
||||
@@ -35,11 +36,11 @@ CounterCard.defaultProps = {
|
||||
|
||||
const queryJobsColumns = [
|
||||
{ title: "Queue", dataIndex: "origin" },
|
||||
{ title: "Query ID", dataIndex: "meta.query_id" },
|
||||
{ title: "Org ID", dataIndex: "meta.org_id" },
|
||||
{ title: "Data Source ID", dataIndex: "meta.data_source_id" },
|
||||
{ title: "User ID", dataIndex: "meta.user_id" },
|
||||
Columns.custom(scheduled => scheduled.toString(), { title: "Scheduled", dataIndex: "meta.scheduled" }),
|
||||
{ title: "Query ID", dataIndex: ["meta", "query_id"] },
|
||||
{ title: "Org ID", dataIndex: ["meta", "org_id"] },
|
||||
{ title: "Data Source ID", dataIndex: ["meta", "data_source_id"] },
|
||||
{ title: "User ID", dataIndex: ["meta", "user_id"] },
|
||||
Columns.custom((scheduled: any) => scheduled.toString(), { title: "Scheduled", dataIndex: ["meta", "scheduled"] }),
|
||||
Columns.timeAgo({ title: "Start Time", dataIndex: "started_at" }),
|
||||
Columns.timeAgo({ title: "Enqueue Time", dataIndex: "enqueued_at" }),
|
||||
];
|
||||
@@ -53,12 +54,11 @@ const otherJobsColumns = [
|
||||
|
||||
const workersColumns = [
|
||||
Columns.custom(
|
||||
value => (
|
||||
<span>
|
||||
<Badge status={{ busy: "processing", idle: "default", started: "success", suspended: "warning" }[value]} />{" "}
|
||||
{value}
|
||||
</span>
|
||||
),
|
||||
(value: any) => <span>
|
||||
{/* @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message */}
|
||||
<Badge status={{ busy: "processing", idle: "default", started: "success", suspended: "warning" }[value]} />{" "}
|
||||
{value}
|
||||
</span>,
|
||||
{ title: "State", dataIndex: "state" }
|
||||
),
|
||||
]
|
||||
@@ -75,12 +75,27 @@ const workersColumns = [
|
||||
|
||||
const queuesColumns = map(["Name", "Started", "Queued"], c => ({ title: c, dataIndex: c.toLowerCase() }));
|
||||
|
||||
const TablePropTypes = {
|
||||
loading: PropTypes.bool.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
type WorkersTableProps = {
|
||||
loading: boolean;
|
||||
items: any[];
|
||||
};
|
||||
|
||||
export function WorkersTable({ loading, items }) {
|
||||
type QueuesTableProps = {
|
||||
loading: boolean;
|
||||
items: any[];
|
||||
};
|
||||
|
||||
type QueryJobsTableProps = {
|
||||
loading: boolean;
|
||||
items: any[];
|
||||
};
|
||||
|
||||
type OtherJobsTableProps = {
|
||||
loading: boolean;
|
||||
items: any[];
|
||||
};
|
||||
|
||||
export function WorkersTable({ loading, items }: WorkersTableProps) {
|
||||
return (
|
||||
<Table
|
||||
loading={loading}
|
||||
@@ -96,9 +111,7 @@ export function WorkersTable({ loading, items }) {
|
||||
);
|
||||
}
|
||||
|
||||
WorkersTable.propTypes = TablePropTypes;
|
||||
|
||||
export function QueuesTable({ loading, items }) {
|
||||
export function QueuesTable({ loading, items }: QueuesTableProps) {
|
||||
return (
|
||||
<Table
|
||||
loading={loading}
|
||||
@@ -114,9 +127,7 @@ export function QueuesTable({ loading, items }) {
|
||||
);
|
||||
}
|
||||
|
||||
QueuesTable.propTypes = TablePropTypes;
|
||||
|
||||
export function QueryJobsTable({ loading, items }) {
|
||||
export function QueryJobsTable({ loading, items }: QueryJobsTableProps) {
|
||||
return (
|
||||
<Table
|
||||
loading={loading}
|
||||
@@ -132,9 +143,7 @@ export function QueryJobsTable({ loading, items }) {
|
||||
);
|
||||
}
|
||||
|
||||
QueryJobsTable.propTypes = TablePropTypes;
|
||||
|
||||
export function OtherJobsTable({ loading, items }) {
|
||||
export function OtherJobsTable({ loading, items }: OtherJobsTableProps) {
|
||||
return (
|
||||
<Table
|
||||
loading={loading}
|
||||
@@ -149,5 +158,3 @@ export function OtherJobsTable({ loading, items }) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
OtherJobsTable.propTypes = TablePropTypes;
|
||||
@@ -9,7 +9,9 @@ import TimeAgo from "@/components/TimeAgo";
|
||||
|
||||
import { toHuman, prettySize } from "@/lib/utils";
|
||||
|
||||
export function General({ info }) {
|
||||
export function General({
|
||||
info
|
||||
}: any) {
|
||||
info = toPairs(info);
|
||||
return (
|
||||
<Card title="General" size="small">
|
||||
@@ -19,6 +21,7 @@ export function General({ info }) {
|
||||
size="small"
|
||||
itemLayout="vertical"
|
||||
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]) => (
|
||||
<List.Item extra={<span className="badge">{value}</span>}>{toHuman(name)}</List.Item>
|
||||
)}
|
||||
@@ -28,7 +31,9 @@ export function General({ info }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function DatabaseMetrics({ info }) {
|
||||
export function DatabaseMetrics({
|
||||
info
|
||||
}: any) {
|
||||
return (
|
||||
<Card title="Redash Database" size="small">
|
||||
{info.length === 0 && <div className="text-muted text-center">No data</div>}
|
||||
@@ -37,6 +42,7 @@ export function DatabaseMetrics({ info }) {
|
||||
size="small"
|
||||
itemLayout="vertical"
|
||||
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]) => (
|
||||
<List.Item extra={<span className="badge">{prettySize(size)}</span>}>{name}</List.Item>
|
||||
)}
|
||||
@@ -46,7 +52,9 @@ export function DatabaseMetrics({ info }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function Queues({ info }) {
|
||||
export function Queues({
|
||||
info
|
||||
}: any) {
|
||||
info = toPairs(info);
|
||||
return (
|
||||
<Card title="Queues" size="small">
|
||||
@@ -56,6 +64,7 @@ export function Queues({ info }) {
|
||||
size="small"
|
||||
itemLayout="vertical"
|
||||
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]) => (
|
||||
<List.Item extra={<span className="badge">{queue.size}</span>}>{name}</List.Item>
|
||||
)}
|
||||
@@ -65,7 +74,9 @@ export function Queues({ info }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function Manager({ info }) {
|
||||
export function Manager({
|
||||
info
|
||||
}: any) {
|
||||
const items = info
|
||||
? [
|
||||
<List.Item
|
||||
@@ -1,156 +0,0 @@
|
||||
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);
|
||||
112
client/app/components/dashboards/AddWidgetDialog.tsx
Normal file
112
client/app/components/dashboards/AddWidgetDialog.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
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