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"