From 606d50bb8d1f3dbbecaa6397bd8fdf16aae0dd89 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 9 Jul 2026 22:35:43 +0200 Subject: [PATCH] feat(spa): collection custom-order reorder UI + all-kind add picker (#211) Adds PUT /api/collections/{id}/custom-order support (updateCollectionCustomOrder) and a reorder mode in ManualItemsView: loads every page of a manual collection (so the wholesale-replace PUT never drops items), lets the user move items with up/down icon buttons, and saves/cancels. Reorder is offered for any manual collection with useCustomPlaybackOrder on, not just movies-only (server/enumerator already support any kind). Widens the add-items picker (ADDABLE_TYPE_LIST, MEDIA_KIND_FILTERS, toAddItemsRequest) from 4 to all 10 addable media kinds; the default "All" search fan-out stays Movie/Show/Artist (seasons excluded per #180), with the new kinds reachable via their specific filter, mirroring Blazor's per-kind list pages. --- web/src/api/collections.test.ts | 32 +++- web/src/api/collections.ts | 50 +++-- web/src/screens/CollectionsScreen.test.tsx | 145 +++++++++++++- web/src/screens/CollectionsScreen.tsx | 213 +++++++++++++++++++-- 4 files changed, 411 insertions(+), 29 deletions(-) diff --git a/web/src/api/collections.test.ts b/web/src/api/collections.test.ts index 61fe660d8..9aa26d35e 100644 --- a/web/src/api/collections.test.ts +++ b/web/src/api/collections.test.ts @@ -12,6 +12,7 @@ import { removeItemFromCollection, toAddItemsRequest, updateCollection, + updateCollectionCustomOrder, updateSmartCollection } from './collections'; import type { LibraryBrowseItem } from './libraryBrowse'; @@ -184,6 +185,17 @@ describe('collections api client', () => { expect(url.searchParams.get('pageNum')).toBe('0'); expect(url.searchParams.get('pageSize')).toBe('100'); }); + + it('updateCollectionCustomOrder PUTs the full ordered mediaItemIds array', async () => { + const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent()); + + await updateCollectionCustomOrder(7, [30, 10, 20]); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/collections/7/custom-order'); + expect(init).toMatchObject({ method: 'PUT' }); + expect(JSON.parse(String(init?.body))).toEqual({ mediaItemIds: [30, 10, 20] }); + }); }); describe('toAddItemsRequest bucket mapping', () => { @@ -202,6 +214,24 @@ describe('toAddItemsRequest bucket mapping', () => { expect(result.artistIds).toEqual([40]); }); + it('routes the remaining six addable kinds into their own buckets (#211)', () => { + const result = toAddItemsRequest([ + browseItem(50, 'Episode'), + browseItem(51, 'MusicVideo'), + browseItem(52, 'Song'), + browseItem(53, 'OtherVideo'), + browseItem(54, 'Image'), + browseItem(55, 'RemoteStream') + ]); + + expect(result.episodeIds).toEqual([50]); + expect(result.musicVideoIds).toEqual([51]); + expect(result.songIds).toEqual([52]); + expect(result.otherVideoIds).toEqual([53]); + expect(result.imageIds).toEqual([54]); + expect(result.remoteStreamIds).toEqual([55]); + }); + it('skips kinds that are not addable media items (collections, playlists, etc.)', () => { const result = toAddItemsRequest([ browseItem(1, 'Collection'), @@ -218,7 +248,7 @@ describe('toAddItemsRequest bucket mapping', () => { expect(result.artistIds).toEqual([]); }); - it('emptyAddItemsRequest leaves the picker-unreachable buckets empty', () => { + it('emptyAddItemsRequest starts every bucket empty', () => { const empty = emptyAddItemsRequest(); expect(empty.episodeIds).toEqual([]); diff --git a/web/src/api/collections.ts b/web/src/api/collections.ts index 5c476e545..f22d4b7ca 100644 --- a/web/src/api/collections.ts +++ b/web/src/api/collections.ts @@ -7,6 +7,7 @@ export type SmartCollection = components['schemas']['SmartCollectionViewModel']; export type CreateCollectionRequest = components['schemas']['CreateCollectionRequest']; export type UpdateCollectionRequest = components['schemas']['UpdateCollectionRequest']; export type AddItemsToCollectionRequest = components['schemas']['AddItemsToCollectionRequest']; +export type UpdateCollectionCustomOrderRequest = components['schemas']['UpdateCollectionCustomOrderRequest']; export type CreateSmartCollectionRequest = components['schemas']['CreateSmartCollectionRequest']; export type UpdateSmartCollectionRequest = components['schemas']['UpdateSmartCollectionRequest']; @@ -41,7 +42,9 @@ export function removeItemFromCollection(id: number, mediaItemId: number): Promi } // Lists a manual collection's full contents (all media kinds), paged. Backed by -// GET /api/collections/{id}/items (#155), which reuses the library-browse item shape. +// GET /api/collections/{id}/items (#155), which reuses the library-browse item shape. When the +// collection's useCustomPlaybackOrder is true, items come back ordered by CustomIndex (nulls +// last, then title); otherwise title order. export function getCollectionItems( id: number, pageNum = 0, @@ -54,6 +57,14 @@ export function getCollectionItems( return request(`/api/collections/${id}/items?${params.toString()}`); } +// Replaces a manual collection's custom order wholesale: the CustomIndex of each media item is +// derived from its position in `mediaItemIds`, so a partial array silently drops the items left +// out of it (#211). Callers must submit the full ordered id list. +export function updateCollectionCustomOrder(id: number, mediaItemIds: number[]): Promise { + const body: UpdateCollectionCustomOrderRequest = { mediaItemIds }; + return request(`/api/collections/${id}/custom-order`, { body, method: 'PUT' }); +} + /* ---------- smart collections ---------- */ export function getSmartCollections(): Promise { @@ -78,11 +89,11 @@ export function deleteSmartCollection(id: number): Promise { /* ---------- add-items bucket mapping ---------- */ -// The add-items request buckets media-item ids by kind. The library-browse search only -// surfaces four kinds (Movie / TelevisionShow / TelevisionSeason / Artist), so those are -// the only buckets reachable from the picker. The remaining buckets (episodes, music -// videos, songs, images, other videos, remote streams) can't be produced by browse and -// are left empty here. See CollectionsScreen for the honest note about this limit. +// The add-items request buckets media-item ids by kind. All 10 addable kinds (Movie / +// TelevisionShow / TelevisionSeason / Artist / Episode / MusicVideo / Song / OtherVideo / +// Image / RemoteStream) are reachable from the picker via `toAddItemsRequest` below — the +// default "All" fan-out only searches Movie/TelevisionShow/Artist (see CollectionsScreen's +// DEFAULT_SEARCH_KINDS), but every kind can be found by picking its specific filter. export function emptyAddItemsRequest(): AddItemsToCollectionRequest { return { artistIds: [], @@ -98,11 +109,10 @@ export function emptyAddItemsRequest(): AddItemsToCollectionRequest { }; } -// Buckets a set of browse results into an AddItemsToCollectionRequest. Movie / Show / -// Season / Artist are the only kinds library-browse can return as concrete media; each -// carries its media-item id in `id` (which equals `mediaItemId` for these types, since -// they are all MediaItem subclasses). Ids must go into their type-specific bucket, since -// the server validates each bucket against that entity type (a Show id in movieIds fails +// Buckets a set of browse results into an AddItemsToCollectionRequest. Each addable kind +// carries its media-item id in `id` (which equals `mediaItemId` for these types, since they +// are all MediaItem subclasses). Ids must go into their type-specific bucket, since the +// server validates each bucket against that entity type (a Show id in movieIds fails // validation). Any other kind (collections, smart/multi/rerun collections, playlists) is // not an addable media item and is skipped. export function toAddItemsRequest(items: LibraryBrowseItem[]): AddItemsToCollectionRequest { @@ -122,6 +132,24 @@ export function toAddItemsRequest(items: LibraryBrowseItem[]): AddItemsToCollect case 'Artist': requestBody.artistIds?.push(item.id); break; + case 'Episode': + requestBody.episodeIds?.push(item.id); + break; + case 'MusicVideo': + requestBody.musicVideoIds?.push(item.id); + break; + case 'Song': + requestBody.songIds?.push(item.id); + break; + case 'OtherVideo': + requestBody.otherVideoIds?.push(item.id); + break; + case 'Image': + requestBody.imageIds?.push(item.id); + break; + case 'RemoteStream': + requestBody.remoteStreamIds?.push(item.id); + break; default: break; } diff --git a/web/src/screens/CollectionsScreen.test.tsx b/web/src/screens/CollectionsScreen.test.tsx index 3d262eb9c..466a5ec78 100644 --- a/web/src/screens/CollectionsScreen.test.tsx +++ b/web/src/screens/CollectionsScreen.test.tsx @@ -276,7 +276,13 @@ describe('CollectionsScreen', () => { Movie: { id: 1, mediaType: 'Movie', title: 'Zathura' }, TelevisionShow: { id: 2, mediaType: 'TelevisionShow', title: 'Adventure Time' }, TelevisionSeason: { id: 3, mediaType: 'TelevisionSeason', title: 'Melon Season 1' }, - Artist: { id: 4, mediaType: 'Artist', title: 'Between Movie and Show' } + Artist: { id: 4, mediaType: 'Artist', title: 'Between Movie and Show' }, + Episode: { id: 5, mediaType: 'Episode', title: 'The Pilot Episode' }, + MusicVideo: { id: 6, mediaType: 'MusicVideo', title: 'Music Video Reel' }, + Song: { id: 7, mediaType: 'Song', title: 'Song of the Sea' }, + OtherVideo: { id: 8, mediaType: 'OtherVideo', title: 'Home Video Clip' }, + Image: { id: 9, mediaType: 'Image', title: 'Poster Art' }, + RemoteStream: { id: 10, mediaType: 'RemoteStream', title: 'Live Feed' } }; function mockAddItemsApi() { @@ -358,4 +364,141 @@ describe('CollectionsScreen', () => { .some((u) => new URL(u, 'http://localhost').searchParams.get('mediaType') === 'TelevisionSeason'); expect(seasonCall).toBe(true); }); + + it('the picker note explains the "All" fan-out and the reachable extra kinds (#211)', async () => { + mockAddItemsApi(); + + const dialog = await openAddItemsDialog(); + + expect( + within(dialog).getByText( + /searches movies, shows and artists; pick a specific kind to add seasons, episodes, music videos, songs, other videos, images or remote streams\./ + ) + ).toBeInTheDocument(); + }); + + it('selecting a newly-addable kind filter (Song) surfaces it and buckets it correctly on add', async () => { + const fetchMock = mockAddItemsApi(); + + const dialog = await openAddItemsDialog(); + fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), { + target: { value: 'a' } + }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Song' })); + + expect(await within(dialog).findByText('Song of the Sea')).toBeInTheDocument(); + + const songCall = fetchMock.mock.calls + .map(([u]) => u.toString()) + .filter((u) => u.startsWith('/api/library/browse')) + .some((u) => new URL(u, 'http://localhost').searchParams.get('mediaType') === 'Song'); + expect(songCall).toBe(true); + + fireEvent.click(within(dialog).getByText('Song of the Sea')); + fireEvent.click(within(dialog).getByRole('button', { name: /Add 1 item/ })); + + await waitFor(() => { + const addCall = fetchMock.mock.calls.find( + ([u, init]) => u === '/api/collections/1/items' && (init?.method ?? '').toUpperCase() === 'POST' + ); + expect(addCall).toBeDefined(); + expect(JSON.parse(String(addCall?.[1]?.body)).songIds).toEqual([7]); + }); + }); + + /* ---------- reorder mode (#211) ---------- */ + + function reorderPages(id: number) { + return { + [`/api/collections/${id}/items:0`]: { + page: [ + { id: 100, mediaItemId: 100, mediaType: 'Movie', title: 'Alpha' }, + { id: 200, mediaItemId: 200, mediaType: 'Movie', title: 'Bravo' } + ], + totalCount: 3 + }, + [`/api/collections/${id}/items:1`]: { + page: [{ id: 300, mediaItemId: 300, mediaType: 'Movie', title: 'Charlie' }], + totalCount: 3 + } + }; + } + + function mockReorderApi() { + const pages = reorderPages(2); + + return mockApi({ + onRequest: (url, method) => { + if (/^\/api\/collections\/2\/items/.test(url) && method === 'GET') { + const pageNum = new URL(url, 'http://localhost').searchParams.get('pageNum') ?? '0'; + const key = `/api/collections/2/items:${pageNum}`; + return jsonResponse(pages[key] ?? { page: [], totalCount: 0 }); + } + + return null; + } + }); + } + + it('reorder mode loads every page, supports moving items, and saves the full ordered array', async () => { + const fetchMock = mockReorderApi(); + + render(); + await screen.findByText('Kids'); + + fireEvent.click(screen.getByText('Kids')); + expect(await screen.findByText('Alpha')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Reorder' })); + + // Reorder mode fetches every page (0 then 1) before entering, so all three items render. + expect(await screen.findByRole('button', { name: 'Save order' })).toBeInTheDocument(); + expect(screen.getByText('Charlie')).toBeInTheDocument(); + + const itemsCallCount = () => + fetchMock.mock.calls.filter(([u]) => /^\/api\/collections\/2\/items/.test(u.toString())).length; + // Initial load (page 0) + enterReorder's page 0 + page 1 = 3 GETs minimum. + expect(itemsCallCount()).toBeGreaterThanOrEqual(3); + + // Add/remove actions are disabled while reordering. + expect(screen.getByRole('button', { name: 'Add items' })).toBeDisabled(); + + // Move "Bravo" (index 1) down, so the order becomes Alpha, Charlie, Bravo. + fireEvent.click(screen.getByRole('button', { name: 'Move Bravo down' })); + + fireEvent.click(screen.getByRole('button', { name: 'Save order' })); + + await waitFor(() => { + const putCall = fetchMock.mock.calls.find( + ([u, init]) => u === '/api/collections/2/custom-order' && (init?.method ?? '').toUpperCase() === 'PUT' + ); + expect(putCall).toBeDefined(); + expect(JSON.parse(String(putCall?.[1]?.body))).toEqual({ mediaItemIds: [100, 300, 200] }); + }); + + // Exits reorder mode and refreshes the normal paged view. + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Save order' })).not.toBeInTheDocument(); + }); + expect(screen.getByRole('button', { name: 'Add items' })).not.toBeDisabled(); + }); + + it('canceling reorder mode discards local moves without saving', async () => { + const fetchMock = mockReorderApi(); + + render(); + await screen.findByText('Kids'); + fireEvent.click(screen.getByText('Kids')); + await screen.findByText('Alpha'); + + fireEvent.click(screen.getByRole('button', { name: 'Reorder' })); + await screen.findByRole('button', { name: 'Save order' }); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(screen.queryByRole('button', { name: 'Save order' })).not.toBeInTheDocument(); + expect( + fetchMock.mock.calls.some(([u]) => u === '/api/collections/2/custom-order') + ).toBe(false); + }); }); diff --git a/web/src/screens/CollectionsScreen.tsx b/web/src/screens/CollectionsScreen.tsx index d2a2fe16c..4e4b68e87 100644 --- a/web/src/screens/CollectionsScreen.tsx +++ b/web/src/screens/CollectionsScreen.tsx @@ -1,6 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { + ArrowDown, ArrowLeft, + ArrowUp, Check, FolderTree, Info, @@ -37,6 +39,7 @@ import { removeItemFromCollection, toAddItemsRequest, updateCollection, + updateCollectionCustomOrder, updateSmartCollection, type LibraryBrowseItem, type MediaCollection, @@ -47,12 +50,23 @@ import { TYPE_LABEL } from '../media/mediaKinds'; type Tab = 'manual' | 'smart'; // Every kind that can be added to a manual collection from the picker. -const ADDABLE_TYPE_LIST: LibraryBrowseItem['mediaType'][] = ['Movie', 'TelevisionShow', 'TelevisionSeason', 'Artist']; +const ADDABLE_TYPE_LIST: LibraryBrowseItem['mediaType'][] = [ + 'Movie', + 'TelevisionShow', + 'TelevisionSeason', + 'Artist', + 'Episode', + 'MusicVideo', + 'Song', + 'OtherVideo', + 'Image', + 'RemoteStream' +]; const ADDABLE_TYPES = new Set(ADDABLE_TYPE_LIST); // The default fan-out excludes seasons so a multi-season show doesn't flood the -// results with per-season rows (issue #180); seasons stay reachable via the -// explicit media-kind filter below. +// results with per-season rows (issue #180); seasons (and the other narrower kinds +// added for #211) stay reachable via the explicit media-kind filter below. const DEFAULT_SEARCH_KINDS: LibraryBrowseItem['mediaType'][] = ['Movie', 'TelevisionShow', 'Artist']; type MediaKindFilter = 'all' | LibraryBrowseItem['mediaType']; @@ -62,7 +76,13 @@ const MEDIA_KIND_FILTERS: { label: string; value: MediaKindFilter }[] = [ { label: 'Movies', value: 'Movie' }, { label: 'Shows', value: 'TelevisionShow' }, { label: 'Seasons', value: 'TelevisionSeason' }, - { label: 'Artists', value: 'Artist' } + { label: 'Artists', value: 'Artist' }, + { label: TYPE_LABEL.Episode, value: 'Episode' }, + { label: TYPE_LABEL.MusicVideo, value: 'MusicVideo' }, + { label: TYPE_LABEL.Song, value: 'Song' }, + { label: TYPE_LABEL.OtherVideo, value: 'OtherVideo' }, + { label: TYPE_LABEL.Image, value: 'Image' }, + { label: TYPE_LABEL.RemoteStream, value: 'RemoteStream' } ]; function sortByName(items: T[]): T[] { @@ -434,9 +454,8 @@ function AddItemsDialog({ ))}

- Add movies, shows, seasons and artists. “All” searches movies, shows and artists; pick - “Seasons” to find a specific season. Episodes, music, images and other item kinds can’t be - added from here yet. + “All” searches movies, shows and artists; pick a specific kind to add seasons, episodes, + music videos, songs, other videos, images or remote streams.

{error && ( @@ -490,6 +509,10 @@ function ManualItemsView({ const [error, setError] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); const [removing, setRemoving] = useState(null); + const [reordering, setReordering] = useState(false); + const [reorderItems, setReorderItems] = useState(null); + const [reorderLoading, setReorderLoading] = useState(false); + const [reorderSaving, setReorderSaving] = useState(false); const activeRef = useRef(true); // No synchronous setState here: `loading` starts true and flips false in `finally`, so @@ -554,6 +577,98 @@ function ManualItemsView({ } }; + // The custom-order PUT replaces the order wholesale from the submitted array, so a + // partial list would silently drop the items left out of it — every page must be loaded + // before reorder mode is entered. If any page fails to load, bail out without entering + // reorder mode (the error surfaces via the normal `error` banner). + const enterReorder = async () => { + setReorderLoading(true); + setError(null); + + try { + const first = await getCollectionItems(collection.id, 0, PAGE_SIZE); + let all = first.page ?? []; + const totalCount = first.totalCount ?? all.length; + let pageNum = 1; + + while (all.length < totalCount) { + const next = await getCollectionItems(collection.id, pageNum, PAGE_SIZE); + const nextPage = next.page ?? []; + + if (nextPage.length === 0) { + break; + } + + all = [...all, ...nextPage]; + pageNum += 1; + } + + if (activeRef.current) { + setReorderItems(all); + setReordering(true); + } + } catch (reorderLoadError) { + if (activeRef.current) { + setError(messageFromCollectionError(reorderLoadError, 'Unable to load items for reordering')); + } + } finally { + if (activeRef.current) { + setReorderLoading(false); + } + } + }; + + const moveItem = (index: number, direction: -1 | 1) => { + setReorderItems((current) => { + if (!current) { + return current; + } + + const target = index + direction; + if (target < 0 || target >= current.length) { + return current; + } + + const next = [...current]; + [next[index], next[target]] = [next[target], next[index]]; + return next; + }); + }; + + const cancelReorder = () => { + setReordering(false); + setReorderItems(null); + setError(null); + }; + + const saveOrder = async () => { + if (!reorderItems) { + return; + } + + setReorderSaving(true); + setError(null); + + try { + const mediaItemIds = reorderItems.map((item) => item.mediaItemId ?? item.id); + await updateCollectionCustomOrder(collection.id, mediaItemIds); + + if (activeRef.current) { + setReordering(false); + setReorderItems(null); + load(); + } + } catch (saveError) { + if (activeRef.current) { + setError(messageFromCollectionError(saveError, 'Unable to save order')); + } + } finally { + if (activeRef.current) { + setReorderSaving(false); + } + } + }; + return (
@@ -562,7 +677,24 @@ function ManualItemsView({ {collection.name} - + )} +
@@ -575,7 +707,41 @@ function ManualItemsView({ )} - {loading ? ( + {reordering && reorderItems ? ( + reorderItems.length === 0 ? ( +
No items in this collection.
+ ) : ( + reorderItems.map((item, index) => ( +
+
+ )) + ) + ) : loading ? (
Loading items… @@ -609,15 +775,30 @@ function ManualItemsView({ )} - {!loading && items.length < total && ( + {reordering ? (
- - Showing {items.length} of {total} - - + {reorderItems?.length ?? 0} items +
+ + +
+ ) : ( + !loading && + items.length < total && ( +
+ + Showing {items.length} of {total} + + +
+ ) )}