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
|
||||
slice proceeded under that delegation. Should be resolved before
|
||||
`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
|
||||
|
||||
- [ ] 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.
|
||||
- [ ] 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] 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] Replace MUI `Drawer` with shadcn `Sheet side="right"` for the user detail drawer; preserve `buildUserDrawerModel` rendering.
|
||||
- [x] Preserve selection-across-pagination semantics (selected-user-id set survives paging/filtering) and the search/filter logic (`mergeUsersWithActivity`, row-level text matching).
|
||||
- [x] Wire status → Badge variant mapping (healthy/activity = `success` cue) consistently with design §2.3.
|
||||
|
||||
### Slice 6b — compose dialog, formatting actions, attachments
|
||||
|
||||
|
||||
Reference in New Issue
Block a user