feat(frontend): slice 6b — Users compose dialog + 9 icons (finish Users)

Web UI rework. Completes the Users migration (6a directory + 6b compose):
- Compose dialog off @mui: shadcn Dialog family + Input (subject) +
  Textarea (html body) + Separator + Label; IconButton -> Button ghost
- 9 @mui/icons-material -> lucide-react: Close->X, AttachFile->Paperclip,
  FormatBold->Bold, FormatItalic->Italic, Link->Link,
  FormatListBulleted->List, MailOutlined->Mail, Send->Send,
  DeleteOutlined->Trash2
- Rich-text compose parity: markup insertion, attachments (FormData),
  queue-status polling, send via useSendUserMessage
- UsersPage.impl.tsx now has ZERO @mui imports

Gate: build + lint + test green (20 files / 46 tests).
This commit is contained in:
Developer
2026-06-17 16:18:49 +00:00
parent 1e23c07a20
commit 04f2e59c92
2 changed files with 158 additions and 93 deletions
+114 -93
View File
@@ -1,30 +1,32 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import type { ChangeEvent } from "react"; import type { ChangeEvent } from "react";
// Slice 6b owns the compose-dialog STRUCTURE + these icons: Dialog family, // Slice 6b: compose dialog (shadcn Dialog family) + lucide icons. The file is
// TextField, Divider, IconButton (below). Slice 6a migrated the directory // now fully @mui-free (6a migrated the directory surface, drawer, and the
// surface + drawer AND the compose content's shared leaf components. // compose content's shared leaf components).
import CloseIcon from "@mui/icons-material/Close"; import {
import AttachFileIcon from "@mui/icons-material/AttachFile"; X,
import FormatBoldIcon from "@mui/icons-material/FormatBold"; Paperclip,
import FormatItalicIcon from "@mui/icons-material/FormatItalic"; Bold,
import LinkIcon from "@mui/icons-material/Link"; Italic,
import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted"; Link,
import MailOutlinedIcon from "@mui/icons-material/MailOutlined"; List,
import SendIcon from "@mui/icons-material/Send"; Mail,
import DeleteOutlinedIcon from "@mui/icons-material/DeleteOutlined"; Send,
Trash2,
} from "lucide-react";
import { import {
// 6b-owned compose-dialog components only (the contract's narrow @mui set).
// Everything else — Alert/Box/Button/Chip/Paper/Stack/Typography/Tooltip/
// LinearProgress/useMediaQuery — was migrated to shadcn/Tailwind in 6a.
Dialog, Dialog,
DialogActions,
DialogContent, DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle, DialogTitle,
Divider, } from "@/components/ui/dialog";
IconButton, import { Input } from "@/components/ui/input";
TextField, import { Textarea } from "@/components/ui/textarea";
} from "@mui/material"; import { Separator } from "@/components/ui/separator";
import { Label } from "@/components/ui/label";
// Slice 6a directory surface + drawer primitives. // Slice 6a directory surface + drawer primitives.
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -456,7 +458,7 @@ export function UsersPage() {
disabled={!selectedDeliverableRows.length} disabled={!selectedDeliverableRows.length}
onClick={openCompose} onClick={openCompose}
> >
<MailOutlinedIcon /> <Mail />
Message selected Message selected
</UiButton> </UiButton>
<UiButton <UiButton
@@ -466,13 +468,12 @@ export function UsersPage() {
> >
Clear selection Clear selection
</UiButton> </UiButton>
<TextField <Input
size="small" aria-label="Search"
label="Search"
placeholder="Name, email, role, permission..." placeholder="Name, email, role, permission..."
value={search} value={search}
onChange={(event) => setSearch(event.target.value)} onChange={(event) => setSearch(event.target.value)}
sx={{ minWidth: { xs: "100%", sm: 320 } }} className="w-full sm:w-80"
/> />
</div> </div>
</div> </div>
@@ -671,7 +672,7 @@ export function UsersPage() {
aria-label="Close user details" aria-label="Close user details"
onClick={() => setSearchParams({})} onClick={() => setSearchParams({})}
> >
<CloseIcon /> <X />
Close Close
</UiButton> </UiButton>
</div> </div>
@@ -743,7 +744,7 @@ export function UsersPage() {
</div> </div>
</div> </div>
<Divider /> <Separator />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
This panel is read-only for now. Communication actions will be This panel is read-only for now. Communication actions will be
@@ -756,26 +757,29 @@ export function UsersPage() {
<Dialog <Dialog
open={composeOpen} open={composeOpen}
onClose={closeCompose} onOpenChange={(open) => {
fullWidth if (!open) {
maxWidth="md" closeCompose();
fullScreen={isMobile} }
}}
> >
<DialogTitle sx={{ pr: 6 }}> <DialogContent
Message selected users className={cn(
<IconButton "flex max-h-[90dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl",
aria-label="Close compose dialog" isMobile &&
onClick={closeCompose} "inset-0 max-h-none max-w-none translate-x-0 translate-y-0 rounded-none",
sx={{ position: "absolute", right: 12, top: 12 }} )}
> >
<CloseIcon /> <DialogHeader className="gap-1 px-4 pt-4">
</IconButton> <DialogTitle className="pr-8">Message selected users</DialogTitle>
</DialogTitle> <DialogDescription className="sr-only">
{sendUserMessage.isPending ? ( Compose a message to the selected deliverable users.
<Progress value={100} className="animate-pulse" /> </DialogDescription>
) : null} </DialogHeader>
<DialogContent dividers> {sendUserMessage.isPending ? (
<div className="flex flex-col gap-4"> <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 ? ( {sendUserMessage.isError ? (
<UIAlert variant="destructive"> <UIAlert variant="destructive">
<AlertDescription> <AlertDescription>
@@ -832,67 +836,84 @@ export function UsersPage() {
))} ))}
</div> </div>
<TextField <div className="flex flex-col gap-1.5">
label="Subject" <Label htmlFor="compose-subject">Subject</Label>
fullWidth <Input
value={subject} id="compose-subject"
onChange={(event) => setSubject(event.target.value)} value={subject}
/> onChange={(event) => setSubject(event.target.value)}
/>
</div>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<IconButton <UiButton
variant="ghost"
size="icon"
onClick={() => insertMarkup("<strong>", "</strong>")} onClick={() => insertMarkup("<strong>", "</strong>")}
aria-label="Bold" aria-label="Bold"
> >
<FormatBoldIcon /> <Bold />
</IconButton> </UiButton>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Bold</TooltipContent> <TooltipContent>Bold</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<IconButton <UiButton
variant="ghost"
size="icon"
onClick={() => insertMarkup("<em>", "</em>")} onClick={() => insertMarkup("<em>", "</em>")}
aria-label="Italic" aria-label="Italic"
> >
<FormatItalicIcon /> <Italic />
</IconButton> </UiButton>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Italic</TooltipContent> <TooltipContent>Italic</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<IconButton onClick={addLink} aria-label="Link"> <UiButton
<LinkIcon /> variant="ghost"
</IconButton> size="icon"
onClick={addLink}
aria-label="Link"
>
<Link />
</UiButton>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Link</TooltipContent> <TooltipContent>Link</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<IconButton <UiButton
variant="ghost"
size="icon"
onClick={() => insertMarkup("<ul><li>", "</li></ul>")} onClick={() => insertMarkup("<ul><li>", "</li></ul>")}
aria-label="Bullet list" aria-label="Bullet list"
> >
<FormatListBulletedIcon /> <List />
</IconButton> </UiButton>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Bullet list</TooltipContent> <TooltipContent>Bullet list</TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<TextField <div className="flex flex-col gap-1.5">
label="HTML message body" <Label htmlFor="compose-body">HTML message body</Label>
multiline <Textarea
minRows={10} id="compose-body"
fullWidth ref={htmlBodyRef}
value={htmlBody} value={htmlBody}
inputRef={htmlBodyRef} onChange={(event) => setHtmlBody(event.target.value)}
onChange={(event) => setHtmlBody(event.target.value)} className="min-h-[260px] font-mono"
helperText="Formatting is sent as HTML; a plain-text fallback is generated automatically." />
/> <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"> <div className="rounded-lg border bg-muted/40 p-4">
<p className="mb-2 text-sm font-semibold">Preview</p> <p className="mb-2 text-sm font-semibold">Preview</p>
@@ -909,7 +930,7 @@ export function UsersPage() {
<div className="flex flex-wrap items-center gap-1"> <div className="flex flex-wrap items-center gap-1">
<UiButton asChild variant="outline"> <UiButton asChild variant="outline">
<label className="cursor-pointer"> <label className="cursor-pointer">
<AttachFileIcon /> <Paperclip />
Add attachment Add attachment
<input <input
hidden hidden
@@ -932,30 +953,30 @@ export function UsersPage() {
onClick={() => removeAttachment(index)} onClick={() => removeAttachment(index)}
className="inline-flex items-center text-current [&>svg]:size-3" className="inline-flex items-center text-current [&>svg]:size-3"
> >
<DeleteOutlinedIcon /> <Trash2 />
</button> </button>
</Badge> </Badge>
))} ))}
</div> </div>
</div> </div>
<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> </DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<UiButton variant="ghost" onClick={closeCompose}>
Cancel
</UiButton>
<UiButton
variant="default"
disabled={
sendUserMessage.isPending ||
!selectedDeliverableRows.length ||
!subject.trim()
}
onClick={handleSend}
>
<SendIcon />
Send message
</UiButton>
</DialogActions>
</Dialog> </Dialog>
</div> </div>
); );
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { UsersPage } from "../UsersPage.impl"; import { UsersPage } from "../UsersPage.impl";
import { TooltipProvider } from "../../components/ui/tooltip";
import type { import type {
NowPlayingSession, NowPlayingSession,
UserDirectoryItem, UserDirectoryItem,
@@ -23,6 +24,14 @@ beforeEach(() => {
dispatchEvent: () => false, dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia; })) as unknown as typeof window.matchMedia;
} }
// The compose formatting actions defer a focus/selection restore via
// requestAnimationFrame (see insertMarkup). jsdom may not flush rAF
// synchronously, so make it synchronous so the slice-6b compose test can
// observe the html-body value update.
window.requestAnimationFrame = ((cb: FrameRequestCallback) => {
cb(0);
return 0;
}) as typeof window.requestAnimationFrame;
}); });
// Keep the drawer's nested session panel out of the DOM under test. // Keep the drawer's nested session panel out of the DOM under test.
@@ -239,3 +248,38 @@ describe("UsersPage (slice 6a — directory surface + drawer)", () => {
expect(screen.getByTestId("session-panel-stub")).toBeInTheDocument(); expect(screen.getByTestId("session-panel-stub")).toBeInTheDocument();
}); });
}); });
describe("UsersPage (slice 6b — compose dialog formatting actions)", () => {
it("opens compose and inserts bold markup into the html body", async () => {
users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
// The formatting toolbar renders <Tooltip> (shadcn), which in the app is
// wrapped by a global <TooltipProvider> in App.tsx; supply it here.
render(
<TooltipProvider>
<UsersPage />
</TooltipProvider>,
);
// Select a deliverable user so the "Message selected" button enables.
await userEvent.click(
screen.getByRole("checkbox", { name: "Select Alice" }),
);
await userEvent.click(
screen.getByRole("button", { name: "Message selected" }),
);
// Compose dialog opens (shadcn Dialog family).
expect(
screen.getByRole("heading", { name: "Message selected users" }),
).toBeInTheDocument();
// Bold action wraps the cursor selection in <strong></strong> via the
// preserved insertMarkup helper (markup insertion actions parity).
await userEvent.click(screen.getByRole("button", { name: "Bold" }));
const body = screen.getByRole("textbox", {
name: "HTML message body",
}) as HTMLTextAreaElement;
expect(body.value).toContain("<strong>");
});
});