fix: restore canvas runtime and panel toolbar behavior (#3827)
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
IconDots,
|
||||
IconEdit,
|
||||
IconExternalLink,
|
||||
IconLayoutGrid,
|
||||
@@ -13,11 +12,19 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { controlSizingClassName } from "@kandev/ui/control-sizing";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { MobilePickerSheet } from "@/components/task/mobile/mobile-picker-sheet";
|
||||
import { PanelHeaderBarSplit } from "@/components/task/panel-primitives";
|
||||
import { CanvasPage } from "@/components/plugins/canvas-page";
|
||||
import { canvasHref, type Canvas } from "@/lib/api/domains/canvas-api";
|
||||
import { CanvasPromotionDialog, CanvasReleaseDialog } from "./canvas-lifecycle-dialogs";
|
||||
import { CanvasMobileActionsButton } from "./canvas-host-actions";
|
||||
import { canvasLockHelp, canvasPromotionHelp } from "./canvas-host-desktop-actions";
|
||||
export { CanvasHostDialogs, CanvasMobileActionsButton } from "./canvas-host-actions";
|
||||
export {
|
||||
CanvasDesktopActions,
|
||||
CanvasDesktopOverflowActions,
|
||||
CanvasDesktopOverflowMenuItems,
|
||||
CanvasDesktopPrimaryAction,
|
||||
} from "./canvas-host-desktop-actions";
|
||||
|
||||
export type CanvasHostState =
|
||||
| "loading_metadata"
|
||||
@@ -57,84 +64,6 @@ const STATE_COPY: Record<CanvasHostState, { title: string; description: string }
|
||||
archived: { title: "canvases:archived", description: "canvases:archivedDescription" },
|
||||
};
|
||||
|
||||
export function CanvasHostDialogs({
|
||||
canvas,
|
||||
promotionOpen,
|
||||
onPromotionOpenChange,
|
||||
releasesOpen,
|
||||
onReleasesOpenChange,
|
||||
onPromotionCompleted,
|
||||
onChanged,
|
||||
}: {
|
||||
canvas: Canvas | null;
|
||||
promotionOpen: boolean;
|
||||
onPromotionOpenChange: (open: boolean) => void;
|
||||
releasesOpen: boolean;
|
||||
onReleasesOpenChange: (open: boolean) => void;
|
||||
onPromotionCompleted: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<CanvasPromotionDialog
|
||||
canvas={canvas?.scope_kind === "task" ? canvas : null}
|
||||
open={promotionOpen}
|
||||
onOpenChange={onPromotionOpenChange}
|
||||
onCompleted={onPromotionCompleted}
|
||||
/>
|
||||
<CanvasReleaseDialog
|
||||
canvas={canvas}
|
||||
open={releasesOpen}
|
||||
onOpenChange={onReleasesOpenChange}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function canvasLockHelp(canvas: Canvas, t: (key: string) => string): string {
|
||||
return canvas.status === "archived"
|
||||
? t("canvases:archivedCanvasActionHelp")
|
||||
: t("canvases:disabledCanvasActionHelp");
|
||||
}
|
||||
|
||||
function canvasPromotionHelp(canvas: Canvas, t: (key: string) => string): string {
|
||||
if (canvas.status === "archived" || canvas.status === "disabled") {
|
||||
return canvasLockHelp(canvas, t);
|
||||
}
|
||||
if (canvas.scope_kind === "task" && canvas.active_release_status === "valid") {
|
||||
return t("canvases:promoteCanvasHelp");
|
||||
}
|
||||
return t("canvases:promoteCanvasUnavailable");
|
||||
}
|
||||
|
||||
function CanvasDesktopActionTooltip({
|
||||
description,
|
||||
disabled,
|
||||
testId,
|
||||
children,
|
||||
}: {
|
||||
description: string;
|
||||
disabled: boolean;
|
||||
testId: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={disabled ? "cursor-not-allowed" : undefined}
|
||||
data-testid={testId}
|
||||
tabIndex={disabled ? 0 : undefined}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{description}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileCanvasAction({
|
||||
description,
|
||||
children,
|
||||
@@ -158,11 +87,8 @@ export function CanvasHostBody({
|
||||
canvasId,
|
||||
title,
|
||||
state,
|
||||
isMobile,
|
||||
menuOpen,
|
||||
runtimeUrl,
|
||||
error,
|
||||
onOpenActions,
|
||||
onRuntimeReady,
|
||||
onRuntimeError,
|
||||
onRetry,
|
||||
@@ -170,29 +96,30 @@ export function CanvasHostBody({
|
||||
canvasId: string;
|
||||
title: string;
|
||||
state: CanvasHostState;
|
||||
isMobile: boolean;
|
||||
menuOpen: boolean;
|
||||
runtimeUrl: string | null;
|
||||
error: string | null;
|
||||
onOpenActions: () => void;
|
||||
onRuntimeReady: () => void;
|
||||
onRuntimeError: () => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className="flex h-dvh min-h-0 min-w-0 max-h-[calc(100dvh-2.75rem)] flex-1 flex-col overflow-hidden md:h-auto md:max-h-none"
|
||||
className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
|
||||
data-testid="canvas-host-route"
|
||||
>
|
||||
<CanvasHostHeader
|
||||
title={title}
|
||||
state={state}
|
||||
isMobile={isMobile}
|
||||
menuOpen={menuOpen}
|
||||
onOpenActions={onOpenActions}
|
||||
/>
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{(state === "loading_runtime" || state === "ready") && runtimeUrl ? (
|
||||
{(state === "loading_runtime" || state === "ready") && runtimeUrl ? (
|
||||
<>
|
||||
{state === "ready" && (
|
||||
<span
|
||||
className="sr-only"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="canvas-host-state"
|
||||
>
|
||||
<span data-testid="canvas-host-ready-announcement">{t(STATE_COPY.ready.title)}</span>
|
||||
</span>
|
||||
)}
|
||||
<CanvasPage
|
||||
key={`${canvasId}:${runtimeUrl}`}
|
||||
runtimeUrl={runtimeUrl}
|
||||
@@ -200,90 +127,9 @@ export function CanvasHostBody({
|
||||
onLoad={onRuntimeReady}
|
||||
onError={onRuntimeError}
|
||||
/>
|
||||
) : (
|
||||
<CanvasHostStatePanel state={state} error={error} onRetry={onRetry} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasDesktopActions({
|
||||
canvas,
|
||||
editing,
|
||||
onEdit,
|
||||
onPromote,
|
||||
onReleases,
|
||||
onShare,
|
||||
}: {
|
||||
canvas: Canvas;
|
||||
editing: boolean;
|
||||
onEdit: () => void;
|
||||
onPromote: () => void;
|
||||
onReleases: () => void;
|
||||
onShare: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const lifecycleLocked = canvas.status === "archived" || canvas.status === "disabled";
|
||||
const promoteAvailable = canvas.scope_kind === "task" && canvas.active_release_status === "valid";
|
||||
const promoteDisabled = lifecycleLocked || !promoteAvailable;
|
||||
const promoteDescription = canvasPromotionHelp(canvas, t);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{canvas.scope_kind === "workspace" && (
|
||||
<CanvasDesktopActionTooltip
|
||||
description={lifecycleLocked ? canvasLockHelp(canvas, t) : t("canvases:editCanvasHelp")}
|
||||
disabled={editing || lifecycleLocked}
|
||||
testId="canvas-action-edit-tooltip-trigger"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
disabled={editing || lifecycleLocked}
|
||||
onClick={onEdit}
|
||||
>
|
||||
<IconEdit className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("canvases:editCanvas")}
|
||||
</Button>
|
||||
</CanvasDesktopActionTooltip>
|
||||
)}
|
||||
<CanvasDesktopActionTooltip
|
||||
description={t("canvases:releasesAndPermissionsHelp")}
|
||||
disabled={false}
|
||||
testId="canvas-action-releases-tooltip-trigger"
|
||||
>
|
||||
<Button variant="outline" size="sm" className="cursor-pointer" onClick={onReleases}>
|
||||
<IconListDetails className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("canvases:releasesAndPermissions")}
|
||||
</Button>
|
||||
</CanvasDesktopActionTooltip>
|
||||
<CanvasDesktopActionTooltip
|
||||
description={t("canvases:shareCanvasDescription")}
|
||||
disabled={false}
|
||||
testId="canvas-action-share-tooltip-trigger"
|
||||
>
|
||||
<Button variant="outline" size="sm" className="cursor-pointer" onClick={onShare}>
|
||||
<IconShare3 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("canvases:shareCanvas")}
|
||||
</Button>
|
||||
</CanvasDesktopActionTooltip>
|
||||
{canvas.scope_kind === "task" && (
|
||||
<CanvasDesktopActionTooltip
|
||||
description={promoteDescription}
|
||||
disabled={promoteDisabled}
|
||||
testId="canvas-action-promote-tooltip-trigger"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
disabled={promoteDisabled}
|
||||
onClick={onPromote}
|
||||
>
|
||||
<IconSparkles className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("canvases:promoteCanvas")}
|
||||
</Button>
|
||||
</CanvasDesktopActionTooltip>
|
||||
</>
|
||||
) : (
|
||||
<CanvasHostStatePanel state={state} error={error} onRetry={onRetry} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -291,47 +137,34 @@ export function CanvasDesktopActions({
|
||||
|
||||
export function CanvasHostHeader({
|
||||
title,
|
||||
state,
|
||||
isMobile,
|
||||
menuOpen,
|
||||
onOpenActions,
|
||||
actions,
|
||||
overflowActions,
|
||||
}: {
|
||||
title: string;
|
||||
state: CanvasHostState;
|
||||
isMobile: boolean;
|
||||
menuOpen: boolean;
|
||||
onOpenActions: () => void;
|
||||
actions?: ReactNode;
|
||||
overflowActions?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-11 shrink-0 items-center gap-2 border-b px-3 py-1.5"
|
||||
<PanelHeaderBarSplit
|
||||
data-testid="canvas-host-header"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium md:hidden">{title}</p>
|
||||
<p
|
||||
className="text-xs text-muted-foreground"
|
||||
data-testid="canvas-host-state"
|
||||
aria-live="polite"
|
||||
>
|
||||
{t(STATE_COPY[state].title)}
|
||||
</p>
|
||||
</div>
|
||||
{isMobile && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-11 w-11 shrink-0 cursor-pointer"
|
||||
aria-label={t("canvases:canvasActions")}
|
||||
aria-expanded={menuOpen}
|
||||
onClick={onOpenActions}
|
||||
data-testid="canvas-mobile-actions"
|
||||
>
|
||||
<IconDots className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
left={<span className="truncate text-sm font-medium">{title}</span>}
|
||||
right={
|
||||
<>
|
||||
{actions}
|
||||
{isMobile && (
|
||||
<CanvasMobileActionsButton menuOpen={menuOpen} onOpenActions={onOpenActions} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
overflow={!isMobile ? overflowActions : undefined}
|
||||
overflowAt={520}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -347,9 +180,14 @@ export function CanvasHostStatePanel({
|
||||
const { t } = useTranslation();
|
||||
const copy = STATE_COPY[state];
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center p-6 text-center">
|
||||
<div
|
||||
className="flex min-h-0 flex-1 items-center justify-center p-6 text-center"
|
||||
data-testid="canvas-host-state-panel"
|
||||
>
|
||||
<div className="max-w-md space-y-3">
|
||||
<h2 className="text-lg font-semibold">{t(copy.title)}</h2>
|
||||
<h2 className="text-lg font-semibold" data-testid="canvas-host-state">
|
||||
{t(copy.title)}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">{error || t(copy.description)}</p>
|
||||
{state !== "loading_metadata" && state !== "loading_runtime" && (
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user