mirror of
https://github.com/getredash/redash.git
synced 2025-12-20 09:57:35 -05:00
Compare commits
9 Commits
query-base
...
ts-migrate
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc50415687 | ||
|
|
8817113f4a | ||
|
|
6ce2896b0b | ||
|
|
fdf636a393 | ||
|
|
88c13868a3 | ||
|
|
aab11dc79b | ||
|
|
00c77cf36e | ||
|
|
6e2631dec2 | ||
|
|
4b88959341 |
@@ -1,10 +1,10 @@
|
|||||||
import { first } from "lodash";
|
import React, { useMemo } from "react";
|
||||||
import React, { useState } from "react";
|
import { first, includes } from "lodash";
|
||||||
import Button from "antd/lib/button";
|
|
||||||
import Menu from "antd/lib/menu";
|
import Menu from "antd/lib/menu";
|
||||||
import Link from "@/components/Link";
|
import Link from "@/components/Link";
|
||||||
import HelpTrigger from "@/components/HelpTrigger";
|
import HelpTrigger from "@/components/HelpTrigger";
|
||||||
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
|
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
|
||||||
|
import { useCurrentRoute } from "@/components/ApplicationArea/Router";
|
||||||
import { Auth, currentUser } from "@/services/auth";
|
import { Auth, currentUser } from "@/services/auth";
|
||||||
import settingsMenu from "@/services/settingsMenu";
|
import settingsMenu from "@/services/settingsMenu";
|
||||||
import logoUrl from "@/assets/images/redash_icon_small.png";
|
import logoUrl from "@/assets/images/redash_icon_small.png";
|
||||||
@@ -15,37 +15,58 @@ import AlertOutlinedIcon from "@ant-design/icons/AlertOutlined";
|
|||||||
import PlusOutlinedIcon from "@ant-design/icons/PlusOutlined";
|
import PlusOutlinedIcon from "@ant-design/icons/PlusOutlined";
|
||||||
import QuestionCircleOutlinedIcon from "@ant-design/icons/QuestionCircleOutlined";
|
import QuestionCircleOutlinedIcon from "@ant-design/icons/QuestionCircleOutlined";
|
||||||
import SettingOutlinedIcon from "@ant-design/icons/SettingOutlined";
|
import SettingOutlinedIcon from "@ant-design/icons/SettingOutlined";
|
||||||
import MenuUnfoldOutlinedIcon from "@ant-design/icons/MenuUnfoldOutlined";
|
|
||||||
import MenuFoldOutlinedIcon from "@ant-design/icons/MenuFoldOutlined";
|
|
||||||
|
|
||||||
import VersionInfo from "./VersionInfo";
|
import VersionInfo from "./VersionInfo";
|
||||||
import "./DesktopNavbar.less";
|
import "./DesktopNavbar.less";
|
||||||
|
|
||||||
function NavbarSection({ inlineCollapsed, children, ...props }) {
|
function NavbarSection({ children, ...props }) {
|
||||||
return (
|
return (
|
||||||
<Menu
|
<Menu selectable={false} mode="vertical" theme="dark" {...props}>
|
||||||
selectable={false}
|
|
||||||
mode={inlineCollapsed ? "inline" : "vertical"}
|
|
||||||
inlineCollapsed={inlineCollapsed}
|
|
||||||
theme="dark"
|
|
||||||
{...props}>
|
|
||||||
{children}
|
{children}
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DesktopNavbar() {
|
function useNavbarActiveState() {
|
||||||
const [collapsed, setCollapsed] = useState(true);
|
const currentRoute = useCurrentRoute();
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
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",
|
||||||
|
],
|
||||||
|
currentRoute.id
|
||||||
|
),
|
||||||
|
dataSources: includes(["DataSources.List"], currentRoute.id),
|
||||||
|
alerts: includes(["Alerts.List", "Alerts.New", "Alerts.View", "Alerts.Edit"], currentRoute.id),
|
||||||
|
}),
|
||||||
|
[currentRoute.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DesktopNavbar() {
|
||||||
const firstSettingsTab = first(settingsMenu.getAvailableItems());
|
const firstSettingsTab = first(settingsMenu.getAvailableItems());
|
||||||
|
|
||||||
|
const activeState = useNavbarActiveState();
|
||||||
|
|
||||||
const canCreateQuery = currentUser.hasPermission("create_query");
|
const canCreateQuery = currentUser.hasPermission("create_query");
|
||||||
const canCreateDashboard = currentUser.hasPermission("create_dashboard");
|
const canCreateDashboard = currentUser.hasPermission("create_dashboard");
|
||||||
const canCreateAlert = currentUser.hasPermission("list_alerts");
|
const canCreateAlert = currentUser.hasPermission("list_alerts");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="desktop-navbar">
|
<div className="desktop-navbar">
|
||||||
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-logo">
|
<NavbarSection className="desktop-navbar-logo">
|
||||||
<div>
|
<div>
|
||||||
<Link href="./">
|
<Link href="./">
|
||||||
<img src={logoUrl} alt="Redash" />
|
<img src={logoUrl} alt="Redash" />
|
||||||
@@ -53,45 +74,43 @@ export default function DesktopNavbar() {
|
|||||||
</div>
|
</div>
|
||||||
</NavbarSection>
|
</NavbarSection>
|
||||||
|
|
||||||
<NavbarSection inlineCollapsed={collapsed}>
|
<NavbarSection>
|
||||||
{currentUser.hasPermission("list_dashboards") && (
|
{currentUser.hasPermission("list_dashboards") && (
|
||||||
<Menu.Item key="dashboards">
|
<Menu.Item key="dashboards" className={activeState.dashboards ? "navbar-active-item" : null}>
|
||||||
<Link href="dashboards">
|
<Link href="dashboards">
|
||||||
<DesktopOutlinedIcon />
|
<DesktopOutlinedIcon />
|
||||||
<span>Dashboards</span>
|
<span className="desktop-navbar-label">Dashboards</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
{currentUser.hasPermission("view_query") && (
|
{currentUser.hasPermission("view_query") && (
|
||||||
<Menu.Item key="queries">
|
<Menu.Item key="queries" className={activeState.queries ? "navbar-active-item" : null}>
|
||||||
<Link href="queries">
|
<Link href="queries">
|
||||||
<CodeOutlinedIcon />
|
<CodeOutlinedIcon />
|
||||||
<span>Queries</span>
|
<span className="desktop-navbar-label">Queries</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
{currentUser.hasPermission("list_alerts") && (
|
{currentUser.hasPermission("list_alerts") && (
|
||||||
<Menu.Item key="alerts">
|
<Menu.Item key="alerts" className={activeState.alerts ? "navbar-active-item" : null}>
|
||||||
<Link href="alerts">
|
<Link href="alerts">
|
||||||
<AlertOutlinedIcon />
|
<AlertOutlinedIcon />
|
||||||
<span>Alerts</span>
|
<span className="desktop-navbar-label">Alerts</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
</NavbarSection>
|
</NavbarSection>
|
||||||
|
|
||||||
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-spacer">
|
<NavbarSection className="desktop-navbar-spacer">
|
||||||
{(canCreateQuery || canCreateDashboard || canCreateAlert) && <Menu.Divider />}
|
|
||||||
{(canCreateQuery || canCreateDashboard || canCreateAlert) && (
|
{(canCreateQuery || canCreateDashboard || canCreateAlert) && (
|
||||||
<Menu.SubMenu
|
<Menu.SubMenu
|
||||||
key="create"
|
key="create"
|
||||||
popupClassName="desktop-navbar-submenu"
|
popupClassName="desktop-navbar-submenu"
|
||||||
|
data-test="CreateButton"
|
||||||
title={
|
title={
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<span data-test="CreateButton">
|
<PlusOutlinedIcon />
|
||||||
<PlusOutlinedIcon />
|
<span className="desktop-navbar-label">Create</span>
|
||||||
<span>Create</span>
|
|
||||||
</span>
|
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
}>
|
}>
|
||||||
{canCreateQuery && (
|
{canCreateQuery && (
|
||||||
@@ -119,32 +138,30 @@ export default function DesktopNavbar() {
|
|||||||
)}
|
)}
|
||||||
</NavbarSection>
|
</NavbarSection>
|
||||||
|
|
||||||
<NavbarSection inlineCollapsed={collapsed}>
|
<NavbarSection>
|
||||||
<Menu.Item key="help">
|
<Menu.Item key="help">
|
||||||
<HelpTrigger showTooltip={false} type="HOME">
|
<HelpTrigger showTooltip={false} type="HOME">
|
||||||
<QuestionCircleOutlinedIcon />
|
<QuestionCircleOutlinedIcon />
|
||||||
<span>Help</span>
|
<span className="desktop-navbar-label">Help</span>
|
||||||
</HelpTrigger>
|
</HelpTrigger>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
{firstSettingsTab && (
|
{firstSettingsTab && (
|
||||||
<Menu.Item key="settings">
|
<Menu.Item key="settings" className={activeState.dataSources ? "navbar-active-item" : null}>
|
||||||
<Link href={firstSettingsTab.path} data-test="SettingsLink">
|
<Link href={firstSettingsTab.path} data-test="SettingsLink">
|
||||||
<SettingOutlinedIcon />
|
<SettingOutlinedIcon />
|
||||||
<span>Settings</span>
|
<span className="desktop-navbar-label">Settings</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
<Menu.Divider />
|
|
||||||
</NavbarSection>
|
</NavbarSection>
|
||||||
|
|
||||||
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-profile-menu">
|
<NavbarSection className="desktop-navbar-profile-menu">
|
||||||
<Menu.SubMenu
|
<Menu.SubMenu
|
||||||
key="profile"
|
key="profile"
|
||||||
popupClassName="desktop-navbar-submenu"
|
popupClassName="desktop-navbar-submenu"
|
||||||
title={
|
title={
|
||||||
<span data-test="ProfileDropdown" className="desktop-navbar-profile-menu-title">
|
<span data-test="ProfileDropdown" className="desktop-navbar-profile-menu-title">
|
||||||
<img className="profile__image_thumb" src={currentUser.profile_image_url} alt={currentUser.name} />
|
<img className="profile__image_thumb" src={currentUser.profile_image_url} alt={currentUser.name} />
|
||||||
<span>{currentUser.name}</span>
|
|
||||||
</span>
|
</span>
|
||||||
}>
|
}>
|
||||||
<Menu.Item key="profile">
|
<Menu.Item key="profile">
|
||||||
@@ -167,10 +184,6 @@ export default function DesktopNavbar() {
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.SubMenu>
|
</Menu.SubMenu>
|
||||||
</NavbarSection>
|
</NavbarSection>
|
||||||
|
|
||||||
<Button onClick={() => setCollapsed(!collapsed)} className="desktop-navbar-collapse-button">
|
|
||||||
{collapsed ? <MenuUnfoldOutlinedIcon /> : <MenuFoldOutlinedIcon />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
@backgroundColor: #001529;
|
@backgroundColor: #001529;
|
||||||
@dividerColor: rgba(255, 255, 255, 0.5);
|
@dividerColor: rgba(255, 255, 255, 0.5);
|
||||||
@textColor: rgba(255, 255, 255, 0.75);
|
@textColor: rgba(255, 255, 255, 0.75);
|
||||||
|
@brandColor: #ff7964; // Redash logo color
|
||||||
|
@activeItemColor: @brandColor;
|
||||||
|
@iconSize: 26px;
|
||||||
|
|
||||||
.desktop-navbar {
|
.desktop-navbar {
|
||||||
background: @backgroundColor;
|
background: @backgroundColor;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
width: 80px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
&-spacer {
|
&-spacer {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
@@ -21,12 +26,6 @@
|
|||||||
height: 40px;
|
height: 40px;
|
||||||
transition: all 270ms;
|
transition: all 270ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.ant-menu-inline-collapsed {
|
|
||||||
img {
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-trigger {
|
.help-trigger {
|
||||||
@@ -34,26 +33,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ant-menu {
|
.ant-menu {
|
||||||
&:not(.ant-menu-inline-collapsed) {
|
|
||||||
width: 170px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.ant-menu-inline-collapsed > .ant-menu-submenu-title span img + span,
|
|
||||||
&.ant-menu-inline-collapsed > .ant-menu-item i + span {
|
|
||||||
display: inline-block;
|
|
||||||
max-width: 0;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-menu-item-divider {
|
|
||||||
background: @dividerColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-menu-item,
|
.ant-menu-item,
|
||||||
.ant-menu-submenu {
|
.ant-menu-submenu {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: @textColor;
|
color: @textColor;
|
||||||
|
|
||||||
|
&.navbar-active-item {
|
||||||
|
box-shadow: inset 3px 0 0 @activeItemColor;
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
color: @activeItemColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.ant-menu-submenu-open,
|
&.ant-menu-submenu-open,
|
||||||
&.ant-menu-submenu-active,
|
&.ant-menu-submenu-active,
|
||||||
&:hover,
|
&:hover,
|
||||||
@@ -61,6 +53,16 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
font-size: @iconSize;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-navbar-label {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
a,
|
a,
|
||||||
span,
|
span,
|
||||||
.anticon {
|
.anticon {
|
||||||
@@ -71,21 +73,33 @@
|
|||||||
.ant-menu-submenu-arrow {
|
.ant-menu-submenu-arrow {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.ant-btn.desktop-navbar-collapse-button {
|
.ant-menu-item,
|
||||||
background-color: @backgroundColor;
|
.ant-menu-submenu {
|
||||||
border: 0;
|
padding: 0;
|
||||||
border-radius: 0;
|
height: 60px;
|
||||||
color: @textColor;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
&:hover,
|
flex-direction: column;
|
||||||
&:active {
|
justify-content: center;
|
||||||
color: #fff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:after {
|
.ant-menu-submenu-title {
|
||||||
animation: 0s !important;
|
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 {
|
.profile__image_thumb {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
width: @iconSize;
|
||||||
|
height: @iconSize;
|
||||||
.profile__image_thumb + span {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
|
|
||||||
margin-left: 10px;
|
|
||||||
vertical-align: middle;
|
|
||||||
display: inline-block;
|
|
||||||
|
|
||||||
// styles from Antd
|
|
||||||
opacity: 1;
|
|
||||||
transition: opacity 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
|
|
||||||
margin-left 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), width 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.ant-menu-inline-collapsed {
|
|
||||||
.ant-menu-submenu-title {
|
|
||||||
padding-left: 16px !important;
|
|
||||||
padding-right: 16px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desktop-navbar-profile-menu-title {
|
|
||||||
.profile__image_thumb + span {
|
|
||||||
opacity: 0;
|
|
||||||
max-width: 0;
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { includes, words, capitalize, clone, isNull, map, get, find } from "lodash";
|
import { includes, words, capitalize, clone, isNull } from "lodash";
|
||||||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import Checkbox from "antd/lib/checkbox";
|
import Checkbox from "antd/lib/checkbox";
|
||||||
import Modal from "antd/lib/modal";
|
import Modal from "antd/lib/modal";
|
||||||
@@ -11,8 +11,6 @@ import Divider from "antd/lib/divider";
|
|||||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||||
import QuerySelector from "@/components/QuerySelector";
|
import QuerySelector from "@/components/QuerySelector";
|
||||||
import { Query } from "@/services/query";
|
import { Query } from "@/services/query";
|
||||||
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
|
|
||||||
import QueryBasedParameterMappingTable from "./query-based-parameter/QueryBasedParameterMappingTable";
|
|
||||||
|
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
|
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
|
||||||
@@ -71,27 +69,17 @@ NameInput.propTypes = {
|
|||||||
function EditParameterSettingsDialog(props) {
|
function EditParameterSettingsDialog(props) {
|
||||||
const [param, setParam] = useState(clone(props.parameter));
|
const [param, setParam] = useState(clone(props.parameter));
|
||||||
const [isNameValid, setIsNameValid] = useState(true);
|
const [isNameValid, setIsNameValid] = useState(true);
|
||||||
const [paramQuery, setParamQuery] = useState();
|
const [initialQuery, setInitialQuery] = useState();
|
||||||
const mappingParameters = useMemo(
|
|
||||||
() =>
|
|
||||||
map(paramQuery && paramQuery.getParametersDefs(), mappingParam => ({
|
|
||||||
mappingParam,
|
|
||||||
existingMapping: get(param.parameterMapping, mappingParam.name, {
|
|
||||||
mappingType: QueryBasedParameterMappingType.UNDEFINED,
|
|
||||||
}),
|
|
||||||
})),
|
|
||||||
[param.parameterMapping, paramQuery]
|
|
||||||
);
|
|
||||||
|
|
||||||
const isNew = !props.parameter.name;
|
const isNew = !props.parameter.name;
|
||||||
|
|
||||||
// fetch query by id
|
// fetch query by id
|
||||||
const initialQueryId = useRef(props.parameter.queryId);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialQueryId.current) {
|
const queryId = props.parameter.queryId;
|
||||||
Query.get({ id: initialQueryId.current }).then(setParamQuery);
|
if (queryId) {
|
||||||
|
Query.get({ id: queryId }).then(setInitialQuery);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [props.parameter.queryId]);
|
||||||
|
|
||||||
function isFulfilled() {
|
function isFulfilled() {
|
||||||
// name
|
// name
|
||||||
@@ -105,14 +93,8 @@ function EditParameterSettingsDialog(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// query
|
// query
|
||||||
if (param.type === "query") {
|
if (param.type === "query" && !param.queryId) {
|
||||||
if (!param.queryId) {
|
return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (find(mappingParameters, { existingMapping: { mappingType: QueryBasedParameterMappingType.UNDEFINED } })) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -205,28 +187,14 @@ function EditParameterSettingsDialog(props) {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
{param.type === "query" && (
|
{param.type === "query" && (
|
||||||
<Form.Item label="Query" help="Select query to load dropdown values from" required {...formItemProps}>
|
<Form.Item label="Query" help="Select query to load dropdown values from" {...formItemProps}>
|
||||||
<QuerySelector
|
<QuerySelector
|
||||||
selectedQuery={paramQuery}
|
selectedQuery={initialQuery}
|
||||||
onChange={q => {
|
onChange={q => setParam({ ...param, queryId: q && q.id })}
|
||||||
if (q) {
|
|
||||||
setParamQuery(q);
|
|
||||||
setParam({ ...param, queryId: q.id, parameterMapping: {} });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
type="select"
|
type="select"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
{param.type === "query" && paramQuery && paramQuery.hasParameters() && (
|
|
||||||
<Form.Item className="m-t-15 m-b-5" label="Parameters" required {...formItemProps}>
|
|
||||||
<QueryBasedParameterMappingTable
|
|
||||||
param={param}
|
|
||||||
mappingParameters={mappingParameters}
|
|
||||||
onChangeParam={setParam}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
)}
|
|
||||||
{(param.type === "enum" || param.type === "query") && (
|
{(param.type === "enum" || param.type === "query") && (
|
||||||
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
|
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -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,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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,6 @@ import ParameterValueInput from "@/components/ParameterValueInput";
|
|||||||
import { ParameterMappingType } from "@/services/widget";
|
import { ParameterMappingType } from "@/services/widget";
|
||||||
import { Parameter, cloneParameter } from "@/services/parameters";
|
import { Parameter, cloneParameter } from "@/services/parameters";
|
||||||
import HelpTrigger from "@/components/HelpTrigger";
|
import HelpTrigger from "@/components/HelpTrigger";
|
||||||
import InputPopover from "@/components/InputPopover";
|
|
||||||
|
|
||||||
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
|
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
|
||||||
import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
|
import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
|
||||||
@@ -314,34 +313,43 @@ class MappingEditor extends React.Component {
|
|||||||
this.setState({ visible: false });
|
this.setState({ visible: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
renderContent() {
|
||||||
const { visible, mapping, inputError } = this.state;
|
const { mapping, inputError } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InputPopover
|
<div className="parameter-mapping-editor" data-test="EditParamMappingPopover">
|
||||||
|
<header>
|
||||||
|
Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" />
|
||||||
|
</header>
|
||||||
|
<ParameterMappingInput
|
||||||
|
mapping={mapping}
|
||||||
|
existingParamNames={this.props.existingParamNames}
|
||||||
|
onChange={this.onChange}
|
||||||
|
inputError={inputError}
|
||||||
|
/>
|
||||||
|
<footer>
|
||||||
|
<Button onClick={this.hide}>Cancel</Button>
|
||||||
|
<Button onClick={this.save} disabled={!!inputError} type="primary">
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { visible, mapping } = this.state;
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
placement="left"
|
placement="left"
|
||||||
trigger="click"
|
trigger="click"
|
||||||
header={
|
content={this.renderContent()}
|
||||||
<>
|
|
||||||
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}
|
visible={visible}
|
||||||
onVisibleChange={this.onVisibleChange}>
|
onVisibleChange={this.onVisibleChange}>
|
||||||
<Button size="small" type="dashed" data-test={`EditParamMappingButton-${mapping.param.name}`}>
|
<Button size="small" type="dashed" data-test={`EditParamMappingButton-${mapping.param.name}`}>
|
||||||
<EditOutlinedIcon />
|
<EditOutlinedIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</InputPopover>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "~antd/lib/modal/style/index"; // for ant @vars
|
@import '~antd/lib/modal/style/index'; // for ant @vars
|
||||||
|
|
||||||
.parameters-mapping-list {
|
.parameters-mapping-list {
|
||||||
.keyword {
|
.keyword {
|
||||||
@@ -22,13 +22,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.parameter-mapping-editor {
|
||||||
|
width: 390px;
|
||||||
|
|
||||||
|
.radio {
|
||||||
|
display: block;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-item {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
padding: 0 16px 10px;
|
||||||
|
margin: 0 -16px 20px;
|
||||||
|
border-bottom: @border-width-base @border-style-base @border-color-split;
|
||||||
|
font-size: @font-size-lg;
|
||||||
|
font-weight: 500;
|
||||||
|
color: @heading-color;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
border-top: @border-width-base @border-style-base @border-color-split;
|
||||||
|
padding: 10px 16px 0;
|
||||||
|
margin: 0 -16px;
|
||||||
|
text-align: right;
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.parameter-mapping-title {
|
.parameter-mapping-title {
|
||||||
.text {
|
.text {
|
||||||
margin-right: 3px;
|
margin-right: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.disabled,
|
&.disabled, .fa {
|
||||||
.fa {
|
|
||||||
color: #a4a4a4;
|
color: #a4a4a4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
.@{ant-prefix}-input-number,
|
.@{ant-prefix}-input-number,
|
||||||
.@{ant-prefix}-select-selector,
|
.@{ant-prefix}-select-selector,
|
||||||
.@{ant-prefix}-picker {
|
.@{ant-prefix}-picker {
|
||||||
background-color: @input-dirty !important;
|
background-color: @input-dirty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Parameter, createParameter } from "@/services/parameters";
|
|||||||
import ParameterApplyButton from "@/components/ParameterApplyButton";
|
import ParameterApplyButton from "@/components/ParameterApplyButton";
|
||||||
import ParameterValueInput from "@/components/ParameterValueInput";
|
import ParameterValueInput from "@/components/ParameterValueInput";
|
||||||
import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
|
import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
|
||||||
|
import { toHuman } from "@/lib/utils";
|
||||||
|
|
||||||
import "./Parameters.less";
|
import "./Parameters.less";
|
||||||
|
|
||||||
@@ -120,7 +121,7 @@ export default class Parameters extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<div key={param.name} className="di-block" data-test={`ParameterName-${param.name}`}>
|
<div key={param.name} className="di-block" data-test={`ParameterName-${param.name}`}>
|
||||||
<div className="parameter-heading">
|
<div className="parameter-heading">
|
||||||
<label>{param.getTitle()}</label>
|
<label>{param.title || toHuman(param.name)}</label>
|
||||||
{editable && (
|
{editable && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-default btn-xs m-l-5"
|
className="btn btn-default btn-xs m-l-5"
|
||||||
|
|||||||
@@ -1,19 +1,8 @@
|
|||||||
import { find, isArray, get, first, map, intersection, isEqual, isEmpty, trim, debounce, isNil } from "lodash";
|
import { find, isArray, get, first, map, intersection, isEqual, isEmpty } from "lodash";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll";
|
import 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 {
|
export default class QueryBasedParameterInput extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||||
@@ -39,7 +28,6 @@ export default class QueryBasedParameterInput extends React.Component {
|
|||||||
options: [],
|
options: [],
|
||||||
value: null,
|
value: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
currentSearchTerm: null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,10 +36,9 @@ export default class QueryBasedParameterInput extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
if (this.props.queryId !== prevProps.queryId || this.props.parameter !== prevProps.parameter) {
|
if (this.props.queryId !== prevProps.queryId) {
|
||||||
this._loadOptions(this.props.queryId);
|
this._loadOptions(this.props.queryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.props.value !== prevProps.value) {
|
if (this.props.value !== prevProps.value) {
|
||||||
this.setValue(this.props.value);
|
this.setValue(this.props.value);
|
||||||
}
|
}
|
||||||
@@ -59,85 +46,54 @@ export default class QueryBasedParameterInput extends React.Component {
|
|||||||
|
|
||||||
setValue(value) {
|
setValue(value) {
|
||||||
const { options } = this.state;
|
const { options } = this.state;
|
||||||
const { mode, parameter } = this.props;
|
if (this.props.mode === "multiple") {
|
||||||
|
|
||||||
if (mode === "multiple") {
|
|
||||||
if (isNil(value)) {
|
|
||||||
value = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
value = isArray(value) ? value : [value];
|
value = isArray(value) ? value : [value];
|
||||||
|
const optionValues = map(options, option => option.value);
|
||||||
|
const validValues = intersection(value, optionValues);
|
||||||
|
this.setState({ value: validValues });
|
||||||
|
return validValues;
|
||||||
}
|
}
|
||||||
|
const found = find(options, option => option.value === this.props.value) !== undefined;
|
||||||
// parameters with search don't have options available, so we trust what we get
|
value = found ? value : get(first(options), "value");
|
||||||
if (!parameter.searchFunction) {
|
|
||||||
value = filterValuesThatAreNotInOptions(value, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({ value });
|
this.setState({ value });
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateOptions(options) {
|
|
||||||
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) {
|
async _loadOptions(queryId) {
|
||||||
if (queryId && queryId !== this.state.queryId) {
|
if (queryId && queryId !== this.state.queryId) {
|
||||||
this.setState({ loading: true });
|
this.setState({ loading: true });
|
||||||
const options = await this.props.parameter.loadDropdownValues(this.state.currentSearchTerm);
|
const options = await this.props.parameter.loadDropdownValues();
|
||||||
|
|
||||||
// stale queryId check
|
// stale queryId check
|
||||||
if (this.props.queryId === queryId) {
|
if (this.props.queryId === queryId) {
|
||||||
this.updateOptions(options);
|
this.setState({ options, loading: false }, () => {
|
||||||
|
const updatedValue = this.setValue(this.props.value);
|
||||||
|
if (!isEqual(updatedValue, this.props.value)) {
|
||||||
|
this.props.onSelect(updatedValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
searchFunction = debounce(searchTerm => {
|
|
||||||
const { parameter } = this.props;
|
|
||||||
if (parameter.searchFunction && trim(searchTerm)) {
|
|
||||||
this.setState({ loading: true, currentSearchTerm: searchTerm });
|
|
||||||
parameter.searchFunction(searchTerm).then(options => {
|
|
||||||
if (this.state.currentSearchTerm === searchTerm) {
|
|
||||||
this.updateOptions(options);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, SEARCH_DEBOUNCE_TIME);
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { parameter, className, mode, onSelect, queryId, value, ...otherProps } = this.props;
|
const { className, mode, onSelect, queryId, value, ...otherProps } = this.props;
|
||||||
const { loading, options } = this.state;
|
const { loading, options } = this.state;
|
||||||
const selectProps = { ...otherProps };
|
|
||||||
|
|
||||||
if (parameter.searchColumn) {
|
|
||||||
selectProps.filterOption = false;
|
|
||||||
selectProps.onSearch = this.searchFunction;
|
|
||||||
selectProps.onChange = value => onSelect(parameter.normalizeValue(value));
|
|
||||||
selectProps.notFoundContent = null;
|
|
||||||
selectProps.labelInValue = true;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
<SelectWithVirtualScroll
|
<SelectWithVirtualScroll
|
||||||
className={className}
|
className={className}
|
||||||
disabled={!parameter.searchFunction && loading}
|
disabled={loading}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
value={this.state.value || undefined}
|
value={this.state.value}
|
||||||
onChange={onSelect}
|
onChange={onSelect}
|
||||||
options={options}
|
options={map(options, ({ value, name }) => ({ label: String(name), value }))}
|
||||||
optionFilterProp="children"
|
optionFilterProp="children"
|
||||||
showSearch
|
showSearch
|
||||||
showArrow
|
showArrow
|
||||||
notFoundContent={isEmpty(options) ? "No options available" : null}
|
notFoundContent={isEmpty(options) ? "No options available" : null}
|
||||||
{...selectProps}
|
{...otherProps}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function MenuButton({ doDelete, canEdit, mute, unmute, muted }) {
|
|||||||
)}
|
)}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
<a onClick={confirmDelete}>Delete Alert</a>
|
<a onClick={confirmDelete}>Delete</a>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu>
|
</Menu>
|
||||||
}>
|
}>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function QuerySourceDropdown(props) {
|
|||||||
|
|
||||||
QuerySourceDropdown.propTypes = {
|
QuerySourceDropdown.propTypes = {
|
||||||
dataSources: PropTypes.any,
|
dataSources: PropTypes.any,
|
||||||
value: PropTypes.string,
|
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||||
disabled: PropTypes.bool,
|
disabled: PropTypes.bool,
|
||||||
loading: PropTypes.bool,
|
loading: PropTypes.bool,
|
||||||
onChange: PropTypes.func,
|
onChange: PropTypes.func,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function QuerySourceDropdownItem({ dataSource, children }) {
|
|||||||
QuerySourceDropdownItem.propTypes = {
|
QuerySourceDropdownItem.propTypes = {
|
||||||
dataSource: PropTypes.shape({
|
dataSource: PropTypes.shape({
|
||||||
name: PropTypes.string,
|
name: PropTypes.string,
|
||||||
id: PropTypes.string,
|
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||||
type: PropTypes.string,
|
type: PropTypes.string,
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
children: PropTypes.element,
|
children: PropTypes.element,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { isNull, isObject, isFunction, isUndefined, isEqual, has, omit, isArray, each } from "lodash";
|
import { isNull, isObject, isFunction, isUndefined, isEqual, has, omit, isArray, each } from "lodash";
|
||||||
import { toHuman } from "@/lib/utils";
|
|
||||||
|
|
||||||
class Parameter {
|
class Parameter {
|
||||||
constructor(parameter, parentQueryId) {
|
constructor(parameter, parentQueryId) {
|
||||||
@@ -45,10 +44,6 @@ class Parameter {
|
|||||||
return this.$$value;
|
return this.$$value;
|
||||||
}
|
}
|
||||||
|
|
||||||
getTitle() {
|
|
||||||
return this.title || toHuman(this.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
isEmptyValue(value) {
|
isEmptyValue(value) {
|
||||||
return isNull(this.normalizeValue(value));
|
return isNull(this.normalizeValue(value));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +1,15 @@
|
|||||||
import {
|
import { isNull, isUndefined, isArray, isEmpty, get, map, join, has } from "lodash";
|
||||||
isNull,
|
|
||||||
isUndefined,
|
|
||||||
isArray,
|
|
||||||
isEmpty,
|
|
||||||
get,
|
|
||||||
map,
|
|
||||||
join,
|
|
||||||
has,
|
|
||||||
toString,
|
|
||||||
findKey,
|
|
||||||
mapValues,
|
|
||||||
pickBy,
|
|
||||||
filter,
|
|
||||||
omit,
|
|
||||||
} from "lodash";
|
|
||||||
import { Query } from "@/services/query";
|
import { Query } from "@/services/query";
|
||||||
import QueryResult from "@/services/query-result";
|
|
||||||
import Parameter from "./Parameter";
|
import Parameter from "./Parameter";
|
||||||
|
|
||||||
function mapQueryResultToDropdownOptions(options) {
|
|
||||||
return map(options, ({ label, name, value }) => ({ label: label || name, value: toString(value) }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const QueryBasedParameterMappingType = {
|
|
||||||
DROPDOWN_SEARCH: "search",
|
|
||||||
STATIC: "static",
|
|
||||||
UNDEFINED: "undefined",
|
|
||||||
};
|
|
||||||
|
|
||||||
function extractOptionLabelsFromValues(values) {
|
|
||||||
if (!isArray(values)) {
|
|
||||||
values = [values];
|
|
||||||
}
|
|
||||||
|
|
||||||
const optionLabels = {};
|
|
||||||
values.forEach(val => {
|
|
||||||
if (has(val, "label") && has(val, "value")) {
|
|
||||||
optionLabels[val.value] = val.label;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return optionLabels;
|
|
||||||
}
|
|
||||||
|
|
||||||
class QueryBasedDropdownParameter extends Parameter {
|
class QueryBasedDropdownParameter extends Parameter {
|
||||||
constructor(parameter, parentQueryId) {
|
constructor(parameter, parentQueryId) {
|
||||||
super(parameter, parentQueryId);
|
super(parameter, parentQueryId);
|
||||||
this.queryId = parameter.queryId;
|
this.queryId = parameter.queryId;
|
||||||
this.multiValuesOptions = parameter.multiValuesOptions;
|
this.multiValuesOptions = parameter.multiValuesOptions;
|
||||||
this.parameterMapping = parameter.parameterMapping;
|
|
||||||
this.$$optionLabels = extractOptionLabelsFromValues(parameter.value);
|
|
||||||
this.setValue(parameter.value);
|
this.setValue(parameter.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
get searchColumn() {
|
|
||||||
return findKey(this.parameterMapping, { mappingType: QueryBasedParameterMappingType.DROPDOWN_SEARCH });
|
|
||||||
}
|
|
||||||
|
|
||||||
get staticParams() {
|
|
||||||
const staticParams = pickBy(
|
|
||||||
this.parameterMapping,
|
|
||||||
mapping => mapping.mappingType === QueryBasedParameterMappingType.STATIC
|
|
||||||
);
|
|
||||||
return mapValues(staticParams, value => value.staticValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizeValue(value) {
|
normalizeValue(value) {
|
||||||
if (isUndefined(value) || isNull(value) || (isArray(value) && isEmpty(value))) {
|
if (isUndefined(value) || isNull(value) || (isArray(value) && isEmpty(value))) {
|
||||||
return null;
|
return null;
|
||||||
@@ -75,48 +20,24 @@ class QueryBasedDropdownParameter extends Parameter {
|
|||||||
} else {
|
} else {
|
||||||
value = isArray(value) ? value[0] : value;
|
value = isArray(value) ? value[0] : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.searchColumn) {
|
|
||||||
value = this._getLabeledValue(value);
|
|
||||||
}
|
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
setValue(value) {
|
|
||||||
if (this.searchColumn) {
|
|
||||||
value = this._getLabeledValue(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return super.setValue(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
getExecutionValue(extra = {}) {
|
getExecutionValue(extra = {}) {
|
||||||
const { joinListValues } = extra;
|
const { joinListValues } = extra;
|
||||||
let executionValue = this.value;
|
if (joinListValues && isArray(this.value)) {
|
||||||
if (isArray(executionValue)) {
|
const separator = get(this.multiValuesOptions, "separator", ",");
|
||||||
executionValue = map(executionValue, value => get(value, "value", value));
|
const prefix = get(this.multiValuesOptions, "prefix", "");
|
||||||
|
const suffix = get(this.multiValuesOptions, "suffix", "");
|
||||||
if (joinListValues) {
|
const parameterValues = map(this.value, v => `${prefix}${v}${suffix}`);
|
||||||
const separator = get(this.multiValuesOptions, "separator", ",");
|
return join(parameterValues, separator);
|
||||||
const prefix = get(this.multiValuesOptions, "prefix", "");
|
|
||||||
const suffix = get(this.multiValuesOptions, "suffix", "");
|
|
||||||
const parameterValues = map(executionValue, v => `${prefix}${v}${suffix}`);
|
|
||||||
executionValue = join(parameterValues, separator);
|
|
||||||
}
|
|
||||||
return executionValue;
|
|
||||||
}
|
}
|
||||||
|
return this.value;
|
||||||
executionValue = get(executionValue, "value", executionValue);
|
|
||||||
return executionValue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toUrlParams() {
|
toUrlParams() {
|
||||||
const prefix = this.urlPrefix;
|
const prefix = this.urlPrefix;
|
||||||
|
|
||||||
if (this.searchColumn) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let urlParam = this.value;
|
let urlParam = this.value;
|
||||||
if (this.multiValuesOptions && isArray(this.value)) {
|
if (this.multiValuesOptions && isArray(this.value)) {
|
||||||
urlParam = JSON.stringify(this.value);
|
urlParam = JSON.stringify(this.value);
|
||||||
@@ -130,80 +51,28 @@ class QueryBasedDropdownParameter extends Parameter {
|
|||||||
fromUrlParams(query) {
|
fromUrlParams(query) {
|
||||||
const prefix = this.urlPrefix;
|
const prefix = this.urlPrefix;
|
||||||
const key = `${prefix}${this.name}`;
|
const key = `${prefix}${this.name}`;
|
||||||
|
|
||||||
if (this.searchColumn) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (has(query, key)) {
|
if (has(query, key)) {
|
||||||
const queryKey = query[key];
|
|
||||||
if (this.multiValuesOptions) {
|
if (this.multiValuesOptions) {
|
||||||
try {
|
try {
|
||||||
const valueFromJson = JSON.parse(queryKey);
|
const valueFromJson = JSON.parse(query[key]);
|
||||||
this.setValue(isArray(valueFromJson) ? valueFromJson : queryKey);
|
this.setValue(isArray(valueFromJson) ? valueFromJson : query[key]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.setValue(queryKey);
|
this.setValue(query[key]);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.setValue(queryKey);
|
this.setValue(query[key]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_saveLabeledValuesFromOptions(options) {
|
loadDropdownValues() {
|
||||||
this.$$optionLabels = { ...this.$$optionLabels, ...extractOptionLabelsFromValues(options) };
|
if (this.parentQueryId) {
|
||||||
return options;
|
return Query.associatedDropdown({ queryId: this.parentQueryId, dropdownQueryId: this.queryId }).catch(() =>
|
||||||
}
|
Promise.resolve([])
|
||||||
|
);
|
||||||
_getLabeledValue(value) {
|
|
||||||
const getSingleLabeledValue = value => {
|
|
||||||
value = get(value, "value", value);
|
|
||||||
if (!(value in this.$$optionLabels)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return { value, label: this.$$optionLabels[value] };
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isArray(value)) {
|
|
||||||
value = map(value, getSingleLabeledValue);
|
|
||||||
return filter(value); // remove values without label
|
|
||||||
}
|
}
|
||||||
return getSingleLabeledValue(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadDropdownValues(initialSearchTerm = null) {
|
return Query.asDropdown({ id: this.queryId }).catch(Promise.resolve([]));
|
||||||
return Query.get({ id: this.queryId })
|
|
||||||
.then(query => {
|
|
||||||
const queryHasParameters = query.hasParameters();
|
|
||||||
if (queryHasParameters && this.searchColumn) {
|
|
||||||
this.searchFunction = searchTerm =>
|
|
||||||
QueryResult.getByQueryId(query.id, { ...this.staticParams, [this.searchColumn]: searchTerm }, -1)
|
|
||||||
.toPromise()
|
|
||||||
.then(result => get(result, "query_result.data.rows"))
|
|
||||||
.then(mapQueryResultToDropdownOptions)
|
|
||||||
.then(options => this._saveLabeledValuesFromOptions(options))
|
|
||||||
.catch(() => Promise.resolve([]));
|
|
||||||
return initialSearchTerm ? this.searchFunction(initialSearchTerm) : Promise.resolve([]);
|
|
||||||
} else {
|
|
||||||
this.searchFunction = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (queryHasParameters) {
|
|
||||||
return QueryResult.getByQueryId(query.id, { ...this.staticParams }, -1)
|
|
||||||
.toPromise()
|
|
||||||
.then(result => get(result, "query_result.data.rows"));
|
|
||||||
} else if (this.parentQueryId) {
|
|
||||||
return Query.associatedDropdown({ queryId: this.parentQueryId, dropdownQueryId: this.queryId });
|
|
||||||
}
|
|
||||||
return Query.asDropdown({ id: this.queryId });
|
|
||||||
})
|
|
||||||
.then(mapQueryResultToDropdownOptions)
|
|
||||||
.catch(() => Promise.resolve([]));
|
|
||||||
}
|
|
||||||
|
|
||||||
toSaveableObject() {
|
|
||||||
const saveableObject = super.toSaveableObject();
|
|
||||||
return omit(saveableObject, ["$$optionLabels"]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,18 +31,6 @@ describe("QueryBasedDropdownParameter", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getExecutionValue", () => {
|
|
||||||
test("returns value when stored value doesn't contain its label", () => {
|
|
||||||
param.setValue("test");
|
|
||||||
expect(param.getExecutionValue()).toBe("test");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns value from object when stored value contains its label", () => {
|
|
||||||
param.setValue({ label: "Test Label", value: "test" });
|
|
||||||
expect(param.getExecutionValue()).toBe("test");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Multi-valued", () => {
|
describe("Multi-valued", () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
multiValuesOptions = { prefix: '"', suffix: '"', separator: "," };
|
multiValuesOptions = { prefix: '"', suffix: '"', separator: "," };
|
||||||
@@ -56,19 +44,6 @@ describe("QueryBasedDropdownParameter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("getExecutionValue", () => {
|
describe("getExecutionValue", () => {
|
||||||
test("returns value when stored value doesn't contain its label", () => {
|
|
||||||
param.setValue(["test1", "test2"]);
|
|
||||||
expect(param.getExecutionValue()).toEqual(["test1", "test2"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns value from object when stored value contains its label", () => {
|
|
||||||
param.setValue([
|
|
||||||
{ label: "Test Label 1", value: "test1" },
|
|
||||||
{ label: "Test Label 2", value: "test2" },
|
|
||||||
]);
|
|
||||||
expect(param.getExecutionValue()).toEqual(["test1", "test2"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("joins values when joinListValues is truthy", () => {
|
test("joins values when joinListValues is truthy", () => {
|
||||||
param.setValue(["value1", "value3"]);
|
param.setValue(["value1", "value3"]);
|
||||||
const executionValue = param.getExecutionValue({ joinListValues: true });
|
const executionValue = param.getExecutionValue({ joinListValues: true });
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ describe("Parameter Mapping", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const saveMappingOptions = () => {
|
const saveMappingOptions = () => {
|
||||||
cy.getByTestId("InputPopoverContent").within(() => {
|
cy.getByTestId("EditParamMappingPopover").within(() => {
|
||||||
cy.contains("button", "OK").click();
|
cy.contains("button", "OK").click();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ describe("Parameter Mapping", () => {
|
|||||||
|
|
||||||
cy.getByTestId("StaticValueOption").click();
|
cy.getByTestId("StaticValueOption").click();
|
||||||
|
|
||||||
cy.getByTestId("InputPopoverContent").within(() => {
|
cy.getByTestId("EditParamMappingPopover").within(() => {
|
||||||
cy.getByTestId("ParameterValueInput")
|
cy.getByTestId("ParameterValueInput")
|
||||||
.find("input")
|
.find("input")
|
||||||
.type("{selectall}StaticValue");
|
.type("{selectall}StaticValue");
|
||||||
|
|||||||
3369
package-lock.json
generated
3369
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -67,9 +67,9 @@
|
|||||||
"path-to-regexp": "^3.1.0",
|
"path-to-regexp": "^3.1.0",
|
||||||
"prop-types": "^15.6.1",
|
"prop-types": "^15.6.1",
|
||||||
"query-string": "^6.9.0",
|
"query-string": "^6.9.0",
|
||||||
"react": "^16.13.1",
|
"react": "^16.14.0",
|
||||||
"react-ace": "^9.1.1",
|
"react-ace": "^9.1.1",
|
||||||
"react-dom": "^16.13.1",
|
"react-dom": "^16.14.0",
|
||||||
"react-grid-layout": "^0.18.2",
|
"react-grid-layout": "^0.18.2",
|
||||||
"react-resizable": "^1.10.1",
|
"react-resizable": "^1.10.1",
|
||||||
"react-virtualized": "^9.21.2",
|
"react-virtualized": "^9.21.2",
|
||||||
@@ -89,6 +89,7 @@
|
|||||||
"@cypress/code-coverage": "^3.8.1",
|
"@cypress/code-coverage": "^3.8.1",
|
||||||
"@percy/agent": "0.24.3",
|
"@percy/agent": "0.24.3",
|
||||||
"@percy/cypress": "^2.3.2",
|
"@percy/cypress": "^2.3.2",
|
||||||
|
"@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
|
||||||
"@types/classnames": "^2.2.10",
|
"@types/classnames": "^2.2.10",
|
||||||
"@types/hoist-non-react-statics": "^3.3.1",
|
"@types/hoist-non-react-statics": "^3.3.1",
|
||||||
"@types/lodash": "^4.14.157",
|
"@types/lodash": "^4.14.157",
|
||||||
@@ -137,16 +138,19 @@
|
|||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"prettier": "^1.19.1",
|
"prettier": "^1.19.1",
|
||||||
"raw-loader": "^0.5.1",
|
"raw-loader": "^0.5.1",
|
||||||
|
"react-refresh": "^0.9.0",
|
||||||
"react-test-renderer": "^16.5.2",
|
"react-test-renderer": "^16.5.2",
|
||||||
"request": "^2.88.0",
|
"request": "^2.88.0",
|
||||||
"request-cookies": "^1.1.0",
|
"request-cookies": "^1.1.0",
|
||||||
|
"style-loader": "^2.0.0",
|
||||||
|
"ts-migrate": "^0.1.10",
|
||||||
"typescript": "^3.9.6",
|
"typescript": "^3.9.6",
|
||||||
"url-loader": "^1.1.2",
|
"url-loader": "^1.1.2",
|
||||||
"webpack": "^4.20.2",
|
"webpack": "^4.44.2",
|
||||||
"webpack-build-notifier": "^0.1.30",
|
"webpack-build-notifier": "^0.1.30",
|
||||||
"webpack-bundle-analyzer": "^2.11.1",
|
"webpack-bundle-analyzer": "^2.11.1",
|
||||||
"webpack-cli": "^3.1.2",
|
"webpack-cli": "^3.1.2",
|
||||||
"webpack-dev-server": "^3.1.9",
|
"webpack-dev-server": "^3.11.0",
|
||||||
"webpack-manifest-plugin": "^2.0.4"
|
"webpack-manifest-plugin": "^2.0.4"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ class QueryResultDropdownResource(BaseResource):
|
|||||||
)
|
)
|
||||||
require_access(query.data_source, current_user, view_only)
|
require_access(query.data_source, current_user, view_only)
|
||||||
try:
|
try:
|
||||||
return dropdown_values(query, self.current_org)
|
return dropdown_values(query_id, self.current_org)
|
||||||
except QueryDetachedFromDataSourceError as e:
|
except QueryDetachedFromDataSourceError as e:
|
||||||
abort(400, message=str(e))
|
abort(400, message=str(e))
|
||||||
|
|
||||||
@@ -224,14 +224,13 @@ class QueryDropdownsResource(BaseResource):
|
|||||||
related_queries_ids = [
|
related_queries_ids = [
|
||||||
p["queryId"] for p in query.parameters if p["type"] == "query"
|
p["queryId"] for p in query.parameters if p["type"] == "query"
|
||||||
]
|
]
|
||||||
dropdown_query = get_object_or_404(
|
|
||||||
models.Query.get_by_id_and_org, dropdown_query_id, self.current_org
|
|
||||||
)
|
|
||||||
|
|
||||||
if int(dropdown_query_id) not in related_queries_ids:
|
if int(dropdown_query_id) not in related_queries_ids:
|
||||||
|
dropdown_query = get_object_or_404(
|
||||||
|
models.Query.get_by_id_and_org, dropdown_query_id, self.current_org
|
||||||
|
)
|
||||||
require_access(dropdown_query.data_source, current_user, view_only)
|
require_access(dropdown_query.data_source, current_user, view_only)
|
||||||
|
|
||||||
return dropdown_values(dropdown_query, self.current_org)
|
return dropdown_values(dropdown_query_id, self.current_org)
|
||||||
|
|
||||||
|
|
||||||
class QueryResultResource(BaseResource):
|
class QueryResultResource(BaseResource):
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from redash.utils import mustache_render, json_loads
|
|||||||
from redash.permissions import require_access, view_only
|
from redash.permissions import require_access, view_only
|
||||||
from funcy import distinct
|
from funcy import distinct
|
||||||
from dateutil.parser import parse
|
from dateutil.parser import parse
|
||||||
from redash import models
|
|
||||||
|
|
||||||
|
|
||||||
def _pluck_name_and_value(default_column, row):
|
def _pluck_name_and_value(default_column, row):
|
||||||
@@ -16,18 +15,22 @@ def _pluck_name_and_value(default_column, row):
|
|||||||
return {"name": row[name_column], "value": str(row[value_column])}
|
return {"name": row[name_column], "value": str(row[value_column])}
|
||||||
|
|
||||||
|
|
||||||
def _load_result(query, org):
|
def _load_result(query_id, org):
|
||||||
|
from redash import models
|
||||||
|
|
||||||
|
query = models.Query.get_by_id_and_org(query_id, org)
|
||||||
|
|
||||||
if query.data_source:
|
if query.data_source:
|
||||||
query_result = models.QueryResult.get_by_id_and_org(
|
query_result = models.QueryResult.get_by_id_and_org(
|
||||||
query.latest_query_data_id, org
|
query.latest_query_data_id, org
|
||||||
)
|
)
|
||||||
return query_result.data
|
return query_result.data
|
||||||
else:
|
else:
|
||||||
raise QueryDetachedFromDataSourceError(query.id)
|
raise QueryDetachedFromDataSourceError(query_id)
|
||||||
|
|
||||||
|
|
||||||
def dropdown_values(query, org):
|
def dropdown_values(query_id, org):
|
||||||
data = _load_result(query, org)
|
data = _load_result(query_id, org)
|
||||||
first_column = data["columns"][0]["name"]
|
first_column = data["columns"][0]["name"]
|
||||||
pluck = partial(_pluck_name_and_value, first_column)
|
pluck = partial(_pluck_name_and_value, first_column)
|
||||||
return list(map(pluck, data["rows"]))
|
return list(map(pluck, data["rows"]))
|
||||||
@@ -152,12 +155,6 @@ class ParameterizedQuery(object):
|
|||||||
query_id = definition.get("queryId")
|
query_id = definition.get("queryId")
|
||||||
allow_multiple_values = isinstance(definition.get("multiValuesOptions"), dict)
|
allow_multiple_values = isinstance(definition.get("multiValuesOptions"), dict)
|
||||||
|
|
||||||
if definition["type"] == "query":
|
|
||||||
try:
|
|
||||||
query = models.Query.get_by_id_and_org(query_id, self.org)
|
|
||||||
except (models.NoResultFound):
|
|
||||||
return False
|
|
||||||
|
|
||||||
if isinstance(enum_options, str):
|
if isinstance(enum_options, str):
|
||||||
enum_options = enum_options.split("\n")
|
enum_options = enum_options.split("\n")
|
||||||
|
|
||||||
@@ -169,11 +166,9 @@ class ParameterizedQuery(object):
|
|||||||
),
|
),
|
||||||
"query": lambda value: _is_value_within_options(
|
"query": lambda value: _is_value_within_options(
|
||||||
value,
|
value,
|
||||||
[v["value"] for v in dropdown_values(query, self.org)],
|
[v["value"] for v in dropdown_values(query_id, self.org)],
|
||||||
allow_multiple_values,
|
allow_multiple_values,
|
||||||
)
|
),
|
||||||
if not query.parameters
|
|
||||||
else True,
|
|
||||||
"date": _is_date,
|
"date": _is_date,
|
||||||
"datetime-local": _is_date,
|
"datetime-local": _is_date,
|
||||||
"datetime-with-seconds": _is_date,
|
"datetime-with-seconds": _is_date,
|
||||||
@@ -188,18 +183,8 @@ class ParameterizedQuery(object):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_safe(self):
|
def is_safe(self):
|
||||||
for param in self.schema:
|
text_parameters = [param for param in self.schema if param["type"] == "text"]
|
||||||
if param["type"] == "text":
|
return not any(text_parameters)
|
||||||
return False
|
|
||||||
if param["type"] == "query":
|
|
||||||
try:
|
|
||||||
query = models.Query.get_by_id_and_org(
|
|
||||||
param.get("queryId"), self.org
|
|
||||||
)
|
|
||||||
return not query.parameters
|
|
||||||
except (models.NoResultFound):
|
|
||||||
return True
|
|
||||||
return True
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def missing_params(self):
|
def missing_params(self):
|
||||||
|
|||||||
@@ -166,13 +166,7 @@ class TestParameterizedQuery(TestCase):
|
|||||||
"redash.models.parameterized_query.dropdown_values",
|
"redash.models.parameterized_query.dropdown_values",
|
||||||
return_value=[{"value": "1"}],
|
return_value=[{"value": "1"}],
|
||||||
)
|
)
|
||||||
@patch(
|
def test_validation_accepts_integer_values_for_dropdowns(self, _):
|
||||||
"redash.models.Query.get_by_id_and_org",
|
|
||||||
return_value=namedtuple("Query", ["parameters"])(parameters=[]),
|
|
||||||
)
|
|
||||||
def test_validation_accepts_integer_values_for_dropdowns(
|
|
||||||
self, dpd_values, dpd_query
|
|
||||||
):
|
|
||||||
schema = [{"name": "bar", "type": "query", "queryId": 1}]
|
schema = [{"name": "bar", "type": "query", "queryId": 1}]
|
||||||
query = ParameterizedQuery("foo {{bar}}", schema)
|
query = ParameterizedQuery("foo {{bar}}", schema)
|
||||||
|
|
||||||
@@ -181,11 +175,7 @@ class TestParameterizedQuery(TestCase):
|
|||||||
self.assertEqual("foo 1", query.text)
|
self.assertEqual("foo 1", query.text)
|
||||||
|
|
||||||
@patch("redash.models.parameterized_query.dropdown_values")
|
@patch("redash.models.parameterized_query.dropdown_values")
|
||||||
@patch(
|
def test_raises_on_invalid_query_parameters(self, _):
|
||||||
"redash.models.Query.get_by_id_and_org",
|
|
||||||
return_value=namedtuple("Query", ["parameters"])(parameters=[]),
|
|
||||||
)
|
|
||||||
def test_raises_on_invalid_query_parameters(self, dpd_values, dpd_query):
|
|
||||||
schema = [{"name": "bar", "type": "query", "queryId": 1}]
|
schema = [{"name": "bar", "type": "query", "queryId": 1}]
|
||||||
query = ParameterizedQuery("foo", schema)
|
query = ParameterizedQuery("foo", schema)
|
||||||
|
|
||||||
@@ -196,11 +186,7 @@ class TestParameterizedQuery(TestCase):
|
|||||||
"redash.models.parameterized_query.dropdown_values",
|
"redash.models.parameterized_query.dropdown_values",
|
||||||
return_value=[{"value": "baz"}],
|
return_value=[{"value": "baz"}],
|
||||||
)
|
)
|
||||||
@patch(
|
def test_raises_on_unlisted_query_value_parameters(self, _):
|
||||||
"redash.models.Query.get_by_id_and_org",
|
|
||||||
return_value=namedtuple("Query", ["parameters"])(parameters=[]),
|
|
||||||
)
|
|
||||||
def test_raises_on_unlisted_query_value_parameters(self, dpd_values, dpd_query):
|
|
||||||
schema = [{"name": "bar", "type": "query", "queryId": 1}]
|
schema = [{"name": "bar", "type": "query", "queryId": 1}]
|
||||||
query = ParameterizedQuery("foo", schema)
|
query = ParameterizedQuery("foo", schema)
|
||||||
|
|
||||||
@@ -211,11 +197,7 @@ class TestParameterizedQuery(TestCase):
|
|||||||
"redash.models.parameterized_query.dropdown_values",
|
"redash.models.parameterized_query.dropdown_values",
|
||||||
return_value=[{"value": "baz"}],
|
return_value=[{"value": "baz"}],
|
||||||
)
|
)
|
||||||
@patch(
|
def test_validates_query_parameters(self, _):
|
||||||
"redash.models.Query.get_by_id_and_org",
|
|
||||||
return_value=namedtuple("Query", ["parameters"])(parameters=[]),
|
|
||||||
)
|
|
||||||
def test_validates_query_parameters(self, dpd_values, dpd_query):
|
|
||||||
schema = [{"name": "bar", "type": "query", "queryId": 1}]
|
schema = [{"name": "bar", "type": "query", "queryId": 1}]
|
||||||
query = ParameterizedQuery("foo {{bar}}", schema)
|
query = ParameterizedQuery("foo {{bar}}", schema)
|
||||||
|
|
||||||
@@ -253,26 +235,6 @@ class TestParameterizedQuery(TestCase):
|
|||||||
|
|
||||||
self.assertFalse(query.is_safe)
|
self.assertFalse(query.is_safe)
|
||||||
|
|
||||||
@patch(
|
|
||||||
"redash.models.Query.get_by_id_and_org",
|
|
||||||
return_value=namedtuple("Query", ["parameters"])(parameters=[{"name": "test"}]),
|
|
||||||
)
|
|
||||||
def test_is_not_safe_if_expecting_parameterized_query_based_parameter(self, _):
|
|
||||||
schema = [{"name": "bar", "type": "query"}]
|
|
||||||
query = ParameterizedQuery("foo", schema)
|
|
||||||
|
|
||||||
self.assertFalse(query.is_safe)
|
|
||||||
|
|
||||||
@patch(
|
|
||||||
"redash.models.Query.get_by_id_and_org",
|
|
||||||
return_value=namedtuple("Query", ["parameters"])(parameters=[]),
|
|
||||||
)
|
|
||||||
def test_is_safe_if_expecting_query_based_parameter(self, _):
|
|
||||||
schema = [{"name": "bar", "type": "query"}]
|
|
||||||
query = ParameterizedQuery("foo", schema)
|
|
||||||
|
|
||||||
self.assertTrue(query.is_safe)
|
|
||||||
|
|
||||||
def test_is_safe_if_not_expecting_text_parameter(self):
|
def test_is_safe_if_not_expecting_text_parameter(self):
|
||||||
schema = [{"name": "bar", "type": "number"}]
|
schema = [{"name": "bar", "type": "number"}]
|
||||||
query = ParameterizedQuery("foo", schema)
|
query = ParameterizedQuery("foo", schema)
|
||||||
@@ -293,7 +255,7 @@ class TestParameterizedQuery(TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
def test_dropdown_values_prefers_name_and_value_columns(self, _):
|
def test_dropdown_values_prefers_name_and_value_columns(self, _):
|
||||||
values = dropdown_values(None, None)
|
values = dropdown_values(1, None)
|
||||||
self.assertEqual(values, [{"name": "John", "value": "John Doe"}])
|
self.assertEqual(values, [{"name": "John", "value": "John Doe"}])
|
||||||
|
|
||||||
@patch(
|
@patch(
|
||||||
@@ -304,7 +266,7 @@ class TestParameterizedQuery(TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
def test_dropdown_values_compromises_for_first_column(self, _):
|
def test_dropdown_values_compromises_for_first_column(self, _):
|
||||||
values = dropdown_values(None, None)
|
values = dropdown_values(1, None)
|
||||||
self.assertEqual(values, [{"name": 5, "value": "5"}])
|
self.assertEqual(values, [{"name": 5, "value": "5"}])
|
||||||
|
|
||||||
@patch(
|
@patch(
|
||||||
@@ -315,9 +277,13 @@ class TestParameterizedQuery(TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
def test_dropdown_supports_upper_cased_columns(self, _):
|
def test_dropdown_supports_upper_cased_columns(self, _):
|
||||||
values = dropdown_values(None, None)
|
values = dropdown_values(1, None)
|
||||||
self.assertEqual(values, [{"name": 5, "value": "5"}])
|
self.assertEqual(values, [{"name": 5, "value": "5"}])
|
||||||
|
|
||||||
def test_dropdown_values_raises_when_query_is_detached_from_data_source(self):
|
@patch(
|
||||||
|
"redash.models.Query.get_by_id_and_org",
|
||||||
|
return_value=namedtuple("Query", "data_source")(None),
|
||||||
|
)
|
||||||
|
def test_dropdown_values_raises_when_query_is_detached_from_data_source(self, _):
|
||||||
with pytest.raises(QueryDetachedFromDataSourceError):
|
with pytest.raises(QueryDetachedFromDataSourceError):
|
||||||
dropdown_values(namedtuple("Query", ["id", "data_source"])(None, None), None)
|
dropdown_values(1, None)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const CopyWebpackPlugin = require("copy-webpack-plugin");
|
|||||||
const LessPluginAutoPrefix = require("less-plugin-autoprefix");
|
const LessPluginAutoPrefix = require("less-plugin-autoprefix");
|
||||||
const BundleAnalyzerPlugin = require("webpack-bundle-analyzer")
|
const BundleAnalyzerPlugin = require("webpack-bundle-analyzer")
|
||||||
.BundleAnalyzerPlugin;
|
.BundleAnalyzerPlugin;
|
||||||
const fs = require("fs");
|
const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin");
|
||||||
|
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
@@ -30,6 +30,9 @@ function optionalRequire(module, defaultReturn = undefined) {
|
|||||||
const CONFIG = optionalRequire("./scripts/config", {});
|
const CONFIG = optionalRequire("./scripts/config", {});
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV === "production";
|
const isProduction = process.env.NODE_ENV === "production";
|
||||||
|
const isDevelopment = !isProduction;
|
||||||
|
const isHotReloadingEnabled =
|
||||||
|
isDevelopment && process.env.HOT_RELOAD === "true";
|
||||||
|
|
||||||
const redashBackend = process.env.REDASH_BACKEND || "http://localhost:5000";
|
const redashBackend = process.env.REDASH_BACKEND || "http://localhost:5000";
|
||||||
const baseHref = CONFIG.baseHref || "/";
|
const baseHref = CONFIG.baseHref || "/";
|
||||||
@@ -45,7 +48,8 @@ const extensionPath = path.join(__dirname, extensionsRelativePath);
|
|||||||
|
|
||||||
// Function to apply configuration overrides (see scripts/README)
|
// Function to apply configuration overrides (see scripts/README)
|
||||||
function maybeApplyOverrides(config) {
|
function maybeApplyOverrides(config) {
|
||||||
const overridesLocation = process.env.REDASH_WEBPACK_OVERRIDES || "./scripts/webpack/overrides";
|
const overridesLocation =
|
||||||
|
process.env.REDASH_WEBPACK_OVERRIDES || "./scripts/webpack/overrides";
|
||||||
const applyOverrides = optionalRequire(overridesLocation);
|
const applyOverrides = optionalRequire(overridesLocation);
|
||||||
if (!applyOverrides) {
|
if (!applyOverrides) {
|
||||||
return config;
|
return config;
|
||||||
@@ -97,9 +101,10 @@ const config = {
|
|||||||
filename: "multi_org.html",
|
filename: "multi_org.html",
|
||||||
excludeChunks: ["server"]
|
excludeChunks: ["server"]
|
||||||
}),
|
}),
|
||||||
new MiniCssExtractPlugin({
|
isProduction &&
|
||||||
filename: "[name].[chunkhash].css"
|
new MiniCssExtractPlugin({
|
||||||
}),
|
filename: "[name].[chunkhash].css"
|
||||||
|
}),
|
||||||
new ManifestPlugin({
|
new ManifestPlugin({
|
||||||
fileName: "asset-manifest.json",
|
fileName: "asset-manifest.json",
|
||||||
publicPath: ""
|
publicPath: ""
|
||||||
@@ -110,8 +115,9 @@ const config = {
|
|||||||
{ from: "client/app/unsupportedRedirect.js" },
|
{ from: "client/app/unsupportedRedirect.js" },
|
||||||
{ from: "client/app/assets/css/*.css", to: "styles/", flatten: true },
|
{ from: "client/app/assets/css/*.css", to: "styles/", flatten: true },
|
||||||
{ from: "client/app/assets/fonts", to: "fonts/" }
|
{ from: "client/app/assets/fonts", to: "fonts/" }
|
||||||
])
|
]),
|
||||||
],
|
isHotReloadingEnabled && new ReactRefreshWebpackPlugin({ overlay: false })
|
||||||
|
].filter(Boolean),
|
||||||
optimization: {
|
optimization: {
|
||||||
splitChunks: {
|
splitChunks: {
|
||||||
chunks: chunk => {
|
chunks: chunk => {
|
||||||
@@ -124,7 +130,17 @@ const config = {
|
|||||||
{
|
{
|
||||||
test: /\.(t|j)sx?$/,
|
test: /\.(t|j)sx?$/,
|
||||||
exclude: /node_modules/,
|
exclude: /node_modules/,
|
||||||
use: ["babel-loader", "eslint-loader"]
|
use: [
|
||||||
|
{
|
||||||
|
loader: require.resolve("babel-loader"),
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
isHotReloadingEnabled && require.resolve("react-refresh/babel")
|
||||||
|
].filter(Boolean)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
require.resolve("eslint-loader")
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
test: /\.html$/,
|
test: /\.html$/,
|
||||||
@@ -139,7 +155,7 @@ const config = {
|
|||||||
test: /\.css$/,
|
test: /\.css$/,
|
||||||
use: [
|
use: [
|
||||||
{
|
{
|
||||||
loader: MiniCssExtractPlugin.loader
|
loader: isProduction ? MiniCssExtractPlugin.loader : "style-loader"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
loader: "css-loader",
|
loader: "css-loader",
|
||||||
@@ -153,12 +169,12 @@ const config = {
|
|||||||
test: /\.less$/,
|
test: /\.less$/,
|
||||||
use: [
|
use: [
|
||||||
{
|
{
|
||||||
loader: MiniCssExtractPlugin.loader
|
loader: isProduction ? MiniCssExtractPlugin.loader : "style-loader"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
loader: "css-loader",
|
loader: "css-loader",
|
||||||
options: {
|
options: {
|
||||||
minimize: process.env.NODE_ENV === "production"
|
minimize: isProduction
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -258,7 +274,8 @@ const config = {
|
|||||||
stats: {
|
stats: {
|
||||||
modules: false,
|
modules: false,
|
||||||
chunkModules: false
|
chunkModules: false
|
||||||
}
|
},
|
||||||
|
hot: isHotReloadingEnabled
|
||||||
},
|
},
|
||||||
performance: {
|
performance: {
|
||||||
hints: false
|
hints: false
|
||||||
|
|||||||
Reference in New Issue
Block a user