feat: Duplicate chart (#109)

* Add duplicate chart button
* Add confirm modal for delete chart and delete dashboard actions

`docker compose -f docker-compose.dev.yml up -d --no-deps --build app` after pulling this branch

https://github.com/hyperdxio/hyperdx/assets/149748269/68f8facc-9d8f-4ebf-9dc2-937d65dbc89f
This commit is contained in:
Shorpo 2023-11-17 00:26:52 -07:00 committed by GitHub
parent 283f32ac9c
commit fe41b150de
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 130 additions and 7 deletions

View file

@ -0,0 +1,5 @@
---
'@hyperdx/app': minor
---
feat: Add dashboard delete confirmations and duplicate chart button

View file

@ -31,6 +31,7 @@
"esbuild": "^0.14.47",
"fuse.js": "^6.6.2",
"immer": "^9.0.21",
"jotai": "^2.5.1",
"ky": "^0.30.0",
"ky-universal": "^0.10.1",
"lodash": "^4.17.21",

View file

@ -7,6 +7,7 @@ import { ReactQueryDevtools } from 'react-query/devtools';
import { ToastContainer } from 'react-toastify';
import { NextAdapter } from 'next-query-params';
import { QueryParamProvider } from 'use-query-params';
import { useConfirmModal } from '../src/useConfirm';
import * as config from '../src/config';
import { QueryParamProvider as HDXQueryParamProvider } from '../src/useQueryParam';
@ -23,6 +24,8 @@ const queryClient = new QueryClient();
import HyperDX from '@hyperdx/browser';
export default function MyApp({ Component, pageProps }: AppProps) {
const confirmModal = useConfirmModal();
// port to react query ? (needs to wrap with QueryClientProvider)
useEffect(() => {
fetch('/api/config')
@ -58,7 +61,7 @@ export default function MyApp({ Component, pageProps }: AppProps) {
<Head>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.1/font/bootstrap-icons.css"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"
/>
<link rel="icon" type="image/png" sizes="32x32" href="/Icon32.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
@ -85,6 +88,7 @@ export default function MyApp({ Component, pageProps }: AppProps) {
<ToastContainer position="bottom-right" theme="dark" />
<Component {...pageProps} />
<ReactQueryDevtools initialIsOpen={false} />
{confirmModal}
</UserPreferencesProvider>
</QueryClientProvider>
</QueryParamProvider>

View file

@ -47,6 +47,7 @@ import {
import HDXNumberChart from './HDXNumberChart';
import GranularityPicker from './GranularityPicker';
import HDXTableChart from './HDXTableChart';
import { useConfirm } from './useConfirm';
import type { Chart } from './EditChartForm';
@ -54,6 +55,8 @@ import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { ZIndexContext } from './zIndex';
const makeId = () => Math.floor(100000000 * Math.random()).toString(36);
const ReactGridLayout = WidthProvider(RGL);
type Dashboard = {
@ -81,6 +84,7 @@ const Tile = forwardRef(
{
chart,
dateRange,
onDuplicateClick,
onEditClick,
onDeleteClick,
query,
@ -99,6 +103,7 @@ const Tile = forwardRef(
}: {
chart: Chart;
dateRange: [Date, Date];
onDuplicateClick: () => void;
onEditClick: () => void;
onDeleteClick: () => void;
query: string;
@ -203,11 +208,21 @@ const Tile = forwardRef(
<span className="bi bi-bell" />
</div>
)}
<Button
variant="link"
className="text-muted-hover p-0"
size="sm"
onClick={onDuplicateClick}
title="Duplicate"
>
<i className="bi bi-copy fs-8"></i>
</Button>
<Button
variant="link"
className="text-muted-hover p-0"
size="sm"
onClick={onEditClick}
title="Edit"
>
<i className="bi bi-pencil"></i>
</Button>
@ -216,6 +231,7 @@ const Tile = forwardRef(
className="text-muted-hover p-0"
size="sm"
onClick={onDeleteClick}
title="Edit"
>
<i className="bi bi-trash"></i>
</Button>
@ -566,6 +582,8 @@ export default function DashboardPage() {
const { dashboardId, config } = router.query;
const queryClient = useQueryClient();
const confirm = useConfirm();
const [localDashboard, setLocalDashboard] = useQueryParam<Dashboard>(
'config',
withDefault(JsonParam, {
@ -662,7 +680,7 @@ export default function DashboardPage() {
const onAddChart = () => {
setEditedChart({
id: Math.floor(100000000 * Math.random()).toString(36),
id: makeId(),
name: 'My New Chart',
x: 0,
y: 0,
@ -700,8 +718,29 @@ export default function DashboardPage() {
onEditClick={() => setEditedChart(chart)}
granularity={granularityQuery}
hasAlert={dashboard?.alerts?.some(a => a.chartId === chart.id)}
onDeleteClick={() => {
onDuplicateClick={async () => {
if (dashboard != null) {
if (!(await confirm(`Duplicate ${chart.name}?`, 'Duplicate'))) {
return;
}
setDashboard({
...dashboard,
charts: [
...dashboard.charts,
{
...chart,
id: makeId(),
name: `${chart.name} (Copy)`,
},
],
});
}
}}
onDeleteClick={async () => {
if (dashboard != null) {
if (!(await confirm(`Delete ${chart.name}?`, 'Delete'))) {
return;
}
setDashboard({
...dashboard,
charts: dashboard.charts.filter(c => c.id !== chart.id),
@ -713,10 +752,11 @@ export default function DashboardPage() {
}),
[
dashboard,
searchedTimeRange,
setDashboard,
dashboardQuery,
searchedTimeRange,
granularityQuery,
confirm,
setDashboard,
],
);
@ -920,7 +960,12 @@ export default function DashboardPage() {
variant="dark"
className="text-muted-hover text-nowrap"
size="sm"
onClick={() => {
onClick={async () => {
if (
!(await confirm(`Delete ${dashboard?.name}?`, 'Delete'))
) {
return;
}
deleteDashboard.mutate(
{
id: `${dashboardId}`,

View file

@ -0,0 +1,63 @@
import * as React from 'react';
import { atom, useAtomValue, useSetAtom } from 'jotai';
import Modal from 'react-bootstrap/Modal';
import Button from 'react-bootstrap/Button';
type ConfirmAtom = {
message: string;
confirmLabel?: string;
onConfirm: () => void;
onClose?: () => void;
} | null;
const confirmAtom = atom<ConfirmAtom>(null);
export const useConfirm = () => {
const setConfirm = useSetAtom(confirmAtom);
return React.useCallback(
async (message: string, confirmLabel?: string): Promise<boolean> => {
return new Promise(resolve => {
setConfirm({
message,
confirmLabel,
onConfirm: () => {
resolve(true);
setConfirm(null);
},
onClose: () => {
resolve(false);
setConfirm(null);
},
});
});
},
[setConfirm],
);
};
export const useConfirmModal = () => {
const confirm = useAtomValue(confirmAtom);
const setConfirm = useSetAtom(confirmAtom);
const handleClose = React.useCallback(() => {
confirm?.onClose?.();
setConfirm(null);
}, [confirm, setConfirm]);
return confirm ? (
<Modal show onHide={handleClose}>
<Modal.Body className="bg-hdx-dark">
{confirm.message}
<div className="mt-3 d-flex justify-content-end gap-2">
<Button variant="secondary" onClick={handleClose} size="sm">
Cancel
</Button>
<Button variant="success" onClick={confirm.onConfirm} size="sm">
{confirm.confirmLabel || 'OK'}
</Button>
</div>
</Modal.Body>
</Modal>
) : null;
};

View file

@ -4655,7 +4655,7 @@
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@>=16", "@types/react@>=16.9.11", "@types/react@^17", "@types/react@^17.0.52":
"@types/react@*", "@types/react@17.0.52", "@types/react@>=16", "@types/react@>=16.9.11", "@types/react@^17", "@types/react@^17.0.52":
version "17.0.52"
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.52.tgz#10d8b907b5c563ac014a541f289ae8eaa9bf2e9b"
integrity sha512-vwk8QqVODi0VaZZpDXQCmEmiOuyjEFPY7Ttaw5vjM112LOq37yz1CDJGrRJwA1fYEq4Iitd5rnjd1yWAc/bT+A==
@ -10218,6 +10218,11 @@ joi@^17.3.0:
"@sideway/formula" "^3.0.1"
"@sideway/pinpoint" "^2.0.0"
jotai@^2.5.1:
version "2.5.1"
resolved "https://registry.yarnpkg.com/jotai/-/jotai-2.5.1.tgz#eed05a32a4ac1264c531a77e86478f7ad3197ca3"
integrity sha512-vanPCCSuHczUXNbVh/iUunuMfrWRL4FdBtAbTRmrfqezJcKb8ybBTg8iivyYuUHapjcDETyJe1E4inlo26bVHA==
js-cookie@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8"