Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/shared/src/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,8 @@ export type DashboardActionType =
| "open"
| "create"
| "delete"
/** The delete was undone inside its undo window, so nothing was removed. */
| "delete_undo"
| "rename"
| "save"
| "fork"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CaretRightIcon } from "@phosphor-icons/react";
import { CaretRightIcon, TrashIcon } from "@phosphor-icons/react";
import type { ChannelTaskRecord } from "@posthog/core/canvas/channelTaskSchemas";
import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas";
import { formatRelativeTimeShort } from "@posthog/shared";
Expand All @@ -9,6 +9,7 @@ import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTe
import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout";
import { useChannelTasks } from "@posthog/ui/features/canvas/hooks/useChannelTasks";
import { useDashboards } from "@posthog/ui/features/canvas/hooks/useDashboards";
import { useIsCanvasPendingDelete } from "@posthog/ui/features/canvas/stores/pendingCanvasDeleteStore";
import { usePrArtifact } from "@posthog/ui/features/git-interaction/usePrArtifact";
import { useTasks } from "@posthog/ui/features/tasks/useTasks";
import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent";
Expand Down Expand Up @@ -140,16 +141,13 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) {
<div className="flex flex-col gap-0.5">
{items.map((item) =>
item.kind === "canvas" ? (
<ArtifactRow
<CanvasArtifactRow
key={item.key}
accent="violet"
icon={iconForTemplate(item.templateId, {
size: 15,
className: "text-violet-9",
})}
dashboardId={item.dashboardId}
templateId={item.templateId}
title={item.title}
subtitle={`Canvas · ${formatRelativeTimeShort(item.ts)}`}
onClick={() => openCanvas(item.dashboardId)}
ts={item.ts}
onClick={openCanvas}
/>
) : (
<PrArtifactRow
Expand All @@ -168,6 +166,43 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) {
);
}

// A canvas artifact row. While the canvas is inside its delete-undo window the
// row stays put — its template icon becomes a pulsing trash can and the row
// stops opening — so undoing puts it back exactly where it was.
function CanvasArtifactRow({
dashboardId,
templateId,
title,
ts,
onClick,
}: {
dashboardId: string;
templateId: string;
title: string;
ts: number;
onClick: (dashboardId: string) => void;
}) {
const deleting = useIsCanvasPendingDelete(dashboardId);

return (
<ArtifactRow
accent={deleting ? "red" : "violet"}
icon={
deleting ? (
<TrashIcon size={15} className="animate-pulse text-red-9" />
) : (
iconForTemplate(templateId, { size: 15, className: "text-violet-9" })
)
}
title={title}
subtitle={
deleting ? "Deleting…" : `Canvas · ${formatRelativeTimeShort(ts)}`
}
onClick={deleting ? undefined : () => onClick(dashboardId)}
/>
);
}

// A PR artifact row. The PR's lifecycle state (open / draft / merged / closed)
// comes from usePrArtifact, which also gates the URL — PR links come from run
// output, so a row must not fetch from whatever host that names.
Expand Down
108 changes: 78 additions & 30 deletions packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { DotsThreeIcon, LinkIcon, TrashIcon } from "@phosphor-icons/react";
import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas";
import {
AlertDialog,
AlertDialogClose,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Badge,
Button,
Card,
Expand All @@ -15,16 +22,17 @@ import {
import { formatRelativeTimeShort } from "@posthog/shared";
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
import { NewCanvasMenu } from "@posthog/ui/features/canvas/components/NewCanvasMenu";
import { deleteCanvasWithUndo } from "@posthog/ui/features/canvas/deleteCanvasWithUndo";
import { FreeformCanvas } from "@posthog/ui/features/canvas/freeform/FreeformCanvas";
import { handleFreeformDataRequest } from "@posthog/ui/features/canvas/freeform/freeformDataBridge";
import { useCanvasTemplates } from "@posthog/ui/features/canvas/hooks/useCanvasTemplates";
import {
useDashboardMutations,
useDashboards,
} from "@posthog/ui/features/canvas/hooks/useDashboards";
import { useIsCanvasPendingDelete } from "@posthog/ui/features/canvas/stores/pendingCanvasDeleteStore";
import { copyCanvasLink } from "@posthog/ui/features/canvas/utils/copyCanvasLink";
import { useInView } from "@posthog/ui/primitives/hooks/useInView";
import { toast } from "@posthog/ui/primitives/toast";
import { track } from "@posthog/ui/shell/analytics";
import { ErrorBoundary } from "@posthog/ui/shell/ErrorBoundary";
import { Box, Flex, Grid } from "@radix-ui/themes";
Expand Down Expand Up @@ -106,10 +114,20 @@ const DashboardCard = memo(function DashboardCard({
summary: DashboardSummary;
templateLabel: string;
}) {
// While the canvas is inside its delete-undo window the card stays in the
// grid — dimmed, with a pulsing trash can over its preview — so undoing puts
// it back exactly where it was rather than re-inserting a row.
const deleting = useIsCanvasPendingDelete(summary.id);

// The React source rides along in the list response, so the grid renders
// previews without a per-card fetch (no N+1 of get()).
return (
<Box className="group relative">
<Box
className={cn(
"group relative",
deleting && "pointer-events-none opacity-60",
)}
>
<Link
to="/website/$channelId/dashboards/$dashboardId"
params={{ channelId, dashboardId: summary.id }}
Expand All @@ -125,7 +143,22 @@ const DashboardCard = memo(function DashboardCard({
}
>
<Card className="gap-0 overflow-hidden p-0">
<FreeformPreview code={summary.code} />
<Box className="relative">
<FreeformPreview code={summary.code} />
{deleting && (
<Flex
align="center"
justify="center"
gap="2"
className="absolute inset-0 bg-gray-1/80"
>
<TrashIcon size={18} className="animate-pulse text-red-9" />
<Text size="xs" variant="muted">
Deleting…
</Text>
</Flex>
)}
</Box>
<CardContent className="flex flex-col gap-0.5 p-3">
<Flex align="center" justify="between" gap="2">
<Text size="sm" weight="medium" className="truncate">
Expand Down Expand Up @@ -223,31 +256,22 @@ function DashboardCardMenu({
channelId: string;
}) {
const [open, setOpen] = useState(false);
const { deleteDashboard, isDeleting } = useDashboardMutations();
// "Delete…" opens a confirmation rather than deleting inline — the canvas and
// its version history go away for everyone in the channel.
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const { invalidateDashboards } = useDashboardMutations();

const onDelete = () => {
deleteDashboard(id)
.then(() => {
track(ANALYTICS_EVENTS.DASHBOARD_ACTION, {
action_type: "delete",
surface: "dashboards_grid",
channel_id: channelId,
dashboard_id: id,
success: true,
});
})
.catch((error) => {
track(ANALYTICS_EVENTS.DASHBOARD_ACTION, {
action_type: "delete",
surface: "dashboards_grid",
channel_id: channelId,
dashboard_id: id,
success: false,
});
toast.error("Couldn't delete canvas", {
description: error instanceof Error ? error.message : String(error),
});
});
// The card disappears immediately, but the delete isn't sent until the undo
// toast's timer runs out — Undo simply cancels it.
const confirmDelete = () => {
setConfirmDeleteOpen(false);
deleteCanvasWithUndo({
dashboardId: id,
channelId,
name,
surface: "dashboards_grid",
invalidate: invalidateDashboards,
});
};

return (
Expand Down Expand Up @@ -280,14 +304,38 @@ function DashboardCardMenu({
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
disabled={isDeleting}
onClick={onDelete}
onClick={() => setConfirmDeleteOpen(true)}
>
<TrashIcon size={14} />
Delete
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Destructive confirm for "Delete…" — the canvas goes for everyone. */}
<AlertDialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
<AlertDialogTitle>Delete canvas</AlertDialogTitle>
<AlertDialogDescription>
Permanently delete <span className="font-medium">{name}</span>?
This deletes its code and version history for everyone in the
channel and cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogClose
render={
<Button variant="outline" size="sm">
Cancel
</Button>
}
/>
<Button variant="destructive" size="sm" onClick={confirmDelete}>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Box>
);
}
Expand Down
68 changes: 66 additions & 2 deletions packages/ui/src/features/canvas/components/WebsiteLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,17 @@ import {
LinkIcon,
PencilSimpleIcon,
PushPinIcon,
TrashIcon,
XIcon,
} from "@phosphor-icons/react";
import {
AlertDialog,
AlertDialogClose,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
DropdownMenu,
DropdownMenuContent,
Expand All @@ -18,6 +26,7 @@ import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
import { ChannelBreadcrumb } from "@posthog/ui/features/canvas/components/ChannelBreadcrumb";
import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon";
import { NewCanvasMenu } from "@posthog/ui/features/canvas/components/NewCanvasMenu";
import { deleteCanvasWithUndo } from "@posthog/ui/features/canvas/deleteCanvasWithUndo";
import { CanvasFrameHost } from "@posthog/ui/features/canvas/freeform/CanvasFrameHost";
import { useCanvasFrameStore } from "@posthog/ui/features/canvas/freeform/canvasFrameStore";
import { CANVAS_QUERY_KEY } from "@posthog/ui/features/canvas/freeform/freeformDataBridge";
Expand Down Expand Up @@ -50,7 +59,7 @@ import {
useParams,
useRouterState,
} from "@tanstack/react-router";
import type { ReactNode } from "react";
import { type ReactNode, useState } from "react";

function threadIdFor(dashboardId: string): string {
return `dashboard:${dashboardId}`;
Expand All @@ -72,8 +81,30 @@ function FreeformEditControls({
const editing = useIsDashboardEditing(dashboardId);
const setEditing = useDashboardEditStore((s) => s.setEditing);
const { dashboard } = useDashboard(dashboardId);
const { forkFreeform, isCreating, setPinned } = useDashboardMutations();
const { forkFreeform, isCreating, setPinned, invalidateDashboards } =
useDashboardMutations();
const isPinned = dashboard?.pinnedAt != null;
// "Delete…" opens a confirmation rather than deleting inline — the canvas and
// its version history go away for everyone in the channel.
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);

// Once confirmed the canvas vanishes from every list and we leave for the
// space's artifacts list, but the delete isn't sent until the undo toast's
// timer runs out — Undo simply cancels it.
const confirmDelete = () => {
setConfirmDeleteOpen(false);
deleteCanvasWithUndo({
dashboardId,
channelId,
name: dashboard?.name ?? "Canvas",
surface: "canvas",
invalidate: invalidateDashboards,
});
void navigate({
to: "/website/$channelId/artifacts",
params: { channelId },
});
};

const onTogglePin = () => {
void setPinned(dashboardId, !isPinned)
Expand Down Expand Up @@ -250,8 +281,41 @@ function FreeformEditControls({
<PushPinIcon size={14} weight={isPinned ? "fill" : "regular"} />
{isPinned ? "Unpin from channel" : "Pin to channel"}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => setConfirmDeleteOpen(true)}
>
<TrashIcon size={14} />
Delete…
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Destructive confirm for "Delete…" — the canvas goes for everyone. */}
<AlertDialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
<AlertDialogTitle>Delete canvas</AlertDialogTitle>
<AlertDialogDescription>
Permanently delete{" "}
<span className="font-medium">{dashboard?.name ?? "Canvas"}</span>
? This deletes its code and version history for everyone in the
channel and cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogClose
render={
<Button variant="outline" size="sm">
Cancel
</Button>
}
/>
<Button variant="destructive" size="sm" onClick={confirmDelete}>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button
variant="outline"
size="sm"
Expand Down
Loading
Loading