Mobile message compose + WidgetConfigDialog SheetForms (Slice 8)
Below md, both the message-compose Dialog and the WidgetConfigDialog
render inside a SheetForm instead of a centered Dialog.
Message compose (UsersPage.impl.tsx): the form body (subject, formatting
toolbar, HTML textarea, preview, attachments) is extracted into a shared
composeBody const consumed by both SheetForm (mobile) and Dialog
(desktop). SheetForm wired with title, onSave=handleSend (which already
closes on success per R4.5), onCancel=closeCompose, isPending,
saveDisabled, saveLabel='Send message'.
WidgetConfigDialog: the draftBody const is shared between branches. The
two-mode flow (list vs draft) maps to dynamic SheetForm props -- list
mode ('Dashboard widgets' / Done / Cancel both close), draft mode
('Add/Edit widget' / Save widget / Cancel=reset back to list). The
inline Back/Save buttons are hidden on mobile (!isMobile) since the
SheetForm footer provides them.
Desktop (md+) is token-identical for both components -- the
isComposeMobile (900px) fullscreen styling on compose is preserved for
the 768-900px band. The large diff (~860 lines) is dominated by
extraction/re-indentation of shared form bodies into consts; the
behavioral delta is ~80 lines.
Tests: 3 new (compose mobile send/subject, WidgetConfigDialog desktop +
mobile titles/Done). 116 tests pass; lint/build green.
Refs openspec/changes/mobile-responsive-parity/ (spec R4, tasks slice 8).
This commit is contained in:
@@ -26,6 +26,8 @@ import {
|
|||||||
} from "../hooks/useWidgets";
|
} from "../hooks/useWidgets";
|
||||||
import { useServiceInstances } from "../hooks/useServices";
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
import { useTasks } from "../hooks/useSettings";
|
import { useTasks } from "../hooks/useSettings";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
|
import { SheetForm } from "@/components/ui/sheet-form";
|
||||||
import type { WidgetInstance, WidgetInstanceInput } from "../types";
|
import type { WidgetInstance, WidgetInstanceInput } from "../types";
|
||||||
import {
|
import {
|
||||||
BUILTIN_WIDGETS,
|
BUILTIN_WIDGETS,
|
||||||
@@ -277,201 +279,215 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
|||||||
]?.widgets.find((w) => w.kind === draft.widgetKind)
|
]?.widgets.find((w) => w.kind === draft.widgetKind)
|
||||||
: BUILTIN_WIDGETS[draft.widgetKind]
|
: BUILTIN_WIDGETS[draft.widgetKind]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const isTaskOutput =
|
const isTaskOutput =
|
||||||
draft?.serviceId !== null &&
|
draft?.serviceId !== null &&
|
||||||
services.find((s) => s.id === draft?.serviceId)?.service_type ===
|
services.find((s) => s.id === draft?.serviceId)?.service_type ===
|
||||||
"ssh_tasks";
|
"ssh_tasks";
|
||||||
|
|
||||||
|
// The draft body (Title/SortOrder/Enabled/config editor) is shared between
|
||||||
|
// the Dialog (desktop) and SheetForm (mobile). On mobile the inline
|
||||||
|
// Back/Save buttons are omitted because the SheetForm footer provides them.
|
||||||
|
const draftBody = draft ? (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Title" htmlFor="widget-title">
|
||||||
|
<Input
|
||||||
|
id="widget-title"
|
||||||
|
value={draft.title}
|
||||||
|
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Sort order" htmlFor="widget-sort-order">
|
||||||
|
<Input
|
||||||
|
id="widget-sort-order"
|
||||||
|
type="number"
|
||||||
|
value={String(draft.sortOrder)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
sortOrder: e.target.value === "" ? 0 : Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="widget-enabled"
|
||||||
|
checked={draft.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setDraft({ ...draft, enabled: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="widget-enabled">Enabled</Label>
|
||||||
|
</div>
|
||||||
|
<WidgetConfigEditor
|
||||||
|
binding={draftBinding}
|
||||||
|
isTaskOutput={!!isTaskOutput}
|
||||||
|
config={draft.config}
|
||||||
|
onChange={(config) => setDraft({ ...draft, config })}
|
||||||
|
tasks={tasks}
|
||||||
|
/>
|
||||||
|
{!isMobile ? (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={reset}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={saveDraft} disabled={saveWidget.isPending}>
|
||||||
|
Save widget
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{sortedInstances.length === 0 ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>No widgets yet. Add one below.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{sortedInstances.map((instance, index) => {
|
||||||
|
const serviceName = instance.service_id
|
||||||
|
? services.find((s) => s.id === instance.service_id)?.name
|
||||||
|
: "Built-in";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={instance.id}
|
||||||
|
className="flex items-center gap-2 rounded border p-2"
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{instance.title}</span>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{bindingLabel(instance.service_id, instance.widget_kind)}
|
||||||
|
</Badge>
|
||||||
|
{serviceName ? (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{serviceName}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{!instance.enabled ? (
|
||||||
|
<Badge variant="secondary">disabled</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => moveInstance(index, -1)}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
disabled={index === sortedInstances.length - 1}
|
||||||
|
onClick={() => moveInstance(index, 1)}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Switch
|
||||||
|
checked={instance.enabled}
|
||||||
|
onCheckedChange={() => toggleEnabled(instance)}
|
||||||
|
aria-label={`Toggle ${instance.title}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => startEdit(instance)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive"
|
||||||
|
onClick={() => removeInstance(instance)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-sm font-medium">Add widget</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.values(BUILTIN_WIDGETS).map((b) => (
|
||||||
|
<Button
|
||||||
|
key={b.kind}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => startAddBuiltIn(b.kind)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
{b.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
{services
|
||||||
|
.filter((s) => s.enabled)
|
||||||
|
.flatMap((s) =>
|
||||||
|
(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map((w) => (
|
||||||
|
<Button
|
||||||
|
key={`${s.id}:${w.kind}`}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => startAddService(s.id, w.kind)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
{w.name} · {s.name}
|
||||||
|
</Button>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Configure services on their service pages to unlock more widgets.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialogTitle = draft
|
||||||
|
? draft.id
|
||||||
|
? "Edit widget"
|
||||||
|
: "Add widget"
|
||||||
|
: "Dashboard widgets";
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<SheetForm
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) handleClose(next);
|
||||||
|
}}
|
||||||
|
title={dialogTitle}
|
||||||
|
onSave={draft ? saveDraft : () => handleClose(false)}
|
||||||
|
onCancel={draft ? reset : () => handleClose(false)}
|
||||||
|
saveLabel={draft ? "Save widget" : "Done"}
|
||||||
|
isPending={draft ? saveWidget.isPending : false}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">{draftBody}</div>
|
||||||
|
</SheetForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-2xl">
|
<DialogContent className="sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||||
{draft
|
|
||||||
? draft.id
|
|
||||||
? "Edit widget"
|
|
||||||
: "Add widget"
|
|
||||||
: "Dashboard widgets"}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
{draftBody}
|
||||||
{draft ? (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<Field label="Title" htmlFor="widget-title">
|
|
||||||
<Input
|
|
||||||
id="widget-title"
|
|
||||||
value={draft.title}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft({ ...draft, title: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Sort order" htmlFor="widget-sort-order">
|
|
||||||
<Input
|
|
||||||
id="widget-sort-order"
|
|
||||||
type="number"
|
|
||||||
value={String(draft.sortOrder)}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft({
|
|
||||||
...draft,
|
|
||||||
sortOrder:
|
|
||||||
e.target.value === "" ? 0 : Number(e.target.value),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
id="widget-enabled"
|
|
||||||
checked={draft.enabled}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setDraft({ ...draft, enabled: checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="widget-enabled">Enabled</Label>
|
|
||||||
</div>
|
|
||||||
<WidgetConfigEditor
|
|
||||||
binding={draftBinding}
|
|
||||||
isTaskOutput={!!isTaskOutput}
|
|
||||||
config={draft.config}
|
|
||||||
onChange={(config) => setDraft({ ...draft, config })}
|
|
||||||
tasks={tasks}
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="outline" onClick={reset}>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button onClick={saveDraft} disabled={saveWidget.isPending}>
|
|
||||||
Save widget
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{sortedInstances.length === 0 ? (
|
|
||||||
<Alert>
|
|
||||||
<AlertDescription>
|
|
||||||
No widgets yet. Add one below.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{sortedInstances.map((instance, index) => {
|
|
||||||
const serviceName = instance.service_id
|
|
||||||
? services.find((s) => s.id === instance.service_id)?.name
|
|
||||||
: "Built-in";
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={instance.id}
|
|
||||||
className="flex items-center gap-2 rounded border p-2"
|
|
||||||
>
|
|
||||||
<div className="flex flex-1 flex-col gap-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium">{instance.title}</span>
|
|
||||||
<Badge variant="outline">
|
|
||||||
{bindingLabel(
|
|
||||||
instance.service_id,
|
|
||||||
instance.widget_kind,
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
{serviceName ? (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{serviceName}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{!instance.enabled ? (
|
|
||||||
<Badge variant="secondary">disabled</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
disabled={index === 0}
|
|
||||||
onClick={() => moveInstance(index, -1)}
|
|
||||||
>
|
|
||||||
<ChevronUp className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
disabled={index === sortedInstances.length - 1}
|
|
||||||
onClick={() => moveInstance(index, 1)}
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Switch
|
|
||||||
checked={instance.enabled}
|
|
||||||
onCheckedChange={() => toggleEnabled(instance)}
|
|
||||||
aria-label={`Toggle ${instance.title}`}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
onClick={() => startEdit(instance)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-destructive"
|
|
||||||
onClick={() => removeInstance(instance)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<p className="text-sm font-medium">Add widget</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{Object.values(BUILTIN_WIDGETS).map((b) => (
|
|
||||||
<Button
|
|
||||||
key={b.kind}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => startAddBuiltIn(b.kind)}
|
|
||||||
>
|
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
|
||||||
{b.name}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
{services
|
|
||||||
.filter((s) => s.enabled)
|
|
||||||
.flatMap((s) =>
|
|
||||||
(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map(
|
|
||||||
(w) => (
|
|
||||||
<Button
|
|
||||||
key={`${s.id}:${w.kind}`}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => startAddService(s.id, w.kind)}
|
|
||||||
>
|
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
|
||||||
{w.name} · {s.name}
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Configure services on their service pages to unlock more
|
|
||||||
widgets.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { WidgetConfigDialog } from "../WidgetConfigDialog";
|
||||||
|
|
||||||
|
// jsdom has no window.matchMedia; default to desktop (matches: false).
|
||||||
|
function setMatchMedia(matches: boolean) {
|
||||||
|
window.matchMedia = ((query: string) => ({
|
||||||
|
matches: query.includes("768") ? matches : false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
})) as unknown as typeof window.matchMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useWidgets", () => ({
|
||||||
|
useWidgetInstances: () => ({ data: [] }),
|
||||||
|
useSaveWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||||
|
useDeleteWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useServices", () => ({
|
||||||
|
useServiceInstances: () => ({ data: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useSettings", () => ({
|
||||||
|
useTasks: () => ({ data: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => setMatchMedia(false));
|
||||||
|
|
||||||
|
describe("WidgetConfigDialog (desktop)", () => {
|
||||||
|
it("renders a Dialog with the dashboard widgets title at md+", () => {
|
||||||
|
render(<WidgetConfigDialog open={true} onClose={() => {}} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Dashboard widgets" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WidgetConfigDialog (mobile SheetForm — slice 8)", () => {
|
||||||
|
beforeEach(() => setMatchMedia(true));
|
||||||
|
|
||||||
|
it("renders a SheetForm with the dashboard widgets title below md", () => {
|
||||||
|
render(<WidgetConfigDialog open={true} onClose={() => {}} />);
|
||||||
|
expect(screen.getByText("Dashboard widgets")).toBeInTheDocument();
|
||||||
|
// List mode footer: "Done" button closes.
|
||||||
|
expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||||
|
import { SheetForm } from "@/components/ui/sheet-form";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -813,229 +814,262 @@ export function UsersPage() {
|
|||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
<Dialog
|
{/* Compose dialog: SheetForm below md, Dialog at md+ (spec R4.1) */}
|
||||||
open={composeOpen}
|
{(() => {
|
||||||
onOpenChange={(open) => {
|
const composeBody = (
|
||||||
if (!open) {
|
<>
|
||||||
closeCompose();
|
{sendUserMessage.isPending ? (
|
||||||
}
|
<Progress value={100} className="animate-pulse" />
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent
|
|
||||||
className={cn(
|
|
||||||
"flex max-h-[90dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl",
|
|
||||||
isComposeMobile &&
|
|
||||||
"inset-0 max-h-none max-w-none translate-x-0 translate-y-0 rounded-none",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<DialogHeader className="gap-1 px-4 pt-4">
|
|
||||||
<DialogTitle className="pr-8">Message selected users</DialogTitle>
|
|
||||||
<DialogDescription className="sr-only">
|
|
||||||
Compose a message to the selected deliverable users.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{sendUserMessage.isPending ? (
|
|
||||||
<Progress value={100} className="animate-pulse" />
|
|
||||||
) : null}
|
|
||||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
|
|
||||||
{sendUserMessage.isError ? (
|
|
||||||
<UIAlert variant="destructive">
|
|
||||||
<AlertDescription>
|
|
||||||
Unable to send message:{" "}
|
|
||||||
{(sendUserMessage.error as Error)?.message || "Unknown error"}
|
|
||||||
</AlertDescription>
|
|
||||||
</UIAlert>
|
|
||||||
) : null}
|
) : null}
|
||||||
{sendUserMessage.isSuccess ? (
|
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
|
||||||
|
{sendUserMessage.isError ? (
|
||||||
|
<UIAlert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
Unable to send message:{" "}
|
||||||
|
{(sendUserMessage.error as Error)?.message ||
|
||||||
|
"Unknown error"}
|
||||||
|
</AlertDescription>
|
||||||
|
</UIAlert>
|
||||||
|
) : null}
|
||||||
|
{sendUserMessage.isSuccess ? (
|
||||||
|
<UIAlert>
|
||||||
|
<AlertDescription>
|
||||||
|
Queued for {sendUserMessage.data.recipient_count} recipients
|
||||||
|
{sendUserMessage.data.attachment_count
|
||||||
|
? ` with ${sendUserMessage.data.attachment_count} attachment${sendUserMessage.data.attachment_count === 1 ? "" : "s"}`
|
||||||
|
: ""}
|
||||||
|
{sendUserMessage.data.request_id
|
||||||
|
? ` (request ${sendUserMessage.data.request_id.slice(0, 8)})`
|
||||||
|
: ""}
|
||||||
|
.
|
||||||
|
</AlertDescription>
|
||||||
|
</UIAlert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{queueBanner ? (
|
||||||
|
<UIAlert
|
||||||
|
variant={
|
||||||
|
queueBanner.severity === "error" ? "destructive" : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{queueBanner.message}
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary">{queueBanner.countLabel}</Badge>
|
||||||
|
</div>
|
||||||
|
</UIAlert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<UIAlert>
|
<UIAlert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Queued for {sendUserMessage.data.recipient_count} recipients
|
{selectedRows.length} selected,{" "}
|
||||||
{sendUserMessage.data.attachment_count
|
{selectedDeliverableRows.length} deliverable.
|
||||||
? ` with ${sendUserMessage.data.attachment_count} attachment${sendUserMessage.data.attachment_count === 1 ? "" : "s"}`
|
{skippedRows.length
|
||||||
|
? ` ${skippedRows.length} will be skipped because they do not have a deliverable email address.`
|
||||||
: ""}
|
: ""}
|
||||||
{sendUserMessage.data.request_id
|
|
||||||
? ` (request ${sendUserMessage.data.request_id.slice(0, 8)})`
|
|
||||||
: ""}
|
|
||||||
.
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</UIAlert>
|
</UIAlert>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{queueBanner ? (
|
<div className="flex flex-wrap gap-1">
|
||||||
<UIAlert
|
{selectedDeliverableRows.map((row) => (
|
||||||
variant={
|
<Badge key={row.jellyfin_id} variant="secondary">
|
||||||
queueBanner.severity === "error" ? "destructive" : undefined
|
{`${userLabel(row)} <${row.email}>`}
|
||||||
}
|
</Badge>
|
||||||
>
|
))}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
</div>
|
||||||
<span className="text-sm font-semibold">
|
|
||||||
{queueBanner.message}
|
|
||||||
</span>
|
|
||||||
<Badge variant="secondary">{queueBanner.countLabel}</Badge>
|
|
||||||
</div>
|
|
||||||
</UIAlert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<UIAlert>
|
<div className="flex flex-col gap-1.5">
|
||||||
<AlertDescription>
|
<Label htmlFor="compose-subject">Subject</Label>
|
||||||
{selectedRows.length} selected, {selectedDeliverableRows.length}{" "}
|
<Input
|
||||||
deliverable.
|
id="compose-subject"
|
||||||
{skippedRows.length
|
value={subject}
|
||||||
? ` ${skippedRows.length} will be skipped because they do not have a deliverable email address.`
|
onChange={(event) => setSubject(event.target.value)}
|
||||||
: ""}
|
|
||||||
</AlertDescription>
|
|
||||||
</UIAlert>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{selectedDeliverableRows.map((row) => (
|
|
||||||
<Badge key={row.jellyfin_id} variant="secondary">
|
|
||||||
{`${userLabel(row)} <${row.email}>`}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="compose-subject">Subject</Label>
|
|
||||||
<Input
|
|
||||||
id="compose-subject"
|
|
||||||
value={subject}
|
|
||||||
onChange={(event) => setSubject(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<UiButton
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => insertMarkup("<strong>", "</strong>")}
|
|
||||||
aria-label="Bold"
|
|
||||||
>
|
|
||||||
<Bold />
|
|
||||||
</UiButton>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Bold</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<UiButton
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => insertMarkup("<em>", "</em>")}
|
|
||||||
aria-label="Italic"
|
|
||||||
>
|
|
||||||
<Italic />
|
|
||||||
</UiButton>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Italic</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<UiButton
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={addLink}
|
|
||||||
aria-label="Link"
|
|
||||||
>
|
|
||||||
<Link />
|
|
||||||
</UiButton>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Link</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<UiButton
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => insertMarkup("<ul><li>", "</li></ul>")}
|
|
||||||
aria-label="Bullet list"
|
|
||||||
>
|
|
||||||
<List />
|
|
||||||
</UiButton>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Bullet list</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="compose-body">HTML message body</Label>
|
|
||||||
<Textarea
|
|
||||||
id="compose-body"
|
|
||||||
ref={htmlBodyRef}
|
|
||||||
value={htmlBody}
|
|
||||||
onChange={(event) => setHtmlBody(event.target.value)}
|
|
||||||
className="min-h-[260px] font-mono"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Formatting is sent as HTML; a plain-text fallback is generated
|
|
||||||
automatically.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border bg-muted/40 p-4">
|
|
||||||
<p className="mb-2 text-sm font-semibold">Preview</p>
|
|
||||||
<div className="overflow-hidden rounded-md border bg-card">
|
|
||||||
<iframe
|
|
||||||
title="Email preview"
|
|
||||||
sandbox=""
|
|
||||||
srcDoc={`<!doctype html><html><head><meta charset="utf-8"><style>body{font-family:Roboto,Arial,sans-serif;padding:16px;margin:0;background:#fff;color:#111;line-height:1.5}</style></head><body>${htmlBody || "<p>(Empty)</p>"}</body></html>`}
|
|
||||||
style={{ width: "100%", minHeight: 220, border: 0 }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
<UiButton asChild variant="outline">
|
<Tooltip>
|
||||||
<label className="cursor-pointer">
|
<TooltipTrigger asChild>
|
||||||
<Paperclip />
|
<UiButton
|
||||||
Add attachment
|
variant="ghost"
|
||||||
<input
|
size="icon"
|
||||||
hidden
|
onClick={() => insertMarkup("<strong>", "</strong>")}
|
||||||
type="file"
|
aria-label="Bold"
|
||||||
multiple
|
>
|
||||||
onChange={handleAttachments}
|
<Bold />
|
||||||
|
</UiButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Bold</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<UiButton
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => insertMarkup("<em>", "</em>")}
|
||||||
|
aria-label="Italic"
|
||||||
|
>
|
||||||
|
<Italic />
|
||||||
|
</UiButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Italic</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<UiButton
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={addLink}
|
||||||
|
aria-label="Link"
|
||||||
|
>
|
||||||
|
<Link />
|
||||||
|
</UiButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Link</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<UiButton
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => insertMarkup("<ul><li>", "</li></ul>")}
|
||||||
|
aria-label="Bullet list"
|
||||||
|
>
|
||||||
|
<List />
|
||||||
|
</UiButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Bullet list</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="compose-body">HTML message body</Label>
|
||||||
|
<Textarea
|
||||||
|
id="compose-body"
|
||||||
|
ref={htmlBodyRef}
|
||||||
|
value={htmlBody}
|
||||||
|
onChange={(event) => setHtmlBody(event.target.value)}
|
||||||
|
className="min-h-[260px] font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Formatting is sent as HTML; a plain-text fallback is generated
|
||||||
|
automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-muted/40 p-4">
|
||||||
|
<p className="mb-2 text-sm font-semibold">Preview</p>
|
||||||
|
<div className="overflow-hidden rounded-md border bg-card">
|
||||||
|
<iframe
|
||||||
|
title="Email preview"
|
||||||
|
sandbox=""
|
||||||
|
srcDoc={`<!doctype html><html><head><meta charset="utf-8"><style>body{font-family:Roboto,Arial,sans-serif;padding:16px;margin:0;background:#fff;color:#111;line-height:1.5}</style></head><body>${htmlBody || "<p>(Empty)</p>"}</body></html>`}
|
||||||
|
style={{ width: "100%", minHeight: 220, border: 0 }}
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
</UiButton>
|
</div>
|
||||||
{attachments.map((file, index) => (
|
|
||||||
<Badge
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
key={`${file.name}-${index}`}
|
<UiButton asChild variant="outline">
|
||||||
variant="secondary"
|
<label className="cursor-pointer">
|
||||||
className="gap-1 pr-1"
|
<Paperclip />
|
||||||
>
|
Add attachment
|
||||||
{file.name}
|
<input
|
||||||
<button
|
hidden
|
||||||
type="button"
|
type="file"
|
||||||
aria-label={`Remove ${file.name}`}
|
multiple
|
||||||
onClick={() => removeAttachment(index)}
|
onChange={handleAttachments}
|
||||||
className="inline-flex items-center text-current [&>svg]:size-3"
|
/>
|
||||||
|
</label>
|
||||||
|
</UiButton>
|
||||||
|
{attachments.map((file, index) => (
|
||||||
|
<Badge
|
||||||
|
key={`${file.name}-${index}`}
|
||||||
|
variant="secondary"
|
||||||
|
className="gap-1 pr-1"
|
||||||
>
|
>
|
||||||
<Trash2 />
|
{file.name}
|
||||||
</button>
|
<button
|
||||||
</Badge>
|
type="button"
|
||||||
))}
|
aria-label={`Remove ${file.name}`}
|
||||||
|
onClick={() => removeAttachment(index)}
|
||||||
|
className="inline-flex items-center text-current [&>svg]:size-3"
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
<DialogFooter className="m-0 border-t p-4">
|
);
|
||||||
<UiButton variant="ghost" onClick={closeCompose}>
|
|
||||||
Cancel
|
if (isMobile) {
|
||||||
</UiButton>
|
return (
|
||||||
<UiButton
|
<SheetForm
|
||||||
variant="default"
|
open={composeOpen}
|
||||||
disabled={
|
onOpenChange={(open) => {
|
||||||
sendUserMessage.isPending ||
|
if (!open) closeCompose();
|
||||||
!selectedDeliverableRows.length ||
|
}}
|
||||||
!subject.trim()
|
title="Message selected users"
|
||||||
}
|
onSave={handleSend}
|
||||||
onClick={handleSend}
|
onCancel={closeCompose}
|
||||||
|
isPending={sendUserMessage.isPending}
|
||||||
|
saveDisabled={!selectedDeliverableRows.length || !subject.trim()}
|
||||||
|
saveLabel="Send message"
|
||||||
>
|
>
|
||||||
<Send />
|
<div className="flex flex-col gap-4">{composeBody}</div>
|
||||||
Send message
|
</SheetForm>
|
||||||
</UiButton>
|
);
|
||||||
</DialogFooter>
|
}
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={composeOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
closeCompose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
"flex max-h-[90dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl",
|
||||||
|
isComposeMobile &&
|
||||||
|
"inset-0 max-h-none max-w-none translate-x-0 translate-y-0 rounded-none",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DialogHeader className="gap-1 px-4 pt-4">
|
||||||
|
<DialogTitle className="pr-8">
|
||||||
|
Message selected users
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
Compose a message to the selected deliverable users.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{composeBody}
|
||||||
|
<DialogFooter className="m-0 border-t p-4">
|
||||||
|
<UiButton variant="ghost" onClick={closeCompose}>
|
||||||
|
Cancel
|
||||||
|
</UiButton>
|
||||||
|
<UiButton
|
||||||
|
variant="default"
|
||||||
|
disabled={
|
||||||
|
sendUserMessage.isPending ||
|
||||||
|
!selectedDeliverableRows.length ||
|
||||||
|
!subject.trim()
|
||||||
|
}
|
||||||
|
onClick={handleSend}
|
||||||
|
>
|
||||||
|
<Send />
|
||||||
|
Send message
|
||||||
|
</UiButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ describe("UsersPage (slice 6b — compose dialog formatting actions)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("UsersPage (mobile card layout — slice 5)", () => {
|
describe("UsersPage (mobile card layout — slice 5)", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.matchMedia = ((query: string) => ({
|
window.matchMedia = ((query: string) => ({
|
||||||
matches: query.includes("768"),
|
matches: query.includes("768"),
|
||||||
@@ -342,4 +342,35 @@ describe("UsersPage (slice 6b — compose dialog formatting actions)", () => {
|
|||||||
// drawer opens via a card-body tap, not via the checkbox.
|
// drawer opens via a card-body tap, not via the checkbox.
|
||||||
expect(screen.queryByTestId("session-panel-stub")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("session-panel-stub")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders compose in a SheetForm below md with send button", async () => {
|
||||||
|
users = [
|
||||||
|
userFixture({
|
||||||
|
jellyfin_id: "u1",
|
||||||
|
display_name: "Alice",
|
||||||
|
email: "alice@example.com",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<UsersPage />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Select the deliverable user via the mobile card checkbox.
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("checkbox", { name: /Select Alice/i }),
|
||||||
|
);
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Message selected" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// On mobile, compose opens in a SheetForm (not a Dialog). The SheetForm
|
||||||
|
// header carries the title and the footer carries the Send button.
|
||||||
|
expect(screen.getByText("Message selected users")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Send message" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Subject")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user