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.
This commit is contained in:
@@ -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([]);
|
||||
|
||||
+39
-11
@@ -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<PagedLibraryBrowseItems>(`/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<void> {
|
||||
const body: UpdateCollectionCustomOrderRequest = { mediaItemIds };
|
||||
return request<void>(`/api/collections/${id}/custom-order`, { body, method: 'PUT' });
|
||||
}
|
||||
|
||||
/* ---------- smart collections ---------- */
|
||||
|
||||
export function getSmartCollections(): Promise<SmartCollection[]> {
|
||||
@@ -78,11 +89,11 @@ export function deleteSmartCollection(id: number): Promise<void> {
|
||||
|
||||
/* ---------- 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user