diff --git a/frontend/src/components/ChannelPicker.tsx b/frontend/src/components/ChannelPicker.tsx index bebc16d2..4ee631fb 100644 --- a/frontend/src/components/ChannelPicker.tsx +++ b/frontend/src/components/ChannelPicker.tsx @@ -40,6 +40,7 @@ export default function ChannelPicker({ selected={filters.channelIds} onChange={(channelIds) => setFilters({ ...filters, channelIds })} loading={channelsQuery.isLoading} + error={channelsQuery.isError && !channelsQuery.data ? t("common.loadError") : undefined} emptyText={t("sidebar.noChannelMatch")} selectedLabels={selectedLabels} /> diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index bb240d3c..bc5290a7 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -329,6 +329,7 @@ export default function Channels() { onView, onOpenWizard, tags: userTags, + tagsState: tagsQuery.isPending ? "loading" : tagsQuery.isError ? "error" : "ready", onToggleTag: (c, tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId)), onPickPriority: pickPriority, }); diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index 2000f08b..d10fafce 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -371,6 +371,8 @@ function PlaylistPicker() { query={query} onQueryChange={setQuery} placeholder={t("plex.playlist.pickerPlaceholder")} + loading={playlistsQ.isLoading} + error={playlistsQ.isError && !playlistsQ.data ? t("common.loadError") : undefined} emptyText={t("plex.playlist.none")} renderOption={(o) => o.value === CREATE_PLAYLIST ? ( @@ -454,6 +456,7 @@ function CollectionPicker({ onQueryChange={setSearch} onOpenChange={setOpen} loading={q.isFetching} + error={q.isError ? t("common.loadError") : undefined} placeholder={t("plex.filter.collectionSearch")} emptyText={t("plex.filter.collectionEmpty")} onChange={(next) => { diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index c27a099c..1f7e1be9 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -46,6 +46,8 @@ function UserShare({ job }: { job: DownloadJob }) { { if (!share.isPending) share.mutate(u.email); }} diff --git a/frontend/src/components/UserPicker.tsx b/frontend/src/components/UserPicker.tsx index 0c258ac2..afd73e57 100644 --- a/frontend/src/components/UserPicker.tsx +++ b/frontend/src/components/UserPicker.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import Combobox from "./ui/Combobox"; // Pick one registered user by name or email: the download quota's "any user" picker and the share @@ -17,13 +18,20 @@ export default function UserPicker({ label, placeholder, loading, + error, + disabled, }: { users: readonly U[]; onPick: (u: U) => void; label: string; placeholder: string; loading?: boolean; + /** The user list failed to load: the picker says so instead of "No matches". */ + error?: boolean; + /** The last pick is still being acted on: no second one meanwhile. */ + disabled?: boolean; }) { + const { t } = useTranslation(); const byId = new Map(users.map((u) => [String(u.id), u])); return ( ({ placeholder={placeholder} multiple={false} loading={loading} + error={error ? t("common.loadError") : undefined} + disabled={disabled} selected={[]} // The label carries both, so the client filter matches a name OR an email. options={users.map((u) => ({ diff --git a/frontend/src/components/downloadAdmin.tsx b/frontend/src/components/downloadAdmin.tsx index 571a0d16..44d6ef92 100644 --- a/frontend/src/components/downloadAdmin.tsx +++ b/frontend/src/components/downloadAdmin.tsx @@ -149,6 +149,7 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string } onPick({ id: u.id, email: u.email })} label={t("downloads.admin.quotaPickPlaceholder")} placeholder={t("downloads.admin.quotaPickPlaceholder")} diff --git a/frontend/src/components/ui/Combobox.dom.test.tsx b/frontend/src/components/ui/Combobox.dom.test.tsx index 9daac7fd..b0b251aa 100644 --- a/frontend/src/components/ui/Combobox.dom.test.tsx +++ b/frontend/src/components/ui/Combobox.dom.test.tsx @@ -276,6 +276,33 @@ describe("Combobox", () => { // PageUp/PageDown (E11, owner request): the sidebar's channel filter and the Plex pickers run to // hundreds of rows, and floating-ui's list navigation has only the arrows. jsdom has no layout, so a // page is the fixed fallback of 10 rows; the measured page is pinned in lib/listPaging. +// E28 review J1 + J2: a failed list said "No matches" (an error dressed as an empty result), and a +// picker whose pick was still being written took another pick. +describe("Combobox · honest states", () => { + it("says the list failed to load, not that nothing matched", () => { + wrap( + , + ); + fireEvent.focus(screen.getByRole("combobox")); + expect(screen.getByText("Load failed")).toBeTruthy(); + expect(screen.queryByText("common.noResults")).toBeNull(); + }); + + it("disabled: keeps the focus, opens nothing and picks nothing", () => { + const onChange = vi.fn(); + wrap(); + const field = screen.getByRole("combobox"); + field.focus(); + fireEvent.focus(field); + expect(screen.queryByRole("option", { hidden: true })).toBeNull(); + fireEvent.keyDown(field, { key: "ArrowDown" }); + fireEvent.keyDown(field, { key: "Enter" }); + expect(onChange).not.toHaveBeenCalled(); + expect(field.getAttribute("aria-disabled")).toBe("true"); + expect(document.activeElement).toBe(field); // not natively disabled: the focus stays put + }); +}); + describe("Combobox · paging", () => { const rows = (disabled: number[] = []) => Array.from({ length: 25 }, (_, i) => ({ value: `Row ${i}`, disabled: disabled.includes(i) })); diff --git a/frontend/src/components/ui/Combobox.tsx b/frontend/src/components/ui/Combobox.tsx index 812c702f..aa87f9ee 100644 --- a/frontend/src/components/ui/Combobox.tsx +++ b/frontend/src/components/ui/Combobox.tsx @@ -52,6 +52,12 @@ export interface ComboboxProps { * the list server-side (then `options` arrives pre-filtered). */ clientFilter?: boolean; loading?: boolean; + /** The options failed to load: said in the list instead of "No matches" (an error is not an empty + * result — E28 review J1). */ + error?: string; + /** Takes no pick (e.g. the last one is still being written, J2). Soft, like `FocusSafeButton`: the + * field keeps the focus (a natively disabled one drops it to ), announced as unavailable. */ + disabled?: boolean; /** Shown when there are no options to list. */ emptyText?: string; /** Cap on rendered rows for a huge vocabulary; the rest hide behind the search. Default 50. */ @@ -75,6 +81,8 @@ export default function Combobox({ onQueryChange, clientFilter = true, loading = false, + error, + disabled = false, emptyText, maxVisible = 50, renderOption, @@ -102,6 +110,7 @@ export default function Combobox({ const labelFor = (v: string) => selectedLabels?.[v] ?? labelCache.current[v] ?? v; const setOpenAnd = (next: boolean) => { + if (next && disabled) return; setOpen(next); onOpenChange?.(next); // Closing discards the typed query: the list is for "find and pick", and a leftover filter would @@ -145,6 +154,7 @@ export default function Combobox({ const hiddenCount = filtered.length - visible.length; const commit = (value: string) => { + if (disabled) return; if (multiple) { onChange( selected.includes(value) ? selected.filter((x) => x !== value) : [...selected, value], @@ -183,13 +193,14 @@ export default function Combobox({ className={clsx( fieldClass({ size: "free" }), "flex min-h-8 w-full cursor-text flex-wrap items-center gap-1 px-1.5 py-0.5 text-sm", + disabled && "cursor-not-allowed opacity-50", )} > {selected.map((v) => ( onChange(selected.filter((x) => x !== v))} + onDismiss={() => !disabled && onChange(selected.filter((x) => x !== v))} dismissLabel={t("common.removeNamed", { name: labelFor(v) })} > {labelFor(v)} @@ -209,13 +220,20 @@ export default function Combobox({ if (e.key === "Enter" && open && idx != null && visible[idx]) { e.preventDefault(); // select the highlighted option, don't submit a surrounding form if (!visible[idx].disabled) commit(visible[idx].value); - } else if (e.key === "Backspace" && query === "" && selected.length > 0) { + } else if ( + e.key === "Backspace" && + !disabled && + query === "" && + selected.length > 0 + ) { // Backspace on an empty field removes the last chip (react-select behaviour). onChange(selected.slice(0, -1)); } }, })} value={query} + readOnly={disabled} + aria-disabled={disabled || undefined} aria-label={label} placeholder={ selected.length === 0 @@ -237,7 +255,11 @@ export default function Combobox({ > {/* The scroll box owns the height cap AND the scroll-fade mask (ref+style on the same element). */}
- {loading && filtered.length === 0 ? ( + {error && filtered.length === 0 ? ( +

+ {error} +

+ ) : loading && filtered.length === 0 ? (

{t("common.loading")}

) : filtered.length === 0 ? (

diff --git a/frontend/src/components/useChannelActions.dom.test.tsx b/frontend/src/components/useChannelActions.dom.test.tsx index cc076cc7..80b385eb 100644 --- a/frontend/src/components/useChannelActions.dom.test.tsx +++ b/frontend/src/components/useChannelActions.dom.test.tsx @@ -40,6 +40,7 @@ afterEach(() => { h.role = "user"; h.canWrite = true; tags = []; + tagsState = "ready"; onToggleTag.mockReset(); onPickPriority.mockReset(); }); @@ -70,6 +71,7 @@ const onView = vi.fn(); const onToggleTag = vi.fn(); const onPickPriority = vi.fn(); let tags: Tag[] = []; +let tagsState: "loading" | "error" | "ready" = "ready"; const tag = (id: number, name: string) => ({ id, name }) as Tag; function mount() { const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); @@ -77,7 +79,15 @@ function mount() { {children} ); return renderHook( - () => useChannelActions({ onView, onOpenWizard: () => {}, tags, onToggleTag, onPickPriority }), + () => + useChannelActions({ + onView, + onOpenWizard: () => {}, + tags, + tagsState, + onToggleTag, + onPickPriority, + }), { wrapper }, ); } @@ -220,6 +230,14 @@ describe("useChannelActions — the Tags submenu", () => { expect(onToggleTag).toHaveBeenCalledWith(expect.objectContaining({ id: "UC1" }), 2); }); + // E28 review J3: a failed /tags read said "no tags yet" — an error dressed as an empty list. + it("says the tags are loading, or failed to load, instead of 'no tags yet'", () => { + tagsState = "loading"; + expect(row(ch(), "tags")).toMatchObject({ disabled: true, note: "common.loading" }); + tagsState = "error"; + expect(row(ch(), "tags")).toMatchObject({ disabled: true, note: "common.loadError" }); + }); + it("stays in place, off, saying where tags are made, with no tag yet", () => { expect(row(ch(), "tags")).toMatchObject({ disabled: true, note: "channels.row.noTagsYet" }); expect(row(ch(), "tags").items).toBeUndefined(); diff --git a/frontend/src/components/useChannelActions.tsx b/frontend/src/components/useChannelActions.tsx index 2c465289..695c5349 100644 --- a/frontend/src/components/useChannelActions.tsx +++ b/frontend/src/components/useChannelActions.tsx @@ -23,6 +23,7 @@ export function useChannelActions({ onView, onOpenWizard, tags, + tagsState = "ready", onToggleTag, onPickPriority, }: { @@ -30,6 +31,9 @@ export function useChannelActions({ onOpenWizard: () => void; /** The reader's own tags (no system tags) — the Tags submenu lists them A–Z. */ tags: readonly Tag[]; + /** Whether `tags` is the answer yet: loading or failed is said as such, never as "no tags yet" + * (E28 review J3). */ + tagsState?: "loading" | "error" | "ready"; onToggleTag: (c: ManagedChannel, tagId: number) => void; /** A priority step picked: the caller re-sorts its list and follows the row. */ onPickPriority: (c: ManagedChannel, priority: number) => void; @@ -149,8 +153,15 @@ export function useChannelActions({ label: t("channels.tags.label"), icon: , }; - if (tags.length === 0) - return { ...base, disabled: true, note: t("channels.row.noTagsYet"), onSelect: () => {} }; + if (tags.length === 0) { + const note = + tagsState === "loading" + ? t("common.loading") + : tagsState === "error" + ? t("common.loadError") + : t("channels.row.noTagsYet"); + return { ...base, disabled: true, note, onSelect: () => {} }; + } const on = new Set(c.tag_ids); return { ...base,