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";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import { useTasks } from "../hooks/useSettings";
|
||||
import { useIsMobile } from "../hooks/useIsMobile";
|
||||
import { SheetForm } from "@/components/ui/sheet-form";
|
||||
import type { WidgetInstance, WidgetInstanceInput } from "../types";
|
||||
import {
|
||||
BUILTIN_WIDGETS,
|
||||
@@ -277,34 +279,23 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||
]?.widgets.find((w) => w.kind === draft.widgetKind)
|
||||
: BUILTIN_WIDGETS[draft.widgetKind]
|
||||
: undefined;
|
||||
const isMobile = useIsMobile();
|
||||
const isTaskOutput =
|
||||
draft?.serviceId !== null &&
|
||||
services.find((s) => s.id === draft?.serviceId)?.service_type ===
|
||||
"ssh_tasks";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{draft
|
||||
? draft.id
|
||||
? "Edit widget"
|
||||
: "Add widget"
|
||||
: "Dashboard widgets"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{draft ? (
|
||||
// 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 })
|
||||
}
|
||||
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Sort order" htmlFor="widget-sort-order">
|
||||
@@ -315,8 +306,7 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
sortOrder:
|
||||
e.target.value === "" ? 0 : Number(e.target.value),
|
||||
sortOrder: e.target.value === "" ? 0 : Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -339,6 +329,7 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||
onChange={(config) => setDraft({ ...draft, config })}
|
||||
tasks={tasks}
|
||||
/>
|
||||
{!isMobile ? (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={reset}>
|
||||
Back
|
||||
@@ -347,14 +338,13 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||
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>
|
||||
<AlertDescription>No widgets yet. Add one below.</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -371,10 +361,7 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{instance.title}</span>
|
||||
<Badge variant="outline">
|
||||
{bindingLabel(
|
||||
instance.service_id,
|
||||
instance.widget_kind,
|
||||
)}
|
||||
{bindingLabel(instance.service_id, instance.widget_kind)}
|
||||
</Badge>
|
||||
{serviceName ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -450,8 +437,7 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||
{services
|
||||
.filter((s) => s.enabled)
|
||||
.flatMap((s) =>
|
||||
(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map(
|
||||
(w) => (
|
||||
(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map((w) => (
|
||||
<Button
|
||||
key={`${s.id}:${w.kind}`}
|
||||
variant="outline"
|
||||
@@ -461,17 +447,47 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||
<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.
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{draftBody}
|
||||
</DialogContent>
|
||||
</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,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { SheetForm } from "@/components/ui/sheet-form";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -813,27 +814,10 @@ export function UsersPage() {
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<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>
|
||||
{/* Compose dialog: SheetForm below md, Dialog at md+ (spec R4.1) */}
|
||||
{(() => {
|
||||
const composeBody = (
|
||||
<>
|
||||
{sendUserMessage.isPending ? (
|
||||
<Progress value={100} className="animate-pulse" />
|
||||
) : null}
|
||||
@@ -842,7 +826,8 @@ export function UsersPage() {
|
||||
<UIAlert variant="destructive">
|
||||
<AlertDescription>
|
||||
Unable to send message:{" "}
|
||||
{(sendUserMessage.error as Error)?.message || "Unknown error"}
|
||||
{(sendUserMessage.error as Error)?.message ||
|
||||
"Unknown error"}
|
||||
</AlertDescription>
|
||||
</UIAlert>
|
||||
) : null}
|
||||
@@ -878,8 +863,8 @@ export function UsersPage() {
|
||||
|
||||
<UIAlert>
|
||||
<AlertDescription>
|
||||
{selectedRows.length} selected, {selectedDeliverableRows.length}{" "}
|
||||
deliverable.
|
||||
{selectedRows.length} selected,{" "}
|
||||
{selectedDeliverableRows.length} deliverable.
|
||||
{skippedRows.length
|
||||
? ` ${skippedRows.length} will be skipped because they do not have a deliverable email address.`
|
||||
: ""}
|
||||
@@ -1017,6 +1002,53 @@ export function UsersPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<SheetForm
|
||||
open={composeOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeCompose();
|
||||
}}
|
||||
title="Message selected users"
|
||||
onSave={handleSend}
|
||||
onCancel={closeCompose}
|
||||
isPending={sendUserMessage.isPending}
|
||||
saveDisabled={!selectedDeliverableRows.length || !subject.trim()}
|
||||
saveLabel="Send message"
|
||||
>
|
||||
<div className="flex flex-col gap-4">{composeBody}</div>
|
||||
</SheetForm>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1036,6 +1068,8 @@ export function UsersPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
})()}
|
||||
</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(() => {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
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.
|
||||
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