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..923885aed --- /dev/null +++ b/apps/start/src/components/dashboards/select-dashboard.tsx @@ -0,0 +1,138 @@ +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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { ArrowLeftIcon, PlusIcon, SaveIcon } from 'lucide-react'; +import { useId, useState } from 'react'; + +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 [previousValue, setPreviousValue] = useState(''); + const newDashboardNameId = useId(); + + 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(''); + }, + }), + ); + + const handleCreateDashboard = () => { + const name = newDashboardName.trim(); + if (!name || dashboardMutation.isPending) { + return; + } + + dashboardMutation.mutate({ + name, + 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({