mirror of
https://github.com/valitydev/redash.git
synced 2024-11-06 17:15:17 +00:00
Refactor dialog wrapper component (#4594)
* Dialog wrapper: stop using promises to handle results - replace with callbacks * Dialog wrapper: handle async operation on close
This commit is contained in:
parent
e552effd96
commit
db71ff399c
@ -28,7 +28,7 @@ function onSearch(q) {
|
||||
|
||||
function DesktopNavbar() {
|
||||
const showCreateDashboardDialog = useCallback(() => {
|
||||
CreateDashboardDialog.showModal().result.catch(() => {}); // ignore dismiss
|
||||
CreateDashboardDialog.showModal();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
@ -14,9 +14,10 @@ import ReactDOM from "react-dom";
|
||||
|
||||
{
|
||||
showModal: (dialogProps) => object({
|
||||
result: Promise,
|
||||
close: (result) => void,
|
||||
dismiss: (reason) => void,
|
||||
onClose: (handler) => this,
|
||||
onDismiss: (handler) => this,
|
||||
}),
|
||||
Component: React.Component, // wrapped dialog component
|
||||
}
|
||||
@ -28,15 +29,20 @@ import ReactDOM from "react-dom";
|
||||
|
||||
const dialog = SomeWrappedDialog.showModal({ greeting: 'Hello' })
|
||||
|
||||
To get result of modal, use `result` property:
|
||||
To get result of modal, use `onClose`/`onDismiss` setters:
|
||||
|
||||
dialog.result
|
||||
.then(...) // pressed OK button or used `close` method; resolved value is a result of dialog
|
||||
.catch(...) // pressed Cancel button or used `dismiss` method; optional argument is a rejection reason.
|
||||
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 used to resolve/reject `dialog.result` promise. `update` methods allows to pass new properties
|
||||
to dialog.
|
||||
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
|
||||
@ -88,21 +94,6 @@ import ReactDOM from "react-dom";
|
||||
<Modal {...dialog.props} onOk={() => this.customOkHandler()}>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Settings
|
||||
========
|
||||
|
||||
You can setup this wrapper to use custom `Promise` library (for example, Bluebird):
|
||||
|
||||
import DialogWrapper from 'path/to/DialogWrapper';
|
||||
import Promise from 'bluebird';
|
||||
|
||||
DialogWrapper.Promise = Promise;
|
||||
|
||||
It could be useful to avoid `unhandledrejection` exception that would fire with native Promises,
|
||||
or when some custom Promise library is used in application.
|
||||
|
||||
*/
|
||||
|
||||
export const DialogPropType = PropTypes.shape({
|
||||
@ -116,17 +107,12 @@ export const DialogPropType = PropTypes.shape({
|
||||
dismiss: PropTypes.func.isRequired,
|
||||
});
|
||||
|
||||
// default export of module
|
||||
const DialogWrapper = {
|
||||
Promise,
|
||||
DialogPropType,
|
||||
wrap() {},
|
||||
};
|
||||
|
||||
function openDialog(DialogComponent, props) {
|
||||
const dialog = {
|
||||
props: {
|
||||
visible: true,
|
||||
okButtonProps: {},
|
||||
cancelButtonProps: {},
|
||||
onOk: () => {},
|
||||
onCancel: () => {},
|
||||
afterClose: () => {},
|
||||
@ -135,9 +121,11 @@ function openDialog(DialogComponent, props) {
|
||||
dismiss: () => {},
|
||||
};
|
||||
|
||||
const dialogResult = {
|
||||
resolve: () => {},
|
||||
reject: () => {},
|
||||
let pendingCloseTask = null;
|
||||
|
||||
const handlers = {
|
||||
onClose: () => {},
|
||||
onDismiss: () => {},
|
||||
};
|
||||
|
||||
const container = document.createElement("div");
|
||||
@ -155,16 +143,43 @@ function openDialog(DialogComponent, props) {
|
||||
}, 10);
|
||||
}
|
||||
|
||||
function closeDialog(result) {
|
||||
dialogResult.resolve(result);
|
||||
dialog.props.visible = false;
|
||||
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 dismissDialog(reason) {
|
||||
dialogResult.reject(reason);
|
||||
dialog.props.visible = false;
|
||||
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;
|
||||
@ -180,20 +195,22 @@ function openDialog(DialogComponent, props) {
|
||||
props = { ...props, ...newProps };
|
||||
render();
|
||||
},
|
||||
result: new DialogWrapper.Promise((resolve, reject) => {
|
||||
dialogResult.resolve = resolve;
|
||||
dialogResult.reject = reject;
|
||||
}),
|
||||
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
|
||||
|
||||
// Some known libraries support
|
||||
// Bluebird: http://bluebirdjs.com/docs/api/suppressunhandledrejections.html
|
||||
if (isFunction(result.result.suppressUnhandledRejections)) {
|
||||
result.result.suppressUnhandledRejections();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -204,6 +221,7 @@ export function wrap(DialogComponent) {
|
||||
};
|
||||
}
|
||||
|
||||
DialogWrapper.wrap = wrap;
|
||||
|
||||
export default DialogWrapper;
|
||||
export default {
|
||||
DialogPropType,
|
||||
wrap,
|
||||
};
|
||||
|
@ -106,16 +106,14 @@ export default class Parameters extends React.Component {
|
||||
|
||||
showParameterSettings = (parameter, index) => {
|
||||
const { onParametersEdit } = this.props;
|
||||
EditParameterSettingsDialog.showModal({ parameter })
|
||||
.result.then(updated => {
|
||||
this.setState(({ parameters }) => {
|
||||
const updatedParameter = extend(parameter, updated);
|
||||
parameters[index] = createParameter(updatedParameter, updatedParameter.parentQueryId);
|
||||
onParametersEdit();
|
||||
return { parameters };
|
||||
});
|
||||
})
|
||||
.catch(() => {}); // ignore dismiss
|
||||
EditParameterSettingsDialog.showModal({ parameter }).onClose(updated => {
|
||||
this.setState(({ parameters }) => {
|
||||
const updatedParameter = extend(parameter, updated);
|
||||
parameters[index] = createParameter(updatedParameter, updatedParameter.parentQueryId);
|
||||
onParametersEdit();
|
||||
return { parameters };
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
renderParameter(param, index) {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { filter, debounce, find, isEmpty, size } from "lodash";
|
||||
import React from "react";
|
||||
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";
|
||||
@ -8,191 +8,185 @@ 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";
|
||||
|
||||
class SelectItemsDialog extends React.Component {
|
||||
static 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,
|
||||
save: PropTypes.func, // (selectedItems[]) => Promise<any>
|
||||
width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
extraFooterContent: PropTypes.node,
|
||||
showCount: PropTypes.bool,
|
||||
};
|
||||
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]
|
||||
);
|
||||
|
||||
static defaultProps = {
|
||||
dialogTitle: "Add Items",
|
||||
inputPlaceholder: "Search...",
|
||||
selectedItemsTitle: "Selected items",
|
||||
itemKey: item => item.id,
|
||||
renderItem: () => "",
|
||||
renderStagedItem: null, // hidden by default
|
||||
save: items => items,
|
||||
width: "80%",
|
||||
extraFooterContent: null,
|
||||
showCount: false,
|
||||
};
|
||||
|
||||
state = {
|
||||
searchTerm: "",
|
||||
loading: false,
|
||||
items: [],
|
||||
selected: [],
|
||||
saveInProgress: false,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react/sort-comp
|
||||
loadItems = (searchTerm = "") => {
|
||||
this.setState({ searchTerm, loading: true }, () => {
|
||||
this.props
|
||||
.searchItems(searchTerm)
|
||||
.then(items => {
|
||||
// If another search appeared while loading data - just reject this set
|
||||
if (this.state.searchTerm === searchTerm) {
|
||||
this.setState({ items, loading: false });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (this.state.searchTerm === searchTerm) {
|
||||
this.setState({ items: [], loading: false });
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
search = debounce(this.loadItems, 200);
|
||||
|
||||
componentDidMount() {
|
||||
this.loadItems();
|
||||
}
|
||||
|
||||
isSelected(item) {
|
||||
const key = this.props.itemKey(item);
|
||||
return !!find(this.state.selected, i => this.props.itemKey(i) === key);
|
||||
}
|
||||
|
||||
toggleItem(item) {
|
||||
if (this.isSelected(item)) {
|
||||
const key = this.props.itemKey(item);
|
||||
this.setState(({ selected }) => ({
|
||||
selected: filter(selected, i => this.props.itemKey(i) !== key),
|
||||
}));
|
||||
} else {
|
||||
this.setState(({ selected }) => ({
|
||||
selected: [...selected, item],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
save() {
|
||||
this.setState({ saveInProgress: true }, () => {
|
||||
const selectedItems = this.state.selected;
|
||||
Promise.resolve(this.props.save(selectedItems))
|
||||
.then(() => {
|
||||
this.props.dialog.close(selectedItems);
|
||||
})
|
||||
.catch(() => {
|
||||
this.setState({ saveInProgress: false });
|
||||
notification.error("Failed to save some of selected items.");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderItem(item, isStagedList) {
|
||||
const { renderItem, renderStagedItem } = this.props;
|
||||
const isSelected = this.isSelected(item);
|
||||
const render = isStagedList ? renderStagedItem : renderItem;
|
||||
|
||||
const { content, className, isDisabled } = render(item, { isSelected });
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
className={classNames("p-l-10", "p-r-10", { clickable: !isDisabled, disabled: isDisabled }, className)}
|
||||
onClick={isDisabled ? null : () => this.toggleItem(item)}>
|
||||
{content}
|
||||
</List.Item>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dialog, dialogTitle, inputPlaceholder } = this.props;
|
||||
const { selectedItemsTitle, renderStagedItem, width, showCount } = this.props;
|
||||
const { loading, saveInProgress, items, selected } = this.state;
|
||||
const hasResults = items.length > 0;
|
||||
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)" }}>
|
||||
{this.props.extraFooterContent}
|
||||
</span>
|
||||
<Button onClick={dialog.dismiss}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => this.save()}
|
||||
loading={saveInProgress}
|
||||
disabled={selected.length === 0}
|
||||
type="primary">
|
||||
Save
|
||||
{showCount && !isEmpty(selected) ? ` (${size(selected)})` : null}
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
<div className="d-flex align-items-center m-b-10">
|
||||
<div className="flex-fill">
|
||||
<Input.Search
|
||||
defaultValue={this.state.searchTerm}
|
||||
onChange={event => this.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">
|
||||
{loading && <LoadingState className="" />}
|
||||
{!loading && !hasResults && (
|
||||
<BigMessage icon="fa-search" message="No items match your search." className="" />
|
||||
)}
|
||||
{!loading && hasResults && (
|
||||
<List size="small" dataSource={items} renderItem={item => this.renderItem(item, false)} />
|
||||
)}
|
||||
</div>
|
||||
{renderStagedItem && (
|
||||
<div className="w-50 m-l-20 scrollbox">
|
||||
{selected.length > 0 && (
|
||||
<List size="small" dataSource={selected} renderItem={item => this.renderItem(item, true)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
return <List size="small" dataSource={items} renderItem={renderListItem} />;
|
||||
}
|
||||
|
||||
ItemsList.propTypes = {
|
||||
items: PropTypes.array,
|
||||
renderItem: PropTypes.func,
|
||||
onItemClick: PropTypes.func,
|
||||
};
|
||||
|
||||
ItemsList.defaultProps = {
|
||||
items: [],
|
||||
renderItem: () => {},
|
||||
onItemClick: () => {},
|
||||
};
|
||||
|
||||
function SelectItemsDialog({
|
||||
dialog,
|
||||
dialogTitle,
|
||||
inputPlaceholder,
|
||||
itemKey,
|
||||
renderItem,
|
||||
renderStagedItem,
|
||||
searchItems,
|
||||
selectedItemsTitle,
|
||||
width,
|
||||
showCount,
|
||||
extraFooterContent,
|
||||
}) {
|
||||
const [selectedItems, setSelectedItems] = useState([]);
|
||||
const [search, items, isLoading] = useSearchResults(searchItems, { initialResults: [] });
|
||||
const hasResults = items.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
search();
|
||||
}, [search]);
|
||||
|
||||
const isItemSelected = useCallback(
|
||||
item => {
|
||||
const key = itemKey(item);
|
||||
return !!find(selectedItems, i => itemKey(i) === key);
|
||||
},
|
||||
[selectedItems, itemKey]
|
||||
);
|
||||
|
||||
const toggleItem = useCallback(
|
||||
item => {
|
||||
if (isItemSelected(item)) {
|
||||
const key = itemKey(item);
|
||||
setSelectedItems(filter(selectedItems, i => itemKey(i) !== key));
|
||||
} else {
|
||||
setSelectedItems([...selectedItems, item]);
|
||||
}
|
||||
},
|
||||
[selectedItems, itemKey, isItemSelected]
|
||||
);
|
||||
|
||||
const save = useCallback(() => {
|
||||
dialog.close(selectedItems).catch(error => {
|
||||
if (error) {
|
||||
notification.error("Failed to save some of selected items.");
|
||||
}
|
||||
});
|
||||
}, [dialog, selectedItems]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
{...dialog.props}
|
||||
className="select-items-dialog"
|
||||
width={width}
|
||||
title={dialogTitle}
|
||||
footer={
|
||||
<div className="d-flex align-items-center">
|
||||
<span className="flex-fill m-r-5" style={{ textAlign: "left", color: "rgba(0, 0, 0, 0.5)" }}>
|
||||
{extraFooterContent}
|
||||
</span>
|
||||
<Button {...dialog.props.cancelButtonProps} onClick={dialog.dismiss}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
{...dialog.props.okButtonProps}
|
||||
onClick={save}
|
||||
disabled={selectedItems.length === 0 || dialog.props.okButtonProps.disabled}
|
||||
type="primary">
|
||||
Save
|
||||
{showCount && !isEmpty(selectedItems) ? ` (${size(selectedItems)})` : null}
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
<div className="d-flex align-items-center m-b-10">
|
||||
<div className="flex-fill">
|
||||
<Input.Search onChange={event => search(event.target.value)} placeholder={inputPlaceholder} autoFocus />
|
||||
</div>
|
||||
{renderStagedItem && (
|
||||
<div className="w-50 m-l-20">
|
||||
<h5 className="m-0">{selectedItemsTitle}</h5>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="d-flex align-items-stretch" style={{ minHeight: "30vh", maxHeight: "50vh" }}>
|
||||
<div className="flex-fill scrollbox">
|
||||
{isLoading && <LoadingState className="" />}
|
||||
{!isLoading && !hasResults && (
|
||||
<BigMessage icon="fa-search" message="No items match your search." className="" />
|
||||
)}
|
||||
{!isLoading && hasResults && (
|
||||
<ItemsList
|
||||
items={items}
|
||||
renderItem={item => renderItem(item, { isSelected: isItemSelected(item) })}
|
||||
onItemClick={toggleItem}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{renderStagedItem && (
|
||||
<div className="w-50 m-l-20 scrollbox">
|
||||
{selectedItems.length > 0 && (
|
||||
<ItemsList
|
||||
items={selectedItems}
|
||||
renderItem={item => renderStagedItem(item, { isSelected: true })}
|
||||
onItemClick={toggleItem}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
SelectItemsDialog.propTypes = {
|
||||
dialog: DialogPropType.isRequired,
|
||||
dialogTitle: PropTypes.string,
|
||||
inputPlaceholder: PropTypes.string,
|
||||
selectedItemsTitle: PropTypes.string,
|
||||
searchItems: PropTypes.func.isRequired, // (searchTerm: string): Promise<Items[]> if `searchTerm === ''` load all
|
||||
itemKey: PropTypes.func, // (item) => string|number - return key of item (by default `id`)
|
||||
// left list
|
||||
// (item, { isSelected }) => {
|
||||
// content: node, // item contents
|
||||
// className: string = '', // additional class for item wrapper
|
||||
// isDisabled: bool = false, // is item clickable or disabled
|
||||
// }
|
||||
renderItem: PropTypes.func,
|
||||
// right list; args/results save as for `renderItem`. if not specified - `renderItem` will be used
|
||||
renderStagedItem: PropTypes.func,
|
||||
width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
extraFooterContent: PropTypes.node,
|
||||
showCount: PropTypes.bool,
|
||||
};
|
||||
|
||||
SelectItemsDialog.defaultProps = {
|
||||
dialogTitle: "Add Items",
|
||||
inputPlaceholder: "Search...",
|
||||
selectedItemsTitle: "Selected items",
|
||||
itemKey: item => item.id,
|
||||
renderItem: () => "",
|
||||
renderStagedItem: null, // hidden by default
|
||||
width: "80%",
|
||||
extraFooterContent: null,
|
||||
showCount: false,
|
||||
};
|
||||
|
||||
export default wrapDialog(SelectItemsDialog);
|
||||
|
@ -1,164 +1,156 @@
|
||||
import { each, values, map, includes, first } from "lodash";
|
||||
import React from "react";
|
||||
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";
|
||||
|
||||
const { Option, OptGroup } = Select;
|
||||
function VisualizationSelect({ query, visualization, onChange }) {
|
||||
const visualizationGroups = useMemo(() => {
|
||||
return query ? groupBy(query.visualizations, "type") : {};
|
||||
}, [query]);
|
||||
|
||||
class AddWidgetDialog extends React.Component {
|
||||
static propTypes = {
|
||||
dashboard: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
dialog: DialogPropType.isRequired,
|
||||
onConfirm: PropTypes.func.isRequired,
|
||||
};
|
||||
const handleChange = useCallback(
|
||||
visualizationId => {
|
||||
const selectedVisualization = query ? find(query.visualizations, { id: visualizationId }) : null;
|
||||
onChange(selectedVisualization || null);
|
||||
},
|
||||
[query, onChange]
|
||||
);
|
||||
|
||||
state = {
|
||||
saveInProgress: false,
|
||||
selectedQuery: null,
|
||||
selectedVis: null,
|
||||
parameterMappings: [],
|
||||
};
|
||||
|
||||
selectQuery(selectedQuery) {
|
||||
// Clear previously selected query (if any)
|
||||
this.setState({
|
||||
selectedQuery: null,
|
||||
selectedVis: null,
|
||||
parameterMappings: [],
|
||||
});
|
||||
|
||||
if (selectedQuery) {
|
||||
Query.get({ id: selectedQuery.id }).then(query => {
|
||||
if (query) {
|
||||
const existingParamNames = map(this.props.dashboard.getParametersDefs(), param => param.name);
|
||||
this.setState({
|
||||
selectedQuery: query,
|
||||
parameterMappings: 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) {
|
||||
this.setState({ selectedVis: query.visualizations[0] });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!query) {
|
||||
return null;
|
||||
}
|
||||
|
||||
selectVisualization(query, visualizationId) {
|
||||
each(query.visualizations, visualization => {
|
||||
if (visualization.id === visualizationId) {
|
||||
this.setState({ selectedVis: visualization });
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
saveWidget() {
|
||||
const { selectedVis, parameterMappings } = this.state;
|
||||
|
||||
this.setState({ saveInProgress: true });
|
||||
|
||||
this.props
|
||||
.onConfirm(selectedVis, parameterMappings)
|
||||
.then(() => {
|
||||
this.props.dialog.close();
|
||||
})
|
||||
.catch(() => {
|
||||
notification.error("Widget could not be added");
|
||||
})
|
||||
.finally(() => {
|
||||
this.setState({ saveInProgress: false });
|
||||
});
|
||||
}
|
||||
|
||||
updateParamMappings(parameterMappings) {
|
||||
this.setState({ parameterMappings });
|
||||
}
|
||||
|
||||
renderVisualizationInput() {
|
||||
let visualizationGroups = {};
|
||||
if (this.state.selectedQuery) {
|
||||
each(this.state.selectedQuery.visualizations, vis => {
|
||||
visualizationGroups[vis.type] = visualizationGroups[vis.type] || [];
|
||||
visualizationGroups[vis.type].push(vis);
|
||||
});
|
||||
}
|
||||
visualizationGroups = values(visualizationGroups);
|
||||
return (
|
||||
<div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="choose-visualization">Choose Visualization</label>
|
||||
<Select
|
||||
id="choose-visualization"
|
||||
className="w-100"
|
||||
defaultValue={first(this.state.selectedQuery.visualizations).id}
|
||||
onChange={visualizationId => this.selectVisualization(this.state.selectedQuery, visualizationId)}>
|
||||
{visualizationGroups.map(visualizations => (
|
||||
<OptGroup label={visualizations[0].type} key={visualizations[0].type}>
|
||||
{visualizations.map(visualization => (
|
||||
<Option value={visualization.id} key={visualization.id}>
|
||||
{visualization.name}
|
||||
</Option>
|
||||
))}
|
||||
</OptGroup>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const existingParams = this.props.dashboard.getParametersDefs();
|
||||
const { dialog } = this.props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
{...dialog.props}
|
||||
title="Add Widget"
|
||||
onOk={() => this.saveWidget()}
|
||||
okButtonProps={{
|
||||
loading: this.state.saveInProgress,
|
||||
disabled: !this.state.selectedQuery,
|
||||
}}
|
||||
okText="Add to Dashboard"
|
||||
width={700}>
|
||||
<div data-test="AddWidgetDialog">
|
||||
<QuerySelector onChange={query => this.selectQuery(query)} />
|
||||
{this.state.selectedQuery && this.renderVisualizationInput()}
|
||||
|
||||
{this.state.parameterMappings.length > 0 && [
|
||||
<label key="parameters-title" htmlFor="parameter-mappings">
|
||||
Parameters
|
||||
</label>,
|
||||
<ParameterMappingListInput
|
||||
key="parameters-list"
|
||||
id="parameter-mappings"
|
||||
mappings={this.state.parameterMappings}
|
||||
existingParams={existingParams}
|
||||
onChange={mappings => this.updateParamMappings(mappings)}
|
||||
/>,
|
||||
]}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
VisualizationSelect.propTypes = {
|
||||
query: PropTypes.object,
|
||||
visualization: PropTypes.object,
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
VisualizationSelect.defaultProps = {
|
||||
query: null,
|
||||
visualization: null,
|
||||
onChange: () => {},
|
||||
};
|
||||
|
||||
function AddWidgetDialog({ dialog, dashboard }) {
|
||||
const [selectedQuery, setSelectedQuery] = useState(null);
|
||||
const [selectedVisualization, setSelectedVisualization] = useState(null);
|
||||
const [parameterMappings, setParameterMappings] = useState([]);
|
||||
|
||||
const selectQuery = useCallback(
|
||||
queryId => {
|
||||
// Clear previously selected query (if any)
|
||||
setSelectedQuery(null);
|
||||
setSelectedVisualization(null);
|
||||
setParameterMappings([]);
|
||||
|
||||
if (queryId) {
|
||||
Query.get({ id: queryId }).then(query => {
|
||||
if (query) {
|
||||
const existingParamNames = map(dashboard.getParametersDefs(), param => param.name);
|
||||
setSelectedQuery(query);
|
||||
setParameterMappings(
|
||||
map(query.getParametersDefs(), param => ({
|
||||
name: param.name,
|
||||
type: includes(existingParamNames, param.name)
|
||||
? MappingType.DashboardMapToExisting
|
||||
: MappingType.DashboardAddNew,
|
||||
mapTo: param.name,
|
||||
value: param.normalizedValue,
|
||||
title: "",
|
||||
param,
|
||||
}))
|
||||
);
|
||||
if (query.visualizations.length > 0) {
|
||||
setSelectedVisualization(first(query.visualizations));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
[dashboard]
|
||||
);
|
||||
|
||||
const saveWidget = useCallback(() => {
|
||||
dialog.close({ visualization: selectedVisualization, parameterMappings }).catch(() => {
|
||||
notification.error("Widget could not be added");
|
||||
});
|
||||
}, [dialog, selectedVisualization, parameterMappings]);
|
||||
|
||||
const existingParams = dashboard.getParametersDefs();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
{...dialog.props}
|
||||
title="Add Widget"
|
||||
onOk={saveWidget}
|
||||
okButtonProps={{
|
||||
...dialog.props.okButtonProps,
|
||||
disabled: !selectedQuery || dialog.props.okButtonProps.disabled,
|
||||
}}
|
||||
okText="Add to Dashboard"
|
||||
width={700}>
|
||||
<div data-test="AddWidgetDialog">
|
||||
<QuerySelector onChange={query => selectQuery(query ? query.id : null)} />
|
||||
|
||||
{selectedQuery && (
|
||||
<VisualizationSelect
|
||||
query={selectedQuery}
|
||||
visualization={selectedVisualization}
|
||||
onChange={setSelectedVisualization}
|
||||
/>
|
||||
)}
|
||||
|
||||
{parameterMappings.length > 0 && [
|
||||
<label key="parameters-title" htmlFor="parameter-mappings">
|
||||
Parameters
|
||||
</label>,
|
||||
<ParameterMappingListInput
|
||||
key="parameters-list"
|
||||
id="parameter-mappings"
|
||||
mappings={parameterMappings}
|
||||
existingParams={existingParams}
|
||||
onChange={setParameterMappings}
|
||||
/>,
|
||||
]}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
AddWidgetDialog.propTypes = {
|
||||
dialog: DialogPropType.isRequired,
|
||||
dashboard: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default wrapDialog(AddWidgetDialog);
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { toString } from "lodash";
|
||||
import { markdown } from "markdown";
|
||||
import { debounce } from "lodash";
|
||||
import React from "react";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import Modal from "antd/lib/modal";
|
||||
import Input from "antd/lib/input";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
@ -12,98 +13,78 @@ import notification from "@/services/notification";
|
||||
|
||||
import "./TextboxDialog.less";
|
||||
|
||||
class TextboxDialog extends React.Component {
|
||||
static propTypes = {
|
||||
dialog: DialogPropType.isRequired,
|
||||
onConfirm: PropTypes.func.isRequired,
|
||||
text: PropTypes.string,
|
||||
};
|
||||
function TextboxDialog({ dialog, isNew, ...props }) {
|
||||
const [text, setText] = useState(toString(props.text));
|
||||
const [preview, setPreview] = useState(null);
|
||||
|
||||
static defaultProps = {
|
||||
text: "",
|
||||
};
|
||||
useEffect(() => {
|
||||
setText(props.text);
|
||||
setPreview(markdown.toHTML(props.text));
|
||||
}, [props.text]);
|
||||
|
||||
updatePreview = debounce(() => {
|
||||
const text = this.state.text;
|
||||
this.setState({
|
||||
preview: markdown.toHTML(text),
|
||||
const [updatePreview] = useDebouncedCallback(() => {
|
||||
setPreview(markdown.toHTML(text));
|
||||
}, 200);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
event => {
|
||||
setText(event.target.value);
|
||||
updatePreview();
|
||||
},
|
||||
[updatePreview]
|
||||
);
|
||||
|
||||
const saveWidget = useCallback(() => {
|
||||
dialog.close(text).catch(() => {
|
||||
notification.error(isNew ? "Widget could not be added" : "Widget could not be saved");
|
||||
});
|
||||
}, 100);
|
||||
}, [dialog, isNew, text]);
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { text } = props;
|
||||
this.state = {
|
||||
saveInProgress: false,
|
||||
text,
|
||||
preview: markdown.toHTML(text),
|
||||
};
|
||||
}
|
||||
|
||||
onTextChanged = event => {
|
||||
this.setState({ text: event.target.value });
|
||||
this.updatePreview();
|
||||
};
|
||||
|
||||
saveWidget() {
|
||||
this.setState({ saveInProgress: true });
|
||||
|
||||
this.props
|
||||
.onConfirm(this.state.text)
|
||||
.then(() => {
|
||||
this.props.dialog.close();
|
||||
})
|
||||
.catch(() => {
|
||||
notification.error("Widget could not be added");
|
||||
})
|
||||
.finally(() => {
|
||||
this.setState({ saveInProgress: false });
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dialog } = this.props;
|
||||
const isNew = !this.props.text;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
{...dialog.props}
|
||||
title={isNew ? "Add Textbox" : "Edit Textbox"}
|
||||
onOk={() => this.saveWidget()}
|
||||
okButtonProps={{
|
||||
loading: this.state.saveInProgress,
|
||||
disabled: !this.state.text,
|
||||
}}
|
||||
okText={isNew ? "Add to Dashboard" : "Save"}
|
||||
width={500}
|
||||
wrapProps={{ "data-test": "TextboxDialog" }}>
|
||||
<div className="textbox-dialog">
|
||||
<Input.TextArea
|
||||
className="resize-vertical"
|
||||
rows="5"
|
||||
value={this.state.text}
|
||||
onChange={this.onTextChanged}
|
||||
autoFocus
|
||||
placeholder="This is where you write some text"
|
||||
/>
|
||||
<small>
|
||||
Supports basic{" "}
|
||||
<a target="_blank" rel="noopener noreferrer" href="https://www.markdownguide.org/cheat-sheet/#basic-syntax">
|
||||
<Tooltip title="Markdown guide opens in new window">Markdown</Tooltip>
|
||||
</a>
|
||||
.
|
||||
</small>
|
||||
{this.state.text && (
|
||||
<React.Fragment>
|
||||
<Divider dashed />
|
||||
<strong className="preview-title">Preview:</strong>
|
||||
<HtmlContent className="preview markdown">{this.state.preview}</HtmlContent>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Modal
|
||||
{...dialog.props}
|
||||
title={isNew ? "Add Textbox" : "Edit Textbox"}
|
||||
onOk={saveWidget}
|
||||
okText={isNew ? "Add to Dashboard" : "Save"}
|
||||
width={500}
|
||||
wrapProps={{ "data-test": "TextboxDialog" }}>
|
||||
<div className="textbox-dialog">
|
||||
<Input.TextArea
|
||||
className="resize-vertical"
|
||||
rows="5"
|
||||
value={text}
|
||||
onChange={handleInputChange}
|
||||
autoFocus
|
||||
placeholder="This is where you write some text"
|
||||
/>
|
||||
<small>
|
||||
Supports basic{" "}
|
||||
<a target="_blank" rel="noopener noreferrer" href="https://www.markdownguide.org/cheat-sheet/#basic-syntax">
|
||||
<Tooltip title="Markdown guide opens in new window">Markdown</Tooltip>
|
||||
</a>
|
||||
.
|
||||
</small>
|
||||
{text && (
|
||||
<React.Fragment>
|
||||
<Divider dashed />
|
||||
<strong className="preview-title">Preview:</strong>
|
||||
<HtmlContent className="preview markdown">{preview}</HtmlContent>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
TextboxDialog.propTypes = {
|
||||
dialog: DialogPropType.isRequired,
|
||||
isNew: PropTypes.bool,
|
||||
text: PropTypes.string,
|
||||
};
|
||||
|
||||
TextboxDialog.defaultProps = {
|
||||
isNew: false,
|
||||
text: "",
|
||||
};
|
||||
|
||||
export default wrapDialog(TextboxDialog);
|
||||
|
@ -13,12 +13,11 @@ function TextboxWidget(props) {
|
||||
const editTextBox = () => {
|
||||
TextboxDialog.showModal({
|
||||
text: widget.text,
|
||||
onConfirm: newText => {
|
||||
widget.text = newText;
|
||||
setText(newText);
|
||||
return widget.save();
|
||||
},
|
||||
}).result.catch(() => {}); // ignore dismiss
|
||||
}).onClose(newText => {
|
||||
widget.text = newText;
|
||||
setText(newText);
|
||||
return widget.save();
|
||||
});
|
||||
};
|
||||
|
||||
const TextboxMenuOptions = [
|
||||
|
@ -215,7 +215,7 @@ class VisualizationWidget extends React.Component {
|
||||
}
|
||||
|
||||
expandWidget = () => {
|
||||
ExpandedWidgetDialog.showModal({ widget: this.props.widget }).result.catch(() => {}); // ignore dismiss
|
||||
ExpandedWidgetDialog.showModal({ widget: this.props.widget });
|
||||
};
|
||||
|
||||
editParameterMappings = () => {
|
||||
@ -223,16 +223,14 @@ class VisualizationWidget extends React.Component {
|
||||
EditParameterMappingsDialog.showModal({
|
||||
dashboard,
|
||||
widget,
|
||||
})
|
||||
.result.then(valuesChanged => {
|
||||
// refresh widget if any parameter value has been updated
|
||||
if (valuesChanged) {
|
||||
onRefresh();
|
||||
}
|
||||
onParameterMappingsChange();
|
||||
this.setState({ localParameters: widget.getLocalParameters() });
|
||||
})
|
||||
.catch(() => {}); // ignore dismiss
|
||||
}).onClose(valuesChanged => {
|
||||
// refresh widget if any parameter value has been updated
|
||||
if (valuesChanged) {
|
||||
onRefresh();
|
||||
}
|
||||
onParameterMappingsChange();
|
||||
this.setState({ localParameters: widget.getLocalParameters() });
|
||||
});
|
||||
};
|
||||
|
||||
renderVisualization() {
|
||||
|
@ -65,7 +65,7 @@ function EmptyState({
|
||||
};
|
||||
|
||||
const showCreateDashboardDialog = useCallback(() => {
|
||||
CreateDashboardDialog.showModal().result.catch(() => {}); // ignore dismiss
|
||||
CreateDashboardDialog.showModal();
|
||||
}, []);
|
||||
|
||||
// Show if `onboardingMode=false` or any requested step not completed
|
||||
|
@ -1,94 +1,74 @@
|
||||
import React from "react";
|
||||
import { isNil, get } from "lodash";
|
||||
import React, { useCallback } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { get, isNil } from "lodash";
|
||||
import Button from "antd/lib/button";
|
||||
import Modal from "antd/lib/modal";
|
||||
import DynamicForm from "@/components/dynamic-form/DynamicForm";
|
||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||
|
||||
class QuerySnippetDialog extends React.Component {
|
||||
static propTypes = {
|
||||
dialog: DialogPropType.isRequired,
|
||||
querySnippet: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
readOnly: PropTypes.bool,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
};
|
||||
function QuerySnippetDialog({ querySnippet, dialog, readOnly }) {
|
||||
const handleSubmit = useCallback(
|
||||
(values, successCallback, errorCallback) => {
|
||||
const querySnippetId = get(querySnippet, "id");
|
||||
|
||||
static defaultProps = {
|
||||
querySnippet: null,
|
||||
readOnly: false,
|
||||
};
|
||||
if (isNil(values.description)) {
|
||||
values.description = "";
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { saving: false };
|
||||
}
|
||||
dialog
|
||||
.close(querySnippetId ? { id: querySnippetId, ...values } : values)
|
||||
.then(() => successCallback("Saved."))
|
||||
.catch(() => errorCallback("Failed saving snippet."));
|
||||
},
|
||||
[dialog, querySnippet]
|
||||
);
|
||||
|
||||
handleSubmit = (values, successCallback, errorCallback) => {
|
||||
const { querySnippet, dialog, onSubmit } = this.props;
|
||||
const querySnippetId = get(querySnippet, "id");
|
||||
const isEditing = !!get(querySnippet, "id");
|
||||
|
||||
if (isNil(values.description)) {
|
||||
values.description = "";
|
||||
}
|
||||
const formFields = [
|
||||
{ name: "trigger", title: "Trigger", type: "text", required: true, autoFocus: !isEditing },
|
||||
{ name: "description", title: "Description", type: "text" },
|
||||
{ name: "snippet", title: "Snippet", type: "ace", required: true },
|
||||
].map(field => ({ ...field, readOnly, initialValue: get(querySnippet, field.name, "") }));
|
||||
|
||||
this.setState({ saving: true });
|
||||
onSubmit(querySnippetId ? { id: querySnippetId, ...values } : values)
|
||||
.then(() => {
|
||||
dialog.close();
|
||||
successCallback("Saved.");
|
||||
})
|
||||
.catch(() => {
|
||||
this.setState({ saving: false });
|
||||
errorCallback("Failed saving snippet.");
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { saving } = this.state;
|
||||
const { querySnippet, dialog, readOnly } = this.props;
|
||||
const isEditing = !!get(querySnippet, "id");
|
||||
|
||||
const formFields = [
|
||||
{ name: "trigger", title: "Trigger", type: "text", required: true, autoFocus: !isEditing },
|
||||
{ name: "description", title: "Description", type: "text" },
|
||||
{ name: "snippet", title: "Snippet", type: "ace", required: true },
|
||||
].map(field => ({ ...field, readOnly, initialValue: get(querySnippet, field.name, "") }));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
{...dialog.props}
|
||||
title={isEditing ? querySnippet.trigger : "Create Query Snippet"}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={dialog.dismiss}>
|
||||
{readOnly ? "Close" : "Cancel"}
|
||||
</Button>,
|
||||
!readOnly && (
|
||||
<Button
|
||||
key="submit"
|
||||
htmlType="submit"
|
||||
loading={saving}
|
||||
disabled={readOnly}
|
||||
type="primary"
|
||||
form="querySnippetForm"
|
||||
data-test="SaveQuerySnippetButton">
|
||||
{isEditing ? "Save" : "Create"}
|
||||
</Button>
|
||||
),
|
||||
]}
|
||||
wrapProps={{
|
||||
"data-test": "QuerySnippetDialog",
|
||||
}}>
|
||||
<DynamicForm
|
||||
id="querySnippetForm"
|
||||
fields={formFields}
|
||||
onSubmit={this.handleSubmit}
|
||||
hideSubmitButton
|
||||
feedbackIcons
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Modal
|
||||
{...dialog.props}
|
||||
title={isEditing ? querySnippet.trigger : "Create Query Snippet"}
|
||||
footer={[
|
||||
<Button key="cancel" {...dialog.props.cancelButtonProps} onClick={dialog.dismiss}>
|
||||
{readOnly ? "Close" : "Cancel"}
|
||||
</Button>,
|
||||
!readOnly && (
|
||||
<Button
|
||||
key="submit"
|
||||
{...dialog.props.okButtonProps}
|
||||
disabled={readOnly || dialog.props.okButtonProps.disabled}
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
form="querySnippetForm"
|
||||
data-test="SaveQuerySnippetButton">
|
||||
{isEditing ? "Save" : "Create"}
|
||||
</Button>
|
||||
),
|
||||
]}
|
||||
wrapProps={{
|
||||
"data-test": "QuerySnippetDialog",
|
||||
}}>
|
||||
<DynamicForm id="querySnippetForm" fields={formFields} onSubmit={handleSubmit} hideSubmitButton feedbackIcons />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
QuerySnippetDialog.propTypes = {
|
||||
dialog: DialogPropType.isRequired,
|
||||
querySnippet: PropTypes.object,
|
||||
readOnly: PropTypes.bool,
|
||||
};
|
||||
|
||||
QuerySnippetDialog.defaultProps = {
|
||||
querySnippet: null,
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
export default wrapDialog(QuerySnippetDialog);
|
||||
|
@ -26,9 +26,7 @@ export class TagsControl extends React.Component {
|
||||
};
|
||||
|
||||
editTags = (tags, getAvailableTags) => {
|
||||
EditTagsDialog.showModal({ tags, getAvailableTags })
|
||||
.result.then(this.props.onEdit)
|
||||
.catch(() => {}); // ignore dismiss
|
||||
EditTagsDialog.showModal({ tags, getAvailableTags }).onClose(this.props.onEdit);
|
||||
};
|
||||
|
||||
renderEditButton() {
|
||||
|
@ -1,63 +1,46 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||
import Modal from "antd/lib/modal";
|
||||
import Alert from "antd/lib/alert";
|
||||
import DynamicForm from "@/components/dynamic-form/DynamicForm";
|
||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||
import recordEvent from "@/services/recordEvent";
|
||||
|
||||
class CreateUserDialog extends React.Component {
|
||||
static propTypes = {
|
||||
dialog: DialogPropType.isRequired,
|
||||
onCreate: PropTypes.func.isRequired,
|
||||
};
|
||||
function CreateUserDialog({ dialog }) {
|
||||
const [error, setError] = useState(null);
|
||||
const formRef = useRef();
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { savingUser: false, errorMessage: null };
|
||||
this.form = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
useEffect(() => {
|
||||
recordEvent("view", "page", "users/new");
|
||||
}
|
||||
}, []);
|
||||
|
||||
createUser = () => {
|
||||
this.form.current.validateFieldsAndScroll((err, values) => {
|
||||
if (!err) {
|
||||
this.setState({ savingUser: true });
|
||||
this.props
|
||||
.onCreate(values)
|
||||
.then(() => {
|
||||
this.props.dialog.close();
|
||||
})
|
||||
.catch(error => {
|
||||
// TODO: Is raw error message correct here?
|
||||
this.setState({ savingUser: false, errorMessage: error.message });
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
const createUser = useCallback(() => {
|
||||
if (formRef.current) {
|
||||
formRef.current.validateFieldsAndScroll((err, values) => {
|
||||
if (!err) {
|
||||
dialog.close(values).catch(setError);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [dialog]);
|
||||
|
||||
render() {
|
||||
const { savingUser, errorMessage } = this.state;
|
||||
const formFields = [
|
||||
{ name: "name", title: "Name", type: "text", autoFocus: true },
|
||||
{ name: "email", title: "Email", type: "email" },
|
||||
].map(field => ({ required: true, props: { onPressEnter: this.createUser }, ...field }));
|
||||
const formFields = useMemo(() => {
|
||||
const common = { required: true, props: { onPressEnter: createUser } };
|
||||
return [
|
||||
{ ...common, name: "name", title: "Name", type: "text", autoFocus: true },
|
||||
{ ...common, name: "email", title: "Email", type: "email" },
|
||||
];
|
||||
}, [createUser]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
{...this.props.dialog.props}
|
||||
title="Create a New User"
|
||||
okText="Create"
|
||||
okButtonProps={{ loading: savingUser }}
|
||||
onOk={() => this.createUser()}>
|
||||
<DynamicForm fields={formFields} ref={this.form} hideSubmitButton />
|
||||
{errorMessage && <Alert message={errorMessage} type="error" showIcon />}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Modal {...dialog.props} title="Create a New User" okText="Create" onOk={createUser}>
|
||||
<DynamicForm fields={formFields} ref={formRef} hideSubmitButton />
|
||||
{error && <Alert message={error.message} type="error" showIcon />}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
CreateUserDialog.propTypes = {
|
||||
dialog: DialogPropType.isRequired,
|
||||
};
|
||||
|
||||
export default wrapDialog(CreateUserDialog);
|
||||
|
@ -42,7 +42,7 @@ export default class UserEdit extends React.Component {
|
||||
}
|
||||
|
||||
changePassword = () => {
|
||||
ChangePasswordDialog.showModal({ user: this.props.user }).result.catch(() => {}); // ignore dismiss
|
||||
ChangePasswordDialog.showModal({ user: this.props.user });
|
||||
};
|
||||
|
||||
sendPasswordReset = () => {
|
||||
|
@ -11,7 +11,7 @@ export default function useSearchResults(fetch, { initialResults = null, debounc
|
||||
setIsLoading(true);
|
||||
currentSearchTerm.current = searchTerm;
|
||||
fetch(searchTerm)
|
||||
.catch(() => null)
|
||||
.catch(() => initialResults)
|
||||
.then(data => {
|
||||
if (searchTerm === currentSearchTerm.current && !isDestroyed.current) {
|
||||
setResult(data);
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { without, find, includes, map, toLower } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { without, find, isEmpty, includes, map } from "lodash";
|
||||
|
||||
import SelectItemsDialog from "@/components/SelectItemsDialog";
|
||||
import { Destination as DestinationType, UserProfile as UserType } from "@/components/proptypes";
|
||||
@ -98,9 +98,8 @@ export default class AlertDestinations extends React.Component {
|
||||
dialogTitle: "Add Existing Alert Destinations",
|
||||
inputPlaceholder: "Search destinations...",
|
||||
searchItems: searchTerm => {
|
||||
searchTerm = searchTerm.toLowerCase();
|
||||
const filtered = dests.filter(d => isEmpty(searchTerm) || includes(d.name.toLowerCase(), searchTerm));
|
||||
return Promise.resolve(filtered);
|
||||
searchTerm = toLower(searchTerm);
|
||||
return Promise.resolve(dests.filter(d => includes(toLower(d.name), searchTerm)));
|
||||
},
|
||||
renderItem: (item, { isSelected }) => {
|
||||
const alreadyInGroup = !!find(subs, s => s.destination.id === item.id);
|
||||
@ -117,17 +116,17 @@ export default class AlertDestinations extends React.Component {
|
||||
className: isSelected || alreadyInGroup ? "selected" : "",
|
||||
};
|
||||
},
|
||||
save: items => {
|
||||
const promises = map(items, item => this.subscribe(item));
|
||||
return Promise.all(promises)
|
||||
.then(() => {
|
||||
notification.success("Subscribed.");
|
||||
})
|
||||
.catch(() => {
|
||||
notification.error("Failed saving subscription.");
|
||||
});
|
||||
},
|
||||
}).result.catch(() => {}); // ignore dismiss
|
||||
}).onClose(items => {
|
||||
const promises = map(items, item => this.subscribe(item));
|
||||
return Promise.all(promises)
|
||||
.then(() => {
|
||||
notification.success("Subscribed.");
|
||||
})
|
||||
.catch(() => {
|
||||
notification.error("Failed saving subscription.");
|
||||
return Promise.reject(null); // keep dialog visible but suppress its default error message
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
onUserEmailToggle = sub => {
|
||||
|
@ -56,7 +56,7 @@ function useDashboard(dashboardData) {
|
||||
aclUrl,
|
||||
context: "dashboard",
|
||||
author: dashboard.user,
|
||||
}).result.catch(() => {}); // ignore dismiss
|
||||
});
|
||||
}, [dashboard]);
|
||||
|
||||
const updateDashboard = useCallback(
|
||||
@ -142,40 +142,42 @@ function useDashboard(dashboardData) {
|
||||
}, [dashboard]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const showShareDashboardDialog = useCallback(() => {
|
||||
const handleDialogClose = () => setDashboard(currentDashboard => extend({}, currentDashboard));
|
||||
|
||||
ShareDashboardDialog.showModal({
|
||||
dashboard,
|
||||
hasOnlySafeQueries,
|
||||
})
|
||||
.result.catch(() => {}) // ignore dismiss
|
||||
.finally(() => setDashboard(currentDashboard => extend({}, currentDashboard)));
|
||||
.onClose(handleDialogClose)
|
||||
.onDismiss(handleDialogClose);
|
||||
}, [dashboard, hasOnlySafeQueries]);
|
||||
|
||||
const showAddTextboxDialog = useCallback(() => {
|
||||
TextboxDialog.showModal({
|
||||
dashboard,
|
||||
onConfirm: text =>
|
||||
dashboard.addWidget(text).then(() => setDashboard(currentDashboard => extend({}, currentDashboard))),
|
||||
}).result.catch(() => {}); // ignore dismiss
|
||||
isNew: true,
|
||||
}).onClose(text =>
|
||||
dashboard.addWidget(text).then(() => setDashboard(currentDashboard => extend({}, currentDashboard)))
|
||||
);
|
||||
}, [dashboard]);
|
||||
|
||||
const showAddWidgetDialog = useCallback(() => {
|
||||
AddWidgetDialog.showModal({
|
||||
dashboard,
|
||||
onConfirm: (visualization, parameterMappings) =>
|
||||
dashboard
|
||||
.addWidget(visualization, {
|
||||
parameterMappings: editableMappingsToParameterMappings(parameterMappings),
|
||||
})
|
||||
.then(widget => {
|
||||
const widgetsToSave = [
|
||||
widget,
|
||||
...synchronizeWidgetTitles(widget.options.parameterMappings, dashboard.widgets),
|
||||
];
|
||||
return Promise.all(widgetsToSave.map(w => w.save())).then(() =>
|
||||
setDashboard(currentDashboard => extend({}, currentDashboard))
|
||||
);
|
||||
}),
|
||||
}).result.catch(() => {}); // ignore dismiss
|
||||
}).onClose(({ visualization, parameterMappings }) =>
|
||||
dashboard
|
||||
.addWidget(visualization, {
|
||||
parameterMappings: editableMappingsToParameterMappings(parameterMappings),
|
||||
})
|
||||
.then(widget => {
|
||||
const widgetsToSave = [
|
||||
widget,
|
||||
...synchronizeWidgetTitles(widget.options.parameterMappings, dashboard.widgets),
|
||||
];
|
||||
return Promise.all(widgetsToSave.map(w => w.save())).then(() =>
|
||||
setDashboard(currentDashboard => extend({}, currentDashboard))
|
||||
);
|
||||
})
|
||||
);
|
||||
}, [dashboard]);
|
||||
|
||||
const [refreshRate, setRefreshRate, disableRefreshRate] = useRefreshRateHandler(refreshDashboard);
|
||||
|
@ -84,16 +84,16 @@ class DataSourcesList extends React.Component {
|
||||
onCreate: this.createDataSource,
|
||||
});
|
||||
|
||||
this.newDataSourceDialog.result
|
||||
.then((result = {}) => {
|
||||
this.newDataSourceDialog
|
||||
.onClose((result = {}) => {
|
||||
this.newDataSourceDialog = null;
|
||||
if (result.success) {
|
||||
navigateTo(`data_sources/${result.data.id}`);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
navigateTo("data_sources", true);
|
||||
.onDismiss(() => {
|
||||
this.newDataSourceDialog = null;
|
||||
navigateTo("data_sources", true);
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -76,12 +76,12 @@ class DestinationsList extends React.Component {
|
||||
imageFolder: IMG_ROOT,
|
||||
onCreate: this.createDestination,
|
||||
})
|
||||
.result.then((result = {}) => {
|
||||
.onClose((result = {}) => {
|
||||
if (result.success) {
|
||||
navigateTo(`destinations/${result.data.id}`);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
.onDismiss(() => {
|
||||
navigateTo("destinations", true);
|
||||
});
|
||||
};
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { filter, map, includes } from "lodash";
|
||||
import { filter, map, includes, toLower } from "lodash";
|
||||
import React from "react";
|
||||
import Button from "antd/lib/button";
|
||||
import Dropdown from "antd/lib/dropdown";
|
||||
@ -140,8 +140,8 @@ class GroupDataSources extends React.Component {
|
||||
inputPlaceholder: "Search data sources...",
|
||||
selectedItemsTitle: "New Data Sources",
|
||||
searchItems: searchTerm => {
|
||||
searchTerm = searchTerm.toLowerCase();
|
||||
return allDataSources.then(items => filter(items, ds => ds.name.toLowerCase().includes(searchTerm)));
|
||||
searchTerm = toLower(searchTerm);
|
||||
return allDataSources.then(items => filter(items, ds => includes(toLower(ds.name), searchTerm)));
|
||||
},
|
||||
renderItem: (item, { isSelected }) => {
|
||||
const alreadyInGroup = includes(alreadyAddedDataSources, item.id);
|
||||
@ -162,15 +162,10 @@ class GroupDataSources extends React.Component {
|
||||
</DataSourcePreviewCard>
|
||||
),
|
||||
}),
|
||||
save: items => {
|
||||
const promises = map(items, ds => Group.addDataSource({ id: this.groupId }, { data_source_id: ds.id }));
|
||||
return Promise.all(promises);
|
||||
},
|
||||
})
|
||||
.result.catch(() => {}) // ignore dismiss
|
||||
.finally(() => {
|
||||
this.props.controller.update();
|
||||
});
|
||||
}).onClose(items => {
|
||||
const promises = map(items, ds => Group.addDataSource({ id: this.groupId }, { data_source_id: ds.id }));
|
||||
return Promise.all(promises).then(() => this.props.controller.update());
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
|
@ -125,15 +125,10 @@ class GroupMembers extends React.Component {
|
||||
</UserPreviewCard>
|
||||
),
|
||||
}),
|
||||
save: items => {
|
||||
const promises = map(items, u => Group.addMember({ id: this.groupId }, { user_id: u.id }));
|
||||
return Promise.all(promises);
|
||||
},
|
||||
})
|
||||
.result.catch(() => {}) // ignore dismiss
|
||||
.finally(() => {
|
||||
this.props.controller.update();
|
||||
});
|
||||
}).onClose(items => {
|
||||
const promises = map(items, u => Group.addMember({ id: this.groupId }, { user_id: u.id }));
|
||||
return Promise.all(promises).then(() => this.props.controller.update());
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
|
@ -73,11 +73,9 @@ class GroupsList extends React.Component {
|
||||
];
|
||||
|
||||
createGroup = () => {
|
||||
CreateGroupDialog.showModal()
|
||||
.result.then(group => {
|
||||
Group.create(group).then(newGroup => navigateTo(`groups/${newGroup.id}`));
|
||||
})
|
||||
.catch(() => {}); // ignore dismiss
|
||||
CreateGroupDialog.showModal().onClose(group =>
|
||||
Group.create(group).then(newGroup => navigateTo(`groups/${newGroup.id}`))
|
||||
);
|
||||
};
|
||||
|
||||
onGroupDeleted = () => {
|
||||
|
@ -11,7 +11,6 @@ import Resizable from "@/components/Resizable";
|
||||
import Parameters from "@/components/Parameters";
|
||||
import EditInPlace from "@/components/EditInPlace";
|
||||
import QueryEditor from "@/components/queries/QueryEditor";
|
||||
import { Query } from "@/services/query";
|
||||
import recordEvent from "@/services/recordEvent";
|
||||
import { ExecutionStatus } from "@/services/query-result";
|
||||
|
||||
|
@ -15,12 +15,10 @@ export default function useAddNewParameterDialog(query, onParameterAdded) {
|
||||
value: null,
|
||||
},
|
||||
existingParams: map(query.getParameters().get(), p => p.name),
|
||||
})
|
||||
.result.then(param => {
|
||||
const newQuery = query.clone();
|
||||
param = newQuery.getParameters().add(param);
|
||||
onParameterAddedRef.current(newQuery, param);
|
||||
})
|
||||
.catch(() => {}); // ignore dismiss
|
||||
}).onClose(param => {
|
||||
const newQuery = query.clone();
|
||||
param = newQuery.getParameters().add(param);
|
||||
onParameterAddedRef.current(newQuery, param);
|
||||
});
|
||||
}, [query]);
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ export default function useAddToDashboardDialog(query) {
|
||||
return useCallback(
|
||||
visualizationId => {
|
||||
const visualization = find(query.visualizations, { id: visualizationId });
|
||||
AddToDashboardDialog.showModal({ visualization }).result.catch(() => {}); // ignore dismiss
|
||||
AddToDashboardDialog.showModal({ visualization });
|
||||
},
|
||||
[query.visualizations]
|
||||
);
|
||||
|
@ -7,10 +7,8 @@ export default function useApiKeyDialog(query, onChange) {
|
||||
onChangeRef.current = isFunction(onChange) ? onChange : () => {};
|
||||
|
||||
return useCallback(() => {
|
||||
ApiKeyDialog.showModal({ query })
|
||||
.result.then(updatedQuery => {
|
||||
onChangeRef.current(updatedQuery);
|
||||
})
|
||||
.catch(() => {}); // ignore dismiss
|
||||
ApiKeyDialog.showModal({ query }).onClose(updatedQuery => {
|
||||
onChangeRef.current(updatedQuery);
|
||||
});
|
||||
}, [query]);
|
||||
}
|
||||
|
@ -25,11 +25,9 @@ export default function useEditScheduleDialog(query, onChange) {
|
||||
ScheduleDialog.showModal({
|
||||
schedule: query.schedule,
|
||||
refreshOptions,
|
||||
})
|
||||
.result.then(schedule => {
|
||||
recordEvent("edit_schedule", "query", query.id);
|
||||
updateQuery({ schedule });
|
||||
})
|
||||
.catch(() => {}); // ignore dismiss
|
||||
}).onClose(schedule => {
|
||||
recordEvent("edit_schedule", "query", query.id);
|
||||
updateQuery({ schedule });
|
||||
});
|
||||
}, [query.id, query.schedule, queryFlags.canEdit, queryFlags.canSchedule, updateQuery]);
|
||||
}
|
||||
|
@ -13,15 +13,13 @@ export default function useEditVisualizationDialog(query, queryResult, onChange)
|
||||
query,
|
||||
visualization,
|
||||
queryResult,
|
||||
})
|
||||
.result.then(updatedVisualization => {
|
||||
const filteredVisualizations = filter(query.visualizations, v => v.id !== updatedVisualization.id);
|
||||
onChangeRef.current(
|
||||
extend(query.clone(), { visualizations: [...filteredVisualizations, updatedVisualization] }),
|
||||
updatedVisualization
|
||||
);
|
||||
})
|
||||
.catch(() => {}); // ignore dismiss
|
||||
}).onClose(updatedVisualization => {
|
||||
const filteredVisualizations = filter(query.visualizations, v => v.id !== updatedVisualization.id);
|
||||
onChangeRef.current(
|
||||
extend(query.clone(), { visualizations: [...filteredVisualizations, updatedVisualization] }),
|
||||
updatedVisualization
|
||||
);
|
||||
});
|
||||
},
|
||||
[query, queryResult]
|
||||
);
|
||||
|
@ -6,7 +6,7 @@ export default function useEmbedDialog(query) {
|
||||
return useCallback(
|
||||
(unusedQuery, visualizationId) => {
|
||||
const visualization = find(query.visualizations, { id: visualizationId });
|
||||
EmbedQueryDialog.showModal({ query, visualization }).result.catch(() => {}); // ignore dismiss
|
||||
EmbedQueryDialog.showModal({ query, visualization });
|
||||
},
|
||||
[query]
|
||||
);
|
||||
|
@ -7,6 +7,6 @@ export default function usePermissionsEditorDialog(query) {
|
||||
aclUrl: `api/queries/${query.id}/acl`,
|
||||
context: "query",
|
||||
author: query.user,
|
||||
}).result.catch(() => {}); // ignore dismiss
|
||||
});
|
||||
}, [query.id, query.user]);
|
||||
}
|
||||
|
@ -121,16 +121,18 @@ class QuerySnippetsList extends React.Component {
|
||||
showSnippetDialog = (querySnippet = null) => {
|
||||
const canSave = !querySnippet || canEditQuerySnippet(querySnippet);
|
||||
navigateTo("query_snippets/" + get(querySnippet, "id", "new"), true);
|
||||
const goToSnippetsList = () => navigateTo("query_snippets", true);
|
||||
QuerySnippetDialog.showModal({
|
||||
querySnippet,
|
||||
onSubmit: this.saveQuerySnippet,
|
||||
readOnly: !canSave,
|
||||
})
|
||||
.result.then(() => this.props.controller.update())
|
||||
.catch(() => {}) // ignore dismiss
|
||||
.finally(() => {
|
||||
navigateTo("query_snippets", true);
|
||||
});
|
||||
.onClose(querySnippet =>
|
||||
this.saveQuerySnippet(querySnippet).then(() => {
|
||||
this.props.controller.update();
|
||||
goToSnippetsList();
|
||||
})
|
||||
)
|
||||
.onDismiss(goToSnippetsList);
|
||||
};
|
||||
|
||||
render() {
|
||||
|
@ -164,14 +164,19 @@ class UsersList extends React.Component {
|
||||
|
||||
showCreateUserDialog = () => {
|
||||
if (policy.isCreateUserEnabled()) {
|
||||
CreateUserDialog.showModal({ onCreate: this.createUser })
|
||||
.result.then(() => this.props.controller.update())
|
||||
.catch(() => {}) // ignore dismiss
|
||||
.finally(() => {
|
||||
if (this.props.controller.params.isNewUserPage) {
|
||||
navigateTo("users");
|
||||
}
|
||||
});
|
||||
const goToUsersList = () => {
|
||||
if (this.props.controller.params.isNewUserPage) {
|
||||
navigateTo("users");
|
||||
}
|
||||
};
|
||||
CreateUserDialog.showModal()
|
||||
.onClose(values =>
|
||||
this.createUser(values).then(() => {
|
||||
this.props.controller.update();
|
||||
goToUsersList();
|
||||
})
|
||||
)
|
||||
.onDismiss(goToUsersList);
|
||||
}
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user