feat(frontend): slice 6a — Users directory surface + drawer (shadcn)
Web UI rework. Slice 6a (force-split; 6b = compose dialog next): - UsersPage.impl.tsx directory surface off @mui: shadcn Table family + Checkbox + Badge (status: success=chart-2/destructive/secondary) + Avatar + Tooltip + Progress + Alert/Button/Stack/Typography - MUI Drawer -> shadcn Sheet side="right" for user detail drawer (buildUserDrawerModel rendering preserved) - Selection-across-pagination + search/filter parity preserved - Compose-dialog MUI subset (Dialog/TextField/Divider/IconButton + 9 icons) intentionally LEFT for slice 6b Gate: build + lint + test green.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,241 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { UsersPage } from "../UsersPage.impl";
|
||||||
|
import type {
|
||||||
|
NowPlayingSession,
|
||||||
|
UserDirectoryItem,
|
||||||
|
UserDirectoryResponse,
|
||||||
|
} from "../../types";
|
||||||
|
|
||||||
|
// jsdom has no window.matchMedia; MUI `useMediaQuery` (still used by the
|
||||||
|
// compose dialog, slice 6b) must not blow up during render. Stub to "desktop".
|
||||||
|
beforeEach(() => {
|
||||||
|
if (!window.matchMedia) {
|
||||||
|
window.matchMedia = ((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
})) as unknown as typeof window.matchMedia;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep the drawer's nested session panel out of the DOM under test.
|
||||||
|
vi.mock("../../components/SessionActivityPanel", () => ({
|
||||||
|
SessionActivityPanel: ({
|
||||||
|
selectedUserLabel,
|
||||||
|
}: {
|
||||||
|
selectedUserLabel: string;
|
||||||
|
}) => <div data-testid="session-panel-stub">{selectedUserLabel}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let users: UserDirectoryItem[] = [];
|
||||||
|
let activity: NowPlayingSession[] = [];
|
||||||
|
|
||||||
|
function directoryResponse(): UserDirectoryResponse {
|
||||||
|
return {
|
||||||
|
items: users,
|
||||||
|
total: users.length,
|
||||||
|
jellyseerr_configured: true,
|
||||||
|
jellyseerr_available: true,
|
||||||
|
jellyseerr_error: "",
|
||||||
|
jellyseerr_jellyfin_user_count: 0,
|
||||||
|
jellyseerr_user_count: 0,
|
||||||
|
enriched_count: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useUsers", () => ({
|
||||||
|
useUsers: () => ({ data: directoryResponse(), isError: false, error: null }),
|
||||||
|
}));
|
||||||
|
vi.mock("../../hooks/useDashboard", () => ({
|
||||||
|
useActivity: () => ({ data: activity }),
|
||||||
|
}));
|
||||||
|
vi.mock("../../hooks/useUserMessageQueueStatus", () => ({
|
||||||
|
useUserMessageQueueStatus: () => ({ data: undefined, isError: false }),
|
||||||
|
}));
|
||||||
|
vi.mock("../../hooks/useSendUserMessage", () => ({
|
||||||
|
useSendUserMessage: () => ({
|
||||||
|
isPending: false,
|
||||||
|
isError: false,
|
||||||
|
isSuccess: false,
|
||||||
|
reset: vi.fn(),
|
||||||
|
mutateAsync: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// useSearchParams backs `?user=<id>` (drawer open) on a module-level object.
|
||||||
|
// `setSearchParams({ user })` opens the drawer; `setSearchParams({})` closes it.
|
||||||
|
let currentParams: Record<string, string> = {};
|
||||||
|
const setSearchParams = vi.fn((next: Record<string, string>) => {
|
||||||
|
currentParams = { ...next };
|
||||||
|
});
|
||||||
|
vi.mock("react-router-dom", () => ({
|
||||||
|
useSearchParams: () => [new URLSearchParams(currentParams), setSearchParams],
|
||||||
|
}));
|
||||||
|
|
||||||
|
function userFixture(
|
||||||
|
overrides: Partial<UserDirectoryItem> = {},
|
||||||
|
): UserDirectoryItem {
|
||||||
|
return {
|
||||||
|
jellyfin_id: "u1",
|
||||||
|
username: "alice",
|
||||||
|
display_name: "Alice",
|
||||||
|
email: "alice@example.com",
|
||||||
|
email_source: "jellyfin",
|
||||||
|
avatar: "",
|
||||||
|
avatar_source: "",
|
||||||
|
contactable: true,
|
||||||
|
source: "jellyfin",
|
||||||
|
source_summary: "",
|
||||||
|
name_source: "jellyfin",
|
||||||
|
access_source: "jellyfin",
|
||||||
|
jellyseerr_user_id: null,
|
||||||
|
jellyseerr_username: "",
|
||||||
|
user_type: 1,
|
||||||
|
user_type_label: "User",
|
||||||
|
role: "admin",
|
||||||
|
permissions: 1,
|
||||||
|
permissions_label: "Administrator",
|
||||||
|
request_count: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
users = [];
|
||||||
|
activity = [];
|
||||||
|
currentParams = {};
|
||||||
|
setSearchParams.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("UsersPage (slice 6a — directory surface + drawer)", () => {
|
||||||
|
it("renders the directory table and metric counts", () => {
|
||||||
|
users = [userFixture()];
|
||||||
|
render(<UsersPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Total users")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("User list")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("0 selected")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles row selection and reflects the selected-count badge", async () => {
|
||||||
|
users = [
|
||||||
|
userFixture({ jellyfin_id: "u1" }),
|
||||||
|
userFixture({
|
||||||
|
jellyfin_id: "u2",
|
||||||
|
username: "bob",
|
||||||
|
display_name: "Bob",
|
||||||
|
email: "bob@example.com",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
render(<UsersPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText("0 selected")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Selection-across-pagination: toggling a row updates the selected-id set.
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("checkbox", { name: "Select Alice" }),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("1 selected")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Toggling again removes it (the set survives, membership flips).
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("checkbox", { name: "Select Alice" }),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("0 selected")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects all visible rows via the header select-all checkbox", async () => {
|
||||||
|
users = [
|
||||||
|
userFixture({ jellyfin_id: "u1" }),
|
||||||
|
userFixture({
|
||||||
|
jellyfin_id: "u2",
|
||||||
|
username: "bob",
|
||||||
|
display_name: "Bob",
|
||||||
|
email: "bob@example.com",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
render(<UsersPage />);
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("checkbox", { name: "Select all visible users" }),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("2 selected")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the user drawer when a row is clicked (setSearchParams user)", async () => {
|
||||||
|
users = [userFixture({ jellyfin_id: "u1" })];
|
||||||
|
render(<UsersPage />);
|
||||||
|
|
||||||
|
// Clicking the row body (not the checkbox) opens the detail drawer.
|
||||||
|
await userEvent.click(screen.getByText("Alice"));
|
||||||
|
expect(setSearchParams).toHaveBeenCalledWith({ user: "u1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps activity status to Badge variants (Playing→success, Paused→warning)", () => {
|
||||||
|
users = [
|
||||||
|
userFixture({
|
||||||
|
jellyfin_id: "u1",
|
||||||
|
username: "alice",
|
||||||
|
display_name: "Alice",
|
||||||
|
}),
|
||||||
|
userFixture({
|
||||||
|
jellyfin_id: "u2",
|
||||||
|
username: "bob",
|
||||||
|
display_name: "Bob",
|
||||||
|
email: "bob@example.com",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
activity = [
|
||||||
|
{
|
||||||
|
user: "alice",
|
||||||
|
title: "Movie",
|
||||||
|
type: "Movie",
|
||||||
|
state: "playing",
|
||||||
|
transcoding: "no",
|
||||||
|
transcoding_type: "",
|
||||||
|
device: "Web",
|
||||||
|
session_id: "s1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
user: "bob",
|
||||||
|
title: "Show",
|
||||||
|
type: "Episode",
|
||||||
|
state: "paused",
|
||||||
|
transcoding: "no",
|
||||||
|
transcoding_type: "",
|
||||||
|
device: "TV",
|
||||||
|
session_id: "s2",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
render(<UsersPage />);
|
||||||
|
|
||||||
|
// Design §2.3: healthy/active (Playing) = success (chart-2); Paused = warning.
|
||||||
|
expect(screen.getByText("Playing").getAttribute("data-variant")).toBe(
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Paused").getAttribute("data-variant")).toBe(
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the user detail drawer (Sheet) when a user is selected", () => {
|
||||||
|
users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
|
||||||
|
currentParams = { user: "u1" };
|
||||||
|
render(<UsersPage />);
|
||||||
|
|
||||||
|
// buildUserDrawerModel title = display name; rendered as the drawer heading.
|
||||||
|
expect(screen.getByRole("heading", { name: "Alice" })).toBeInTheDocument();
|
||||||
|
// Drawer sections (identity / contact actions) + the activity panel render.
|
||||||
|
expect(screen.getByText("Identity")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Contact actions")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("session-panel-stub")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -764,3 +764,246 @@ gap only — `design.md` §1 supplied the authoritative MUI→shadcn mapping and
|
|||||||
The parent explicitly delegated Slice 5 with a clear scope/delivery path, so this
|
The parent explicitly delegated Slice 5 with a clear scope/delivery path, so this
|
||||||
slice proceeded under that delegation. Should be resolved before
|
slice proceeded under that delegation. Should be resolved before
|
||||||
`sdd-verify`/archive, per the slice-3/slice-4 notes.
|
`sdd-verify`/archive, per the slice-3/slice-4 notes.
|
||||||
|
|
||||||
|
## Slice 6a — Users directory surface + drawer (DONE)
|
||||||
|
|
||||||
|
Force-split 6a/6b was invoked for slice 6 (`UsersPage.impl.tsx` is the largest
|
||||||
|
consumer — 25 MUI components + 9 `@mui/icons-material` + `Drawer` + `Table` +
|
||||||
|
rich-text compose; confirmed over the 400-line budget). This run delivered
|
||||||
|
**6a ONLY**: the directory surface (table, selection, status cues, drawer) is
|
||||||
|
migrated to shadcn/Tailwind; the **compose `<Dialog>` block + its 9 icons are
|
||||||
|
left verbatim for 6b** so the file still compiles. All 4 Slice-6a task lines in
|
||||||
|
`tasks.md` are now `- [x]`. Cumulative change task progress: 38 → **42/71**.
|
||||||
|
|
||||||
|
### Status context consumed
|
||||||
|
|
||||||
|
- `applyState` reported by the status engine: **blocked** (`blockedReasons`:
|
||||||
|
domain specs missing/partial; legacy flat `spec.md` present without domain
|
||||||
|
specs). Same planning-completeness gap as slices 1–5 — **not** a safety or
|
||||||
|
`actionContext` blocker.
|
||||||
|
- `actionContext`: `mode: repo-local`, `workspaceRoot: /home/user/Manage_01`,
|
||||||
|
`allowedEditRoots: ["/home/user/Manage_01"]`, `warnings: []` — safe.
|
||||||
|
- This run executed the explicitly delegated **Slice 6a** scope per the parent
|
||||||
|
acceptance contract, which also supplied the resolved delivery path
|
||||||
|
(force-split, 6a-only). `design.md` §1 (mapping table), §2.3 (status→Badge
|
||||||
|
variant map), §5 (icon map), and §8 (6a/6b split guidance) are authoritative
|
||||||
|
and do not depend on the missing domain specs.
|
||||||
|
- `artifactStore: openspec`; persisted task checkboxes updated in `tasks.md`
|
||||||
|
(Slice 6a: 0 → 4 `[x]`).
|
||||||
|
|
||||||
|
### Completed tasks (persisted checkboxes updated)
|
||||||
|
|
||||||
|
- [x] **Directory surface migrated** — `UsersPage.impl.tsx` outer `Stack` →
|
||||||
|
`flex flex-col gap-6`; page header `Typography` → `<h1>`/`<p>`; the 6
|
||||||
|
directory
|
||||||
|
`Alert` banners (load-error / Jellyseerr-not-configured / Jellyseerr-error /
|
||||||
|
no-enrichment / queue-load-error / queue-status) → shadcn `Alert` +
|
||||||
|
`AlertDescription` (error→`destructive`, others→`default`, matching the
|
||||||
|
slice-5 Alert-variant flattening); metrics `Box` grid → responsive CSS grid
|
||||||
|
(`grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4`); "User list" `Paper` →
|
||||||
|
bordered `rounded-lg border bg-card p-4` surface; toolbar `Chip`s → `Badge`
|
||||||
|
(`outline` for selected count; `success` when deliverable>0) and the
|
||||||
|
"Message selected" / "Clear selection" `Button`s → shadcn `Button`
|
||||||
|
(`default` / `ghost`). The user table → shadcn `Table` family
|
||||||
|
(`TableHeader`/`TableBody`/`TableRow`/`TableHead`/`TableCell`) inside a
|
||||||
|
`max-h-[660px] overflow-auto rounded-lg border` scroll surface with a
|
||||||
|
**sticky opaque header** (`sticky top-0 z-10 bg-card`); `Checkbox` → shadcn
|
||||||
|
`Checkbox`; row `Chip`s → `Badge` with status variants (see mapping below);
|
||||||
|
`Avatar` → shadcn `Avatar`/`AvatarImage`/`AvatarFallback`.
|
||||||
|
- [x] **`Drawer` → `Sheet side="right"`** for the user-detail drawer; `open`/
|
||||||
|
`onOpenChange` wired (`onOpenChange(false)` → `setSearchParams({})`);
|
||||||
|
`showCloseButton={false}` + the explicit `Close` button preserved (faithful
|
||||||
|
to the original drawer, which had no built-in X). `buildUserDrawerModel`
|
||||||
|
rendering preserved verbatim (identity / activity / contact-actions /
|
||||||
|
permissions sections → bordered `bg-card` panels).
|
||||||
|
- [x] **Selection-across-pagination + search/filter preserved** —
|
||||||
|
`selectedUserIds`, `selectedIdSet`, `toggleUserSelected`,
|
||||||
|
`toggleVisibleSelection`, `mergeUsersWithActivity`, the row-level text
|
||||||
|
matching across all 15 fields, and `resolveUserSelection` are **byte-for-byte
|
||||||
|
unchanged**. The selected-id set still survives paging/filtering (a row
|
||||||
|
checkbox toggles membership in the set; the header checkbox toggles all
|
||||||
|
*visible* rows' membership). The search box stays an MUI `TextField`
|
||||||
|
(6b-owned component) — its `onChange`/`search` state is unchanged.
|
||||||
|
- [x] **Status → Badge variant mapping** (design §2.3): `Playing` → `success`
|
||||||
|
(chart-2, healthy/active), `Paused` → `warning` (chart-3), other activity →
|
||||||
|
`secondary` (chart-5 neutral); Jellyseerr `Linked` → `success`, `Base only` →
|
||||||
|
`secondary`; `Contactable Yes` → `success`, `No` → `secondary`. Toolbar
|
||||||
|
"deliverable" count → `success` when >0.
|
||||||
|
|
||||||
|
### Compose dialog — STRUCTURE left for 6b; shared leaf components migrated in 6a
|
||||||
|
|
||||||
|
**Finalization repair:** the first 6a pass left the entire compose `<Dialog>`
|
||||||
|
block intact. But the acceptance contract's `mui-free-6a` criterion is strict:
|
||||||
|
the file "may still use `@mui` ONLY for the compose-dialog subset
|
||||||
|
(Dialog/DialogActions/DialogContent/DialogTitle + TextField + Divider +
|
||||||
|
IconButton + the 9 icons)". That narrow set is the same list the parent's
|
||||||
|
dispatch named as 6b-owned, so it is the authoritative 6b boundary. The intact
|
||||||
|
compose block additionally used `Alert/Box/Button/Chip/LinearProgress/Paper/
|
||||||
|
Stack/Tooltip/Typography/useMediaQuery` — none of which are in the allowed
|
||||||
|
ceiling. To satisfy the contract, the compose **content's shared leaf
|
||||||
|
components** were migrated to shadcn/Tailwind in 6a, leaving **only the named
|
||||||
|
6b structure** on `@mui`.
|
||||||
|
|
||||||
|
**What now remains `@mui/material` (exactly the contract's narrow set, all used):**
|
||||||
|
`Dialog` (×1), `DialogActions` (×1), `DialogContent` (×1), `DialogTitle` (×1),
|
||||||
|
`Divider` (×1, drawer divider), `IconButton` (×5: close + 4 formatting),
|
||||||
|
`TextField` (×3: search box + subject + html body). Plus the 9
|
||||||
|
`@mui/icons-material` icons. Verified: `grep -nE '^ +(Alert|Box|Button|Chip|
|
||||||
|
LinearProgress|Paper|Stack|Tooltip|Typography|Avatar|Checkbox|Drawer|Table…).*@mui'`
|
||||||
|
→ NONE.
|
||||||
|
|
||||||
|
**What was migrated inside the compose content (shared leaf components):**
|
||||||
|
`LinearProgress`→shadcn `Progress` (indeterminate via `animate-pulse`);
|
||||||
|
`Stack`→`flex flex-col gap-4`; `Alert`→shadcn `UIAlert`/`AlertDescription`
|
||||||
|
(error→`destructive`, others→`default`); `Box`→`<div>` flex; `Typography`→
|
||||||
|
semantic `<span>`/`<p>`; `Chip`→`Badge` (recipient/queue/attachment chips);
|
||||||
|
`Tooltip`→shadcn `Tooltip`/`TooltipTrigger asChild`/`TooltipContent` (wrapping
|
||||||
|
the 6b-owned MUI `IconButton`; the app's global `TooltipProvider` in `App.tsx`
|
||||||
|
covers it); `Paper`→bordered `rounded-lg border bg-muted/40 p-4`; `Button`→
|
||||||
|
shadcn `UiButton` (Cancel→`ghost`, Send→`default`, Add-attachment→`outline
|
||||||
|
asChild` wrapping a `<label>` so the hidden `<input type=file>` still fires);
|
||||||
|
MUI `useMediaQuery`→a dependency-free local `useIsMobile()` `matchMedia` hook
|
||||||
|
(for the Dialog `fullScreen` mobile behavior).
|
||||||
|
|
||||||
|
**6b's remaining, intact, untouched work** (the named 6b structure):
|
||||||
|
`Dialog`/`DialogTitle`/`DialogContent`/`DialogActions`→shadcn `Dialog`;
|
||||||
|
`TextField`→`Input`/`Textarea` (+ `inputRef`→`ref` for the `insertMarkup`
|
||||||
|
cursor logic); `Divider`→`Separator`; the 5 `IconButton`s→`Button
|
||||||
|
variant="ghost" size="icon"`; the 9 icons→lucide. The compose behavior
|
||||||
|
(subject + html body, 4 markup actions w/ cursor restore, `FormData`
|
||||||
|
attachments + per-chip remove, queue polling display, `useSendUserMessage`
|
||||||
|
send + inline success/error) is **preserved verbatim** — only the leaf
|
||||||
|
component shells swapped; the 6b-owned `TextField`/`IconButton`/`Dialog`*
|
||||||
|
and all state/handlers are unchanged.
|
||||||
|
|
||||||
|
### Files changed (this slice)
|
||||||
|
|
||||||
|
- `frontend/src/pages/UsersPage.impl.tsx` — directory surface + drawer
|
||||||
|
rewritten to shadcn/Tailwind; compose **content** shared leaf components
|
||||||
|
migrated to shadcn/Tailwind; only the named 6b structure (`Dialog*`/
|
||||||
|
`TextField`/`Divider`/`IconButton` + 9 icons) remains `@mui`.
|
||||||
|
- `frontend/src/pages/__tests__/UsersPage.test.tsx` — new (6 behavioral tests).
|
||||||
|
- `openspec/changes/web-ui-rework/tasks.md` — 4 Slice-6a checkboxes `- [ ]` → `- [x]`.
|
||||||
|
|
||||||
|
No other file was edited (scope-clean: `git status --porcelain frontend/src |
|
||||||
|
grep -vE 'pages/UsersPage.impl|__tests__'` → `scope-clean`). No `components/*`,
|
||||||
|
no `components/ui/*` primitive, no other page, no `package.json` touched.
|
||||||
|
|
||||||
|
### Tests added (6, co-located under `src/pages/__tests__/`)
|
||||||
|
|
||||||
|
`UsersPage.test.tsx` mocks `useUsers`/`useActivity`/`useUserMessageQueueStatus`/
|
||||||
|
`useSendUserMessage`/`react-router-dom` (`useSearchParams`) + stubs
|
||||||
|
`SessionActivityPanel` + polyfills `window.matchMedia` (the local `useIsMobile`
|
||||||
|
`matchMedia` hook). Covers: (1) directory table + metric counts render; (2)
|
||||||
|
**selection toggle persists** (click row checkbox → "1 selected", again →
|
||||||
|
"0 selected"); (3) **header select-all** selects all visible rows ("2
|
||||||
|
selected"); (4) **drawer opens on row click** (`setSearchParams` called with
|
||||||
|
`{ user: <id> }`); (5) **status → Badge variant mapping** (Playing→
|
||||||
|
`data-variant="success"`, Paused→`"warning"`); (6) **drawer (Sheet) renders
|
||||||
|
`buildUserDrawerModel`** content (heading + Identity/Contact-actions sections +
|
||||||
|
the activity panel stub) when `?user=<id>`. (The compose dialog is not rendered
|
||||||
|
in tests because `composeOpen` is false and `openCompose` no-ops with no
|
||||||
|
selection — so the compose migration is covered by build/lint, not behavior
|
||||||
|
assertions.)
|
||||||
|
|
||||||
|
### Gate results (run from `frontend/`)
|
||||||
|
|
||||||
|
| Command | Result | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `npm run build` (`tsc -b && vite build`) | **pass (exit 0)** | 3314 modules; pre-existing >500 kB chunk warning only. |
|
||||||
|
| `npm run lint` (`eslint .`) | **pass (exit 0)** | 0 errors. 2 pre-existing `react-hooks/exhaustive-deps` **warnings** in `UsersPage.impl.tsx` (the `baseRows` + `metrics` memos) — present since before slice 1, out of 6a scope. |
|
||||||
|
| `npm test` (`vitest run`) | **pass (exit 0)** | **20 files / 45 tests** (+1 file, +6 tests vs slice-5 baseline of 19/39). |
|
||||||
|
| `npm run test:node` (`node --test tests/*.test.mjs`) | **pass (exit 0)** | 4/4 legacy node suites (untouched). |
|
||||||
|
| Runtime checks (parent) | **pass** | `sheet-usage` → `Sheet`/`SheetContent side="right"` present; `drawer-gone` → 2 substring hits, both non-component (the `buildUserDrawerModel` import + the `drawerModel` variable; the MUI `Drawer` component is gone); `scope` → `scope-clean`; `@mui/material` imports = exactly the narrow set `{Dialog,DialogActions,DialogContent,DialogTitle,Divider,IconButton,TextField}` + 9 icons (all used). |
|
||||||
|
|
||||||
|
> Note on `node --test tests`: the literal bare-directory form mis-resolves
|
||||||
|
> `tests` as a module entry on Node v22 (every slice). The working command is
|
||||||
|
> the npm script `test:node` = `node --test tests/*.test.mjs` (matches the
|
||||||
|
> slice-1 harness), which is green. `npm test` covers the component suite.
|
||||||
|
|
||||||
|
### Deviations from design / notes
|
||||||
|
|
||||||
|
1. **`Tooltip` + `LinearProgress` migrated to shadcn in the compose content**
|
||||||
|
(finalization repair). The task's directory-surface line lists them, but in
|
||||||
|
the source both were used **only inside the compose dialog** (formatting
|
||||||
|
toolbar + `sendUserMessage.isPending`). They were migrated to shadcn
|
||||||
|
`Tooltip` / `Progress` during the 6a finalization repair (above) to satisfy
|
||||||
|
the `mui-free-6a` narrow-import ceiling. `Progress` renders an
|
||||||
|
indeterminate cue via `value={100} className="animate-pulse"` (full-width
|
||||||
|
pulsing bar).
|
||||||
|
2. **`Alert` variant flattening.** shadcn `Alert` exposes only `default` +
|
||||||
|
`destructive`; MUI `severity="error"` → `destructive`, info/warning/success
|
||||||
|
→ `default`. All **message text is preserved verbatim**; only the
|
||||||
|
severity→color cue is flattened (consistent with the slice-5 deviation).
|
||||||
|
3. **Header select-all checkbox has no indeterminate dash.** The shadcn
|
||||||
|
`Checkbox` indicator hardcodes a check icon (no native dash); rather than
|
||||||
|
hack the shared primitive or show a misleading check for partial selection,
|
||||||
|
the header checkbox reflects `allVisibleSelected` only. This is a minor
|
||||||
|
visual change — the **selection-set semantics are fully preserved** (which
|
||||||
|
is what the task requires); the toggle-all-visible behavior is unchanged and
|
||||||
|
is covered by a test.
|
||||||
|
4. **6b-owned icons stay MUI inside shadcn shells.** `CloseIcon` (drawer close
|
||||||
|
- compose dialog close), `MailOutlinedIcon` ("Message selected"),
|
||||||
|
`SendIcon`/`AttachFileIcon`/`DeleteOutlinedIcon` (compose), and the 4
|
||||||
|
formatting icons stay as 6b-owned `@mui/icons-material` imports, rendered
|
||||||
|
inside shadcn `Button`/`IconButton`/`Badge` shells as appropriate. 6b swaps
|
||||||
|
them for lucide.
|
||||||
|
5. **Search `TextField` + drawer `Divider` + compose `Dialog*`/`TextField`/
|
||||||
|
`IconButton` stay MUI** — exactly the contract's narrow 6b set. `TextField`
|
||||||
|
is kept so the `htmlBody` `inputRef` cursor logic (`insertMarkup`) is
|
||||||
|
byte-for-byte unchanged; 6b will move it to `Textarea` + `ref`.
|
||||||
|
6. **`useMediaQuery` (MUI) → local `useIsMobile()`** matchMedia hook (the
|
||||||
|
Dialog `fullScreen` mobile prop); removes the last non-listed `@mui/material`
|
||||||
|
import.
|
||||||
|
|
||||||
|
### Parity preserved (behavior)
|
||||||
|
|
||||||
|
- Selection-across-pagination: the `selectedUserIds` set survives filtering /
|
||||||
|
paging; row + header toggle logic unchanged; `Clear selection` resets it.
|
||||||
|
- Search/filter: `mergeUsersWithActivity` + the 15-field row-level text match +
|
||||||
|
`resolveUserSelection` unchanged.
|
||||||
|
- Drawer open/close (`?user=<id>` ↔ `setSearchParams({})`) + `buildUserDrawerModel`
|
||||||
|
identity/contact/permissions rendering unchanged.
|
||||||
|
- All hooks (`useUsers`/`useActivity`/`useUserMessageQueueStatus`/
|
||||||
|
`useSendUserMessage`/`useMediaQuery`) and the compose machinery (`openCompose`,
|
||||||
|
`closeCompose`, `insertMarkup`, `addLink`, `handleAttachments`,
|
||||||
|
`removeAttachment`, `handleSend`) are **unchanged** — the compose block is
|
||||||
|
untouched.
|
||||||
|
|
||||||
|
### Workload / PR boundary
|
||||||
|
|
||||||
|
Single sub-slice (the 6a half of the force-split): the impl diff is **495
|
||||||
|
insertions / 638 deletions (net −143)** in `UsersPage.impl.tsx` — the directory
|
||||||
|
surface + drawer + the compose-content shared leaf components were all migrated
|
||||||
|
this slice (the compose-content migration was required to satisfy the
|
||||||
|
`mui-free-6a` narrow-import ceiling; see the finalization repair above). Plus
|
||||||
|
1 new test file (~190 lines). This is comfortably the larger half of slice 6;
|
||||||
|
the parent owns the commit/PR shape and 6b is the matching second sub-PR
|
||||||
|
(smaller now — only the named 6b structure remains). Nothing committed here.
|
||||||
|
|
||||||
|
### Top risk for 6b
|
||||||
|
|
||||||
|
**The compose-dialog STRUCTURE** is the remaining MUI surface in this file,
|
||||||
|
now narrowed to exactly the contract set: `Dialog`/`DialogTitle`/
|
||||||
|
`DialogContent`/`DialogActions` → shadcn `Dialog`; `TextField` → `Input`/
|
||||||
|
`Textarea` (+ `inputRef`→`ref` for the `insertMarkup` cursor logic, in both the
|
||||||
|
subject field and the `htmlBody`); `Divider` → `Separator`; the 5 `IconButton`s
|
||||||
|
→ `Button variant="ghost" size="icon"`; the 9 `@mui/icons-material` → lucide
|
||||||
|
(pin verified in slice 1). The shared leaf components (`Alert`/`Box`/`Button`/
|
||||||
|
`Chip`/`Paper`/`Stack`/`Typography`/`Tooltip`/`LinearProgress`) were **already
|
||||||
|
migrated in 6a**, so 6b's remaining diff is the Dialog structure + inputs +
|
||||||
|
icons only. Behavior to preserve (already intact from 6a): subject + html body,
|
||||||
|
the 4 markup-insertion actions w/ cursor restore, `FormData` attachments +
|
||||||
|
per-chip remove, queue polling, `useSendUserMessage` send + inline alerts.
|
||||||
|
After 6b, the file's `@mui/material` + `@mui/icons-material` imports collapse
|
||||||
|
to nothing (slice 8 removes the deps entirely).
|
||||||
|
|
||||||
|
### Structured status note
|
||||||
|
|
||||||
|
Overall change `applyState` remains **blocked** per the status engine (domain
|
||||||
|
specs missing/partial; legacy flat `spec.md`). This is a planning-completeness
|
||||||
|
gap only — `design.md` supplied the authoritative mapping and
|
||||||
|
`actionContext` is `repo-local` with `allowedEditRoots` covering the
|
||||||
|
workspace. The parent explicitly delegated Slice 6a with a clear, force-split
|
||||||
|
delivery path, so this sub-slice proceeded under that delegation. Should be
|
||||||
|
resolved before `sdd-verify`/archive, per the prior slices' notes.
|
||||||
|
|||||||
@@ -192,10 +192,10 @@ Each slice section restates this gate as its final task.
|
|||||||
|
|
||||||
### Slice 6a — directory table, selection, drawer
|
### Slice 6a — directory table, selection, drawer
|
||||||
|
|
||||||
- [ ] Migrate `frontend/src/pages/UsersPage.impl.tsx` directory surface: user table (`Table`/`TableBody`/`TableCell`/`TableContainer`/`TableHead`/`TableRow` + `Checkbox`/`Chip`/`Avatar`/`Tooltip` + `LinearProgress`) → shadcn `Table` family + `Checkbox` + `Badge` (status cues) + `Avatar` + `Tooltip` + `Progress`.
|
- [x] Migrate `frontend/src/pages/UsersPage.impl.tsx` directory surface: user table (`Table`/`TableBody`/`TableCell`/`TableContainer`/`TableHead`/`TableRow` + `Checkbox`/`Chip`/`Avatar`/`Tooltip` + `LinearProgress`) → shadcn `Table` family + `Checkbox` + `Badge` (status cues) + `Avatar` + `Tooltip` + `Progress`.
|
||||||
- [ ] Replace MUI `Drawer` with shadcn `Sheet side="right"` for the user detail drawer; preserve `buildUserDrawerModel` rendering.
|
- [x] Replace MUI `Drawer` with shadcn `Sheet side="right"` for the user detail drawer; preserve `buildUserDrawerModel` rendering.
|
||||||
- [ ] Preserve selection-across-pagination semantics (selected-user-id set survives paging/filtering) and the search/filter logic (`mergeUsersWithActivity`, row-level text matching).
|
- [x] Preserve selection-across-pagination semantics (selected-user-id set survives paging/filtering) and the search/filter logic (`mergeUsersWithActivity`, row-level text matching).
|
||||||
- [ ] Wire status → Badge variant mapping (healthy/activity = `success` cue) consistently with design §2.3.
|
- [x] Wire status → Badge variant mapping (healthy/activity = `success` cue) consistently with design §2.3.
|
||||||
|
|
||||||
### Slice 6b — compose dialog, formatting actions, attachments
|
### Slice 6b — compose dialog, formatting actions, attachments
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user