diff --git a/frontend/src/components/WidgetConfigDialog.tsx b/frontend/src/components/WidgetConfigDialog.tsx index f898b30..e70ff23 100644 --- a/frontend/src/components/WidgetConfigDialog.tsx +++ b/frontend/src/components/WidgetConfigDialog.tsx @@ -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,201 +279,215 @@ 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"; + // 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 ? ( +
+
+ + setDraft({ ...draft, title: e.target.value })} + /> + + + + setDraft({ + ...draft, + sortOrder: e.target.value === "" ? 0 : Number(e.target.value), + }) + } + /> + +
+
+ + setDraft({ ...draft, enabled: checked }) + } + /> + +
+ setDraft({ ...draft, config })} + tasks={tasks} + /> + {!isMobile ? ( +
+ + +
+ ) : null} +
+ ) : ( +
+ {sortedInstances.length === 0 ? ( + + No widgets yet. Add one below. + + ) : ( +
+ {sortedInstances.map((instance, index) => { + const serviceName = instance.service_id + ? services.find((s) => s.id === instance.service_id)?.name + : "Built-in"; + return ( +
+
+
+ {instance.title} + + {bindingLabel(instance.service_id, instance.widget_kind)} + + {serviceName ? ( + + {serviceName} + + ) : null} + {!instance.enabled ? ( + disabled + ) : null} +
+
+
+ + + toggleEnabled(instance)} + aria-label={`Toggle ${instance.title}`} + /> + + +
+
+ ); + })} +
+ )} + +
+

Add widget

+
+ {Object.values(BUILTIN_WIDGETS).map((b) => ( + + ))} + {services + .filter((s) => s.enabled) + .flatMap((s) => + (SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map((w) => ( + + )), + )} +
+

+ Configure services on their service pages to unlock more widgets. +

+
+
+ ); + + const dialogTitle = draft + ? draft.id + ? "Edit widget" + : "Add widget" + : "Dashboard widgets"; + + if (isMobile) { + return ( + { + 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} + > +
{draftBody}
+
+ ); + } + return ( - - {draft - ? draft.id - ? "Edit widget" - : "Add widget" - : "Dashboard widgets"} - + {dialogTitle} - - {draft ? ( -
-
- - - setDraft({ ...draft, title: e.target.value }) - } - /> - - - - setDraft({ - ...draft, - sortOrder: - e.target.value === "" ? 0 : Number(e.target.value), - }) - } - /> - -
-
- - setDraft({ ...draft, enabled: checked }) - } - /> - -
- setDraft({ ...draft, config })} - tasks={tasks} - /> -
- - -
-
- ) : ( -
- {sortedInstances.length === 0 ? ( - - - No widgets yet. Add one below. - - - ) : ( -
- {sortedInstances.map((instance, index) => { - const serviceName = instance.service_id - ? services.find((s) => s.id === instance.service_id)?.name - : "Built-in"; - return ( -
-
-
- {instance.title} - - {bindingLabel( - instance.service_id, - instance.widget_kind, - )} - - {serviceName ? ( - - {serviceName} - - ) : null} - {!instance.enabled ? ( - disabled - ) : null} -
-
-
- - - toggleEnabled(instance)} - aria-label={`Toggle ${instance.title}`} - /> - - -
-
- ); - })} -
- )} - -
-

Add widget

-
- {Object.values(BUILTIN_WIDGETS).map((b) => ( - - ))} - {services - .filter((s) => s.enabled) - .flatMap((s) => - (SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map( - (w) => ( - - ), - ), - )} -
-

- Configure services on their service pages to unlock more - widgets. -

-
-
- )} + {draftBody}
); diff --git a/frontend/src/components/__tests__/WidgetConfigDialog.test.tsx b/frontend/src/components/__tests__/WidgetConfigDialog.test.tsx new file mode 100644 index 0000000..cb87696 --- /dev/null +++ b/frontend/src/components/__tests__/WidgetConfigDialog.test.tsx @@ -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( {}} />); + 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( {}} />); + expect(screen.getByText("Dashboard widgets")).toBeInTheDocument(); + // List mode footer: "Done" button closes. + expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/UsersPage.impl.tsx b/frontend/src/pages/UsersPage.impl.tsx index 31281c6..0061357 100644 --- a/frontend/src/pages/UsersPage.impl.tsx +++ b/frontend/src/pages/UsersPage.impl.tsx @@ -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,229 +814,262 @@ export function UsersPage() { - { - if (!open) { - closeCompose(); - } - }} - > - - - Message selected users - - Compose a message to the selected deliverable users. - - - {sendUserMessage.isPending ? ( - - ) : null} -
- {sendUserMessage.isError ? ( - - - Unable to send message:{" "} - {(sendUserMessage.error as Error)?.message || "Unknown error"} - - + {/* Compose dialog: SheetForm below md, Dialog at md+ (spec R4.1) */} + {(() => { + const composeBody = ( + <> + {sendUserMessage.isPending ? ( + ) : null} - {sendUserMessage.isSuccess ? ( +
+ {sendUserMessage.isError ? ( + + + Unable to send message:{" "} + {(sendUserMessage.error as Error)?.message || + "Unknown error"} + + + ) : null} + {sendUserMessage.isSuccess ? ( + + + 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)})` + : ""} + . + + + ) : null} + + {queueBanner ? ( + +
+ + {queueBanner.message} + + {queueBanner.countLabel} +
+
+ ) : null} + - Queued for {sendUserMessage.data.recipient_count} recipients - {sendUserMessage.data.attachment_count - ? ` with ${sendUserMessage.data.attachment_count} attachment${sendUserMessage.data.attachment_count === 1 ? "" : "s"}` + {selectedRows.length} selected,{" "} + {selectedDeliverableRows.length} deliverable. + {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)})` - : ""} - . - ) : null} - {queueBanner ? ( - -
- - {queueBanner.message} - - {queueBanner.countLabel} -
-
- ) : null} +
+ {selectedDeliverableRows.map((row) => ( + + {`${userLabel(row)} <${row.email}>`} + + ))} +
- - - {selectedRows.length} selected, {selectedDeliverableRows.length}{" "} - deliverable. - {skippedRows.length - ? ` ${skippedRows.length} will be skipped because they do not have a deliverable email address.` - : ""} - - - -
- {selectedDeliverableRows.map((row) => ( - - {`${userLabel(row)} <${row.email}>`} - - ))} -
- -
- - setSubject(event.target.value)} - /> -
- -
- - - insertMarkup("", "")} - aria-label="Bold" - > - - - - Bold - - - - insertMarkup("", "")} - aria-label="Italic" - > - - - - Italic - - - - - - - - Link - - - - insertMarkup("
  • ", "
")} - aria-label="Bullet list" - > - -
-
- Bullet list -
-
- -
- -