From 96d4584f747d049c5cf86ae9fd78fcf9577b1b80 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 18:47:19 +0000 Subject: [PATCH 1/5] feat: move a report to another dashboard Adds a report.move mutation, a MoveReport modal and a "Move to dashboard" item on the report card menu. The dashboard picker is pulled out of the save-report modal into components/dashboards/select-dashboard.tsx so both modals use the same one; it takes an excludeDashboardId prop to hide the dashboard the report already sits on. The move deletes the ReportLayout row in the same transaction as the update. Grid position is keyed by reportId, not by dashboard, so a report that keeps its old x/y lands on top of whatever already occupies those coordinates in the target dashboard. Dropping the row lets the grid place it with the defaults. The mutation rejects a move to a dashboard in another project. Nothing in the schema ties a report's projectId to its dashboard's, and chart queries resolve the project from the report itself, including behind a public dashboard share. A cross-project move would serve the source project's data through the target project's share link. UserJot: cmsx91b2i0vgi0io8iprykw92 Co-Authored-By: Claude Opus 5 --- .../dashboards/select-dashboard.tsx | 133 +++++++++++++++++ .../src/components/report/report-item.tsx | 20 ++- apps/start/src/modals/index.tsx | 2 + apps/start/src/modals/move-report.tsx | 99 +++++++++++++ apps/start/src/modals/save-report.tsx | 135 +----------------- ...Id.$projectId.dashboards_.$dashboardId.tsx | 3 + packages/trpc/src/routers/report.ts | 69 ++++++++- 7 files changed, 326 insertions(+), 135 deletions(-) create mode 100644 apps/start/src/components/dashboards/select-dashboard.tsx create mode 100644 apps/start/src/modals/move-report.tsx diff --git a/apps/start/src/components/dashboards/select-dashboard.tsx b/apps/start/src/components/dashboards/select-dashboard.tsx new file mode 100644 index 000000000..e661273ef --- /dev/null +++ b/apps/start/src/components/dashboards/select-dashboard.tsx @@ -0,0 +1,133 @@ +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { handleError, useTRPC } from '@/integrations/trpc/react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { ArrowLeftIcon, PlusIcon, SaveIcon } from 'lucide-react'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +export function SelectDashboard({ + value, + onChange, + projectId, + excludeDashboardId, +}: { + value: string; + onChange: (value: string) => void; + projectId: string; + excludeDashboardId?: string; +}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [isCreatingNew, setIsCreatingNew] = useState(false); + const [newDashboardName, setNewDashboardName] = useState(''); + + const form = useForm({ + resolver: zodResolver(z.object({ name: z.string().min(1, 'Required') })), + defaultValues: { + name: '', + }, + }); + + const dashboardQuery = useQuery( + trpc.dashboard.list.queryOptions({ + projectId, + }), + ); + + const dashboardMutation = useMutation( + trpc.dashboard.create.mutationOptions({ + onError: handleError, + async onSuccess(res) { + queryClient.invalidateQueries(trpc.dashboard.list.pathFilter()); + await dashboardQuery.refetch(); + onChange(res.id); + setIsCreatingNew(false); + setNewDashboardName(''); + form.reset(); + }, + }), + ); + + const handleCreateDashboard = () => { + if (newDashboardName.trim()) { + dashboardMutation.mutate({ + name: newDashboardName.trim(), + projectId, + }); + } + }; + + const dashboards = (dashboardQuery.data ?? []).filter( + (dashboard) => dashboard.id !== excludeDashboardId, + ); + + return ( +
+ + + {!isCreatingNew ? ( +
+ {dashboards.map((dashboard) => ( + + ))} + +
+ ) : ( +
+ +
+ )} +
+ ); +} diff --git a/apps/start/src/components/report/report-item.tsx b/apps/start/src/components/report/report-item.tsx index ca9cb6ba6..26210b309 100644 --- a/apps/start/src/components/report/report-item.tsx +++ b/apps/start/src/components/report/report-item.tsx @@ -7,7 +7,12 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { cn } from '@/utils/cn'; -import { CopyIcon, MoreHorizontal, Trash } from 'lucide-react'; +import { + CopyIcon, + LayoutPanelTopIcon, + MoreHorizontal, + Trash, +} from 'lucide-react'; import { timeWindows } from '@openpanel/constants'; @@ -41,6 +46,7 @@ export function ReportItem({ interval, onDelete, onDuplicate, + onMove, }: { report: any; organizationId: string; @@ -51,6 +57,7 @@ export function ReportItem({ interval: any; onDelete: (reportId: string) => void; onDuplicate: (reportId: string) => void; + onMove?: (reportId: string) => void; }) { const router = useRouter(); const chartRange = report.range; @@ -149,6 +156,17 @@ export function ReportItem({ Duplicate + {onMove && ( + { + event.stopPropagation(); + onMove(report.id); + }} + > + + Move to dashboard + + )} ; + +export default function MoveReport({ + reportId, + dashboardId, +}: MoveReportProps) { + const queryClient = useQueryClient(); + const { projectId } = useAppParams(); + + const trpc = useTRPC(); + const move = useMutation( + trpc.report.move.mutationOptions({ + onError: handleError, + onSuccess() { + queryClient.invalidateQueries(trpc.report.list.pathFilter()); + queryClient.invalidateQueries(trpc.dashboard.list.pathFilter()); + toast('Report moved'); + popModal(); + }, + }), + ); + + const { handleSubmit, formState, control } = useForm({ + resolver: zodResolver(validator), + defaultValues: { + dashboardId: '', + }, + }); + + return ( + + +
{ + move.mutate({ + reportId, + dashboardId: values.dashboardId, + }); + })} + > + { + return ( + + ); + }} + /> + + + + + +
+ ); +} diff --git a/apps/start/src/modals/save-report.tsx b/apps/start/src/modals/save-report.tsx index b478ddb29..1fbec1422 100644 --- a/apps/start/src/modals/save-report.tsx +++ b/apps/start/src/modals/save-report.tsx @@ -1,7 +1,7 @@ import { ButtonContainer } from '@/components/button-container'; +import { SelectDashboard } from '@/components/dashboards/select-dashboard'; import { InputWithLabel } from '@/components/forms/input-with-label'; import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; import { useAppParams } from '@/hooks/use-app-params'; import { handleError } from '@/integrations/trpc/react'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -12,11 +12,8 @@ import { z } from 'zod'; import type { IReport } from '@openpanel/validation'; -import { Input } from '@/components/ui/input'; import { useTRPC } from '@/integrations/trpc/react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { ArrowLeftIcon, PlusIcon, SaveIcon } from 'lucide-react'; -import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { popModal } from '.'; import { ModalContent, ModalHeader } from './Modal/Container'; @@ -148,131 +145,3 @@ export default function SaveReport({ ); } -function SelectDashboard({ - value, - onChange, - projectId, -}: { - value: string; - onChange: (value: string) => void; - projectId: string; -}) { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - const [isCreatingNew, setIsCreatingNew] = useState(false); - const [newDashboardName, setNewDashboardName] = useState(''); - - const form = useForm({ - resolver: zodResolver(z.object({ name: z.string().min(1, 'Required') })), - defaultValues: { - name: '', - }, - }); - - const dashboardQuery = useQuery( - trpc.dashboard.list.queryOptions({ - projectId: projectId!, - }), - ); - - const dashboardMutation = useMutation( - trpc.dashboard.create.mutationOptions({ - onError: handleError, - async onSuccess(res) { - queryClient.invalidateQueries(trpc.dashboard.list.pathFilter()); - await dashboardQuery.refetch(); - onChange(res.id); - setIsCreatingNew(false); - setNewDashboardName(''); - form.reset(); - }, - }), - ); - - const handleSelectChange = (selectedValue: string) => { - if (selectedValue === 'create-new') { - setIsCreatingNew(true); - onChange(''); // Clear the current selection - } else { - setIsCreatingNew(false); - onChange(selectedValue); - } - }; - - const handleCreateDashboard = () => { - if (newDashboardName.trim()) { - dashboardMutation.mutate({ - name: newDashboardName.trim(), - projectId, - }); - } - }; - - const selectedDashboard = dashboardQuery.data?.find((d) => d.id === value); - - return ( -
- - - {!isCreatingNew ? ( -
- {dashboardQuery.data?.map((dashboard) => ( - - ))} - -
- ) : ( -
- -
- )} -
- ); -} diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.dashboards_.$dashboardId.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.dashboards_.$dashboardId.tsx index 60e23b6f3..b3c18d3ac 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.dashboards_.$dashboardId.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.dashboards_.$dashboardId.tsx @@ -412,6 +412,9 @@ function Component() { onDuplicate={(reportId) => { reportDuplicate.mutate({ reportId }); }} + onMove={(reportId) => { + pushModal('MoveReport', { reportId, dashboardId }); + }} /> ))} diff --git a/packages/trpc/src/routers/report.ts b/packages/trpc/src/routers/report.ts index 2ba31ced3..817c555e7 100644 --- a/packages/trpc/src/routers/report.ts +++ b/packages/trpc/src/routers/report.ts @@ -9,7 +9,11 @@ import { import { zReport } from '@openpanel/validation'; import { getProjectAccess } from '../access'; -import { TRPCForbiddenError, TRPCNotFoundError } from '../errors'; +import { + TRPCBadRequestError, + TRPCForbiddenError, + TRPCNotFoundError, +} from '../errors'; import { createTRPCRouter, protectedProcedure } from '../trpc'; export const reportRouter = createTRPCRouter({ @@ -120,6 +124,69 @@ export const reportRouter = createTRPCRouter({ }, }); }), + move: protectedProcedure + .input( + z.object({ + reportId: z.string(), + dashboardId: z.string(), + }), + ) + .mutation(async ({ input: { reportId, dashboardId }, ctx }) => { + const report = await db.report.findUniqueOrThrow({ + where: { + id: reportId, + }, + }); + + const access = await getProjectAccess({ + userId: ctx.session.userId, + projectId: report.projectId, + }); + + if (!access) { + throw new TRPCForbiddenError('You do not have access to this project'); + } + + if (report.dashboardId === dashboardId) { + throw new TRPCBadRequestError('Report is already on this dashboard'); + } + + const dashboard = await db.dashboard.findUniqueOrThrow({ + where: { + id: dashboardId, + }, + }); + + // A report keeps its own projectId and that is what powers the chart + // queries, public shares included. Moving it to a dashboard in another + // project would expose the source project through the target project. + if (dashboard.projectId !== report.projectId) { + throw new TRPCBadRequestError( + 'You can only move a report to a dashboard in the same project', + ); + } + + const [, moved] = await db.$transaction([ + // The layout belongs to the report, not the dashboard. Keeping it would + // drop the report on top of whatever already sits at those coordinates + // in the target dashboard. + db.reportLayout.deleteMany({ + where: { + reportId, + }, + }), + db.report.update({ + where: { + id: reportId, + }, + data: { + dashboardId, + }, + }), + ]); + + return moved; + }), delete: protectedProcedure .input( z.object({ From 5d79dd711915c5aaa6b12565e5f3d8a19d43251c Mon Sep 17 00:00:00 2001 From: OpenPanel Agent Date: Sat, 22 Aug 2026 20:17:52 +0000 Subject: [PATCH 2/5] fix: guard dashboard creation against duplicate submits The Enter key handler in SelectDashboard called handleCreateDashboard directly, bypassing the disabled state on the Create button. Repeated Enter presses could fire dashboard.create more than once and create duplicates. Guard on dashboardMutation.isPending in the handler itself. Co-Authored-By: Claude Opus 5 --- .../src/components/dashboards/select-dashboard.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/start/src/components/dashboards/select-dashboard.tsx b/apps/start/src/components/dashboards/select-dashboard.tsx index e661273ef..ad5bc5686 100644 --- a/apps/start/src/components/dashboards/select-dashboard.tsx +++ b/apps/start/src/components/dashboards/select-dashboard.tsx @@ -53,12 +53,15 @@ export function SelectDashboard({ ); const handleCreateDashboard = () => { - if (newDashboardName.trim()) { - dashboardMutation.mutate({ - name: newDashboardName.trim(), - projectId, - }); + const name = newDashboardName.trim(); + if (!name || dashboardMutation.isPending) { + return; } + + dashboardMutation.mutate({ + name, + projectId, + }); }; const dashboards = (dashboardQuery.data ?? []).filter( From d5180e60af8e521495041791fbbd25e2224f50b5 Mon Sep 17 00:00:00 2001 From: OpenPanel Agent Date: Sat, 22 Aug 2026 20:24:21 +0000 Subject: [PATCH 3/5] fix: drop unused form state and expose dashboard selection to a11y The useForm/zodResolver setup in SelectDashboard was never wired to the name input; handleCreateDashboard validates newDashboardName directly and the two form.reset() calls reset a form with no registered fields. Removed it along with the now-unused imports. Dashboard buttons only signalled selection through `variant`, which is visual only. Added aria-pressed so the selected destination is readable by assistive technology. Co-Authored-By: Claude Opus 5 --- .../src/components/dashboards/select-dashboard.tsx | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/apps/start/src/components/dashboards/select-dashboard.tsx b/apps/start/src/components/dashboards/select-dashboard.tsx index ad5bc5686..a5db56556 100644 --- a/apps/start/src/components/dashboards/select-dashboard.tsx +++ b/apps/start/src/components/dashboards/select-dashboard.tsx @@ -2,12 +2,9 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { handleError, useTRPC } from '@/integrations/trpc/react'; -import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ArrowLeftIcon, PlusIcon, SaveIcon } from 'lucide-react'; import { useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; export function SelectDashboard({ value, @@ -25,13 +22,6 @@ export function SelectDashboard({ const [isCreatingNew, setIsCreatingNew] = useState(false); const [newDashboardName, setNewDashboardName] = useState(''); - const form = useForm({ - resolver: zodResolver(z.object({ name: z.string().min(1, 'Required') })), - defaultValues: { - name: '', - }, - }); - const dashboardQuery = useQuery( trpc.dashboard.list.queryOptions({ projectId, @@ -47,7 +37,6 @@ export function SelectDashboard({ onChange(res.id); setIsCreatingNew(false); setNewDashboardName(''); - form.reset(); }, }), ); @@ -79,6 +68,7 @@ export function SelectDashboard({ type="button" key={dashboard.id} variant={value === dashboard.id ? 'default' : 'outline'} + aria-pressed={value === dashboard.id} onClick={() => onChange(dashboard.id)} > {dashboard.name} @@ -106,7 +96,6 @@ export function SelectDashboard({ onClick={() => { setIsCreatingNew(false); setNewDashboardName(''); - form.reset(); }} /> Date: Sat, 22 Aug 2026 20:32:43 +0000 Subject: [PATCH 4/5] fix: restore dashboard selection on cancel and label the create input Entering create mode cleared the controlled value but the back button never restored it, so cancelling left both consuming forms with an empty required dashboardId. Stash the selection on entry and put it back on cancel. Also associate the Dashboard label with the name input while creating, and give the icon-only back button an accessible name. Co-Authored-By: Claude Opus 5 --- .../src/components/dashboards/select-dashboard.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/start/src/components/dashboards/select-dashboard.tsx b/apps/start/src/components/dashboards/select-dashboard.tsx index a5db56556..4600313d1 100644 --- a/apps/start/src/components/dashboards/select-dashboard.tsx +++ b/apps/start/src/components/dashboards/select-dashboard.tsx @@ -4,7 +4,7 @@ import { Label } from '@/components/ui/label'; import { handleError, useTRPC } from '@/integrations/trpc/react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ArrowLeftIcon, PlusIcon, SaveIcon } from 'lucide-react'; -import { useState } from 'react'; +import { useId, useState } from 'react'; export function SelectDashboard({ value, @@ -21,6 +21,8 @@ export function SelectDashboard({ const queryClient = useQueryClient(); const [isCreatingNew, setIsCreatingNew] = useState(false); const [newDashboardName, setNewDashboardName] = useState(''); + const [previousValue, setPreviousValue] = useState(''); + const newDashboardNameId = useId(); const dashboardQuery = useQuery( trpc.dashboard.list.queryOptions({ @@ -59,7 +61,9 @@ export function SelectDashboard({ return (
- + {!isCreatingNew ? (
@@ -78,6 +82,7 @@ export function SelectDashboard({ type="button" variant="outline" onClick={() => { + setPreviousValue(value); setIsCreatingNew(true); onChange(''); }} @@ -93,12 +98,15 @@ export function SelectDashboard({ variant="outline" size="icon" icon={ArrowLeftIcon} + aria-label="Back to dashboard selection" onClick={() => { setIsCreatingNew(false); setNewDashboardName(''); + onChange(previousValue); }} /> setNewDashboardName(e.target.value)} From bec289b6cb5cfc7bd6ce7d670597e936709b3b4e Mon Sep 17 00:00:00 2001 From: OpenPanel Agent Date: Sat, 22 Aug 2026 20:39:03 +0000 Subject: [PATCH 5/5] fix: ignore Enter during IME composition in dashboard create input Pressing Enter to confirm an IME candidate fired handleCreateDashboard with the in-progress text, and the preventDefault swallowed the confirmation. Bail out on isComposing, with the keyCode 229 fallback for browsers that report the confirming event with isComposing false. Same pair already used in shouldIgnoreKeypress. Co-Authored-By: Claude Opus 5 --- .../src/components/dashboards/select-dashboard.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/start/src/components/dashboards/select-dashboard.tsx b/apps/start/src/components/dashboards/select-dashboard.tsx index 4600313d1..923885aed 100644 --- a/apps/start/src/components/dashboards/select-dashboard.tsx +++ b/apps/start/src/components/dashboards/select-dashboard.tsx @@ -111,10 +111,15 @@ export function SelectDashboard({ value={newDashboardName} onChange={(e) => setNewDashboardName(e.target.value)} onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleCreateDashboard(); + if (e.key !== 'Enter') { + return; } + // Enter confirms an IME candidate, it should not submit. + if (e.nativeEvent.isComposing || e.keyCode === 229) { + return; + } + e.preventDefault(); + handleCreateDashboard(); }} />