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:
@@ -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>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user