From fe41b150de8065634bf3e5f209e751441caa6513 Mon Sep 17 00:00:00 2001
From: Shorpo <149748269+svc-shorpo@users.noreply.github.com>
Date: Fri, 17 Nov 2023 00:26:52 -0700
Subject: [PATCH] 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
---
.changeset/chilled-icons-lay.md | 5 +++
packages/app/package.json | 1 +
packages/app/pages/_app.tsx | 6 ++-
packages/app/src/DashboardPage.tsx | 55 +++++++++++++++++++++++---
packages/app/src/useConfirm.tsx | 63 ++++++++++++++++++++++++++++++
yarn.lock | 7 +++-
6 files changed, 130 insertions(+), 7 deletions(-)
create mode 100644 .changeset/chilled-icons-lay.md
create mode 100644 packages/app/src/useConfirm.tsx
diff --git a/.changeset/chilled-icons-lay.md b/.changeset/chilled-icons-lay.md
new file mode 100644
index 000000000..e350a5f21
--- /dev/null
+++ b/.changeset/chilled-icons-lay.md
@@ -0,0 +1,5 @@
+---
+'@hyperdx/app': minor
+---
+
+feat: Add dashboard delete confirmations and duplicate chart button
diff --git a/packages/app/package.json b/packages/app/package.json
index 419b29924..a523997d6 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -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",
diff --git a/packages/app/pages/_app.tsx b/packages/app/pages/_app.tsx
index 719c1cec1..3d90f4a3a 100644
--- a/packages/app/pages/_app.tsx
+++ b/packages/app/pages/_app.tsx
@@ -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) {
@@ -85,6 +88,7 @@ export default function MyApp({ Component, pageProps }: AppProps) {
+ {confirmModal}
diff --git a/packages/app/src/DashboardPage.tsx b/packages/app/src/DashboardPage.tsx
index 53c61ac79..11d3748b4 100644
--- a/packages/app/src/DashboardPage.tsx
+++ b/packages/app/src/DashboardPage.tsx
@@ -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(
)}
+
@@ -216,6 +231,7 @@ const Tile = forwardRef(
className="text-muted-hover p-0"
size="sm"
onClick={onDeleteClick}
+ title="Edit"
>
@@ -566,6 +582,8 @@ export default function DashboardPage() {
const { dashboardId, config } = router.query;
const queryClient = useQueryClient();
+ const confirm = useConfirm();
+
const [localDashboard, setLocalDashboard] = useQueryParam(
'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}`,
diff --git a/packages/app/src/useConfirm.tsx b/packages/app/src/useConfirm.tsx
new file mode 100644
index 000000000..8b4e8c19f
--- /dev/null
+++ b/packages/app/src/useConfirm.tsx
@@ -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(null);
+
+export const useConfirm = () => {
+ const setConfirm = useSetAtom(confirmAtom);
+
+ return React.useCallback(
+ async (message: string, confirmLabel?: string): Promise => {
+ 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 ? (
+
+
+ {confirm.message}
+
+
+
+
+
+
+ ) : null;
+};
diff --git a/yarn.lock b/yarn.lock
index ce0554575..f1c934898 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"