Mobile FileBrowser: stacked cards for file list (Slice 4)
Below md, the file table renders as MobileCardRow cards: name as primary, plus type/size/modified. Whole-card tap triggers handleRowClick (dir rows navigate into the directory; file rows select for ffprobe preview). No pagination needed (FileBrowser does not paginate). The ext column is omitted from the card -- the extension is already visible in the filename itself, so it's redundant on mobile and would waste card space. Path bar / breadcrumbs / Open / Refresh live outside the table and already stack on mobile via existing md:flex-row. ffprobe and Jobs sections are unaffected. Desktop (md+) is byte-for-byte identical: the isMobile===false branch renders the same DataTable with the same props. Tests: 4 new covering mobile card render + dir-tap navigation + path controls present + desktop DataTable. matchMedia mocked per-breakpoint. 98 tests pass; lint/build green. Refs openspec/changes/mobile-responsive-parity/ (spec R3, tasks slice 4).
This commit is contained in:
@@ -3,6 +3,10 @@ import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import type { ColumnDef, RowSelectionState } from "@tanstack/react-table";
|
||||
|
||||
import { DataTable } from "@/components/ui/data-table";
|
||||
import {
|
||||
MobileCardRow,
|
||||
type MobileCardField,
|
||||
} from "@/components/ui/mobile-card";
|
||||
import { Alert, AlertAction, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -24,6 +28,7 @@ import {
|
||||
useRunJob,
|
||||
} from "../hooks/useFiles";
|
||||
import { usePersistentState } from "../hooks/usePersistentState";
|
||||
import { useIsMobile } from "../hooks/useIsMobile";
|
||||
import { useMonitoringSettings } from "../hooks/useSettings";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { TabbedCard } from "../components/TabbedCard";
|
||||
@@ -182,6 +187,18 @@ const fileColumns: ColumnDef<DisplayRow>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// Mobile card fields (spec R3.2): the card picks the 3-5 most important fields.
|
||||
// Name is the primary identifier; type distinguishes dir/file/up at a glance;
|
||||
// size and modified give the at-a-glance info a user browsing files on a phone
|
||||
// needs. Ext is redundant with the name on mobile (the extension is visible in
|
||||
// the filename itself). See OpenSpec change `mobile-responsive-parity`.
|
||||
const fileCardFields: MobileCardField<DisplayRow>[] = [
|
||||
{ key: "name", label: "Name", render: (r) => r.name, primary: true },
|
||||
{ key: "type", label: "Type", render: (r) => r.type },
|
||||
{ key: "size", label: "Size", render: (r) => r.size || "-" },
|
||||
{ key: "modified", label: "Modified", render: (r) => r.modified || "-" },
|
||||
];
|
||||
|
||||
const FILE_BROWSER_STATE_KEY = "manage.files.browserState";
|
||||
|
||||
type FileBrowserState = {
|
||||
@@ -501,6 +518,7 @@ function InfoAlert({ children }: { children: React.ReactNode }) {
|
||||
|
||||
export function FileBrowser() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const isMobile = useIsMobile();
|
||||
const [columnVisibility, setColumnVisibility] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
@@ -725,23 +743,34 @@ export function FileBrowser() {
|
||||
</Alert>
|
||||
)}
|
||||
<div className="rounded-lg border bg-card">
|
||||
<DataTable
|
||||
columns={fileColumns}
|
||||
data={rows}
|
||||
getRowId={(row) => row.id}
|
||||
enableRowSelection
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={handleSelectionChange}
|
||||
onRowClick={handleRowClick}
|
||||
enableColumnVisibilityToggle
|
||||
columnVisibility={columnVisibility}
|
||||
onColumnVisibilityChange={setColumnVisibility}
|
||||
emptyMessage={
|
||||
isLoading
|
||||
? "Loading directory..."
|
||||
: "This directory is empty."
|
||||
}
|
||||
/>
|
||||
{isMobile ? (
|
||||
<div className="p-4">
|
||||
<MobileCardRow
|
||||
rows={rows}
|
||||
fields={fileCardFields}
|
||||
getRowId={(row) => row.id}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={fileColumns}
|
||||
data={rows}
|
||||
getRowId={(row) => row.id}
|
||||
enableRowSelection
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={handleSelectionChange}
|
||||
onRowClick={handleRowClick}
|
||||
enableColumnVisibilityToggle
|
||||
columnVisibility={columnVisibility}
|
||||
onColumnVisibilityChange={setColumnVisibility}
|
||||
emptyMessage={
|
||||
isLoading
|
||||
? "Loading directory..."
|
||||
: "This directory is empty."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { DirectoryListing, MonitoringMachine } from "../../types";
|
||||
// so the selectedPath / currentDir state never leaks across cases.
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
setMatchMedia(false);
|
||||
});
|
||||
|
||||
function machineFixture(
|
||||
@@ -77,6 +78,30 @@ beforeEach(() => {
|
||||
]);
|
||||
});
|
||||
|
||||
/** Stub window.matchMedia so useIsMobile resolves in jsdom (Slice 4). */
|
||||
function setMatchMedia(matches: boolean) {
|
||||
const listeners: ((e: MediaQueryListEvent) => void)[] = [];
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query.includes("768") ? matches : false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: (
|
||||
_evt: string,
|
||||
listener: (e: MediaQueryListEvent) => void,
|
||||
) => listeners.push(listener),
|
||||
removeEventListener: (
|
||||
_evt: string,
|
||||
listener: (e: MediaQueryListEvent) => void,
|
||||
) => {
|
||||
const idx = listeners.indexOf(listener);
|
||||
if (idx >= 0) listeners.splice(idx, 1);
|
||||
},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("FileBrowser (slice 7a — TanStack DataTable parity)", () => {
|
||||
it("renders the 5 locked columns (type/name/ext/size/modified)", () => {
|
||||
render(<FileBrowser />);
|
||||
@@ -118,3 +143,56 @@ describe("FileBrowser (slice 7a — TanStack DataTable parity)", () => {
|
||||
expect(screen.queryByText(/Selected:/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileBrowser (mobile card layout — slice 4)", () => {
|
||||
it("renders cards with file/dir name as primary below md", () => {
|
||||
setMatchMedia(true);
|
||||
render(<FileBrowser />);
|
||||
|
||||
// Card titles (the 'name' field rendered as primary).
|
||||
expect(screen.getByText("movies")).toBeInTheDocument();
|
||||
expect(screen.getByText("video.mkv")).toBeInTheDocument();
|
||||
expect(screen.getByText("notes.txt")).toBeInTheDocument();
|
||||
|
||||
// Desktop table column headers must NOT render.
|
||||
const headers = screen.queryAllByRole("columnheader");
|
||||
expect(headers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("tapping a directory card navigates into it", async () => {
|
||||
setMatchMedia(true);
|
||||
render(<FileBrowser />);
|
||||
|
||||
// Directory card is a button wrapping the 'movies' text.
|
||||
await userEvent.click(screen.getByText("movies"));
|
||||
|
||||
// After navigating into /movies, the status caption shows the new cwd
|
||||
// and NO 'Selected:' segment (directories are opened, not selected).
|
||||
expect(screen.getByText(/Current: \/movies\b/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Selected:/)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the path/breadcrumb controls on mobile", () => {
|
||||
setMatchMedia(true);
|
||||
render(<FileBrowser />);
|
||||
|
||||
// The 'Remote path' label and its input are part of the Browser section
|
||||
// card (outside the table), so they render on both breakpoints.
|
||||
expect(screen.getByLabelText("Remote path")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the DataTable at desktop width (1280px)", () => {
|
||||
setMatchMedia(false);
|
||||
render(<FileBrowser />);
|
||||
|
||||
// Desktop path: table column headers are present.
|
||||
const headers = screen
|
||||
.getAllByRole("columnheader")
|
||||
.map((h) => h.textContent);
|
||||
expect(headers).toEqual(
|
||||
expect.arrayContaining(["Type", "Name", "Ext", "Size", "Modified"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user