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 (
+
+
+
+
+ );
+}
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) => (
- onChange(dashboard.id)}
- >
- {dashboard.name}
-
- ))}
- {
- setIsCreatingNew(true);
- onChange('');
- }}
- icon={PlusIcon}
- >
- Create new dashboard
-
-
- ) : (
-
- {
- setIsCreatingNew(false);
- setNewDashboardName('');
- form.reset();
- }}
- />
- setNewDashboardName(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- handleCreateDashboard();
- }
- }}
- />
-
- {dashboardMutation.isPending ? 'Creating...' : 'Create'}
-
-
- )}
-
- );
-}
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({