From 1e72faa60f354e392467fa2a0ffbe8e45c31a10a Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 10 Jul 2026 00:00:05 +0200 Subject: [PATCH] feat(spa): shared Add-to dialogs + MediaPosterCard actions slot (#208 #209) Co-Authored-By: Claude Fable 5 --- web/src/api/libraries.test.ts | 40 ++++ web/src/api/libraries.ts | 15 ++ web/src/api/playlists.test.ts | 22 ++ web/src/api/playlists.ts | 12 + web/src/api/search.test.ts | 41 +++- web/src/api/search.ts | 44 ++++ web/src/media/MediaPosterCard.test.tsx | 66 ++++++ web/src/media/MediaPosterCard.tsx | 19 +- .../addTo/AddToCollectionDialog.test.tsx | 129 +++++++++++ web/src/media/addTo/AddToCollectionDialog.tsx | 184 +++++++++++++++ web/src/media/addTo/AddToMenu.test.tsx | 81 +++++++ web/src/media/addTo/AddToMenu.tsx | 109 +++++++++ .../media/addTo/AddToPlaylistDialog.test.tsx | 95 ++++++++ web/src/media/addTo/AddToPlaylistDialog.tsx | 212 ++++++++++++++++++ .../media/addTo/AddToScheduleDialog.test.tsx | 137 +++++++++++ web/src/media/addTo/AddToScheduleDialog.tsx | 175 +++++++++++++++ web/src/media/addTo/index.ts | 5 + web/src/media/addTo/scheduleItem.ts | 83 +++++++ web/src/shell.css | 61 +++++ 19 files changed, 1527 insertions(+), 3 deletions(-) create mode 100644 web/src/api/libraries.test.ts create mode 100644 web/src/media/MediaPosterCard.test.tsx create mode 100644 web/src/media/addTo/AddToCollectionDialog.test.tsx create mode 100644 web/src/media/addTo/AddToCollectionDialog.tsx create mode 100644 web/src/media/addTo/AddToMenu.test.tsx create mode 100644 web/src/media/addTo/AddToMenu.tsx create mode 100644 web/src/media/addTo/AddToPlaylistDialog.test.tsx create mode 100644 web/src/media/addTo/AddToPlaylistDialog.tsx create mode 100644 web/src/media/addTo/AddToScheduleDialog.test.tsx create mode 100644 web/src/media/addTo/AddToScheduleDialog.tsx create mode 100644 web/src/media/addTo/index.ts create mode 100644 web/src/media/addTo/scheduleItem.ts diff --git a/web/src/api/libraries.test.ts b/web/src/api/libraries.test.ts new file mode 100644 index 000000000..f50fa5436 --- /dev/null +++ b/web/src/api/libraries.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { scanLibrary, scanShow } from './libraries'; + +function noContent(): Response { + return new Response(null, { status: 200 }); +} + +function lastCall(fetchMock: ReturnType) { + const call = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]; + return { init: call[1] as RequestInit | undefined, url: String(call[0]) }; +} + +describe('libraries api client', () => { + beforeEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + it('scanLibrary POSTs to the library scan endpoint', async () => { + const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent()); + await scanLibrary(4); + expect(fetchMock).toHaveBeenCalledWith('/api/libraries/4/scan', expect.objectContaining({ method: 'POST' })); + }); + + it('scanShow POSTs the show title and deepScan flag', async () => { + const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent()); + await scanShow(4, { deepScan: true, showTitle: 'The Office' }); + const { init, url } = lastCall(fetchMock); + expect(url).toBe('/api/libraries/4/scan-show'); + expect(init?.method).toBe('POST'); + expect(JSON.parse(String(init?.body))).toEqual({ deepScan: true, showTitle: 'The Office' }); + }); + + it('scanShow defaults deepScan to false when omitted', async () => { + const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent()); + await scanShow(9, { showTitle: 'Firefly' }); + const { init } = lastCall(fetchMock); + expect(JSON.parse(String(init?.body))).toEqual({ deepScan: false, showTitle: 'Firefly' }); + }); +}); diff --git a/web/src/api/libraries.ts b/web/src/api/libraries.ts index f2b78ab8b..0def1f765 100644 --- a/web/src/api/libraries.ts +++ b/web/src/api/libraries.ts @@ -51,6 +51,21 @@ export function scanLibrary(libraryId: number): Promise { return request(`/api/libraries/${libraryId}/scan`, { method: 'POST' }); } +export interface ScanShowParams { + showTitle: string; + deepScan?: boolean; +} + +// Queues a scan of a single show (by title) within a library. Returns 200 on success, 400 when +// the title can't be resolved / the library doesn't support single-show scanning. Body keys are +// `showTitle` and `deepScan` (see LibrariesController.ScanShowRequest). +export function scanShow(libraryId: number, params: ScanShowParams): Promise { + return request(`/api/libraries/${libraryId}/scan-show`, { + body: { deepScan: params.deepScan ?? false, showTitle: params.showTitle }, + method: 'POST' + }); +} + // Pure: computes the surviving pending-id set for one poll tick (success or failure) and // mutates the grace-ticks map in place (delete on promote/expire, set on decrement) - // callers must still write pendingIdsRef.current with the returned set themselves, and diff --git a/web/src/api/playlists.test.ts b/web/src/api/playlists.test.ts index 9591399bb..26f7f2a70 100644 --- a/web/src/api/playlists.test.ts +++ b/web/src/api/playlists.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { + addItemsToPlaylist, createPlaylist, createPlaylistGroup, deletePlaylist, @@ -131,6 +132,27 @@ describe('playlists api client', () => { expect(JSON.parse(String(init?.body))).toEqual({ items: [sampleItem], name: 'Draft' }); }); + it('addItemsToPlaylist POSTs the bucketed ids to the items endpoint', async () => { + const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent()); + const body = { + artistIds: [], + episodeIds: [], + imageIds: [], + movieIds: [12], + musicVideoIds: [], + otherVideoIds: [], + remoteStreamIds: [], + seasonIds: [], + showIds: [3], + songIds: [] + }; + await addItemsToPlaylist(6, body); + const { init, url } = lastCall(fetchMock); + expect(url).toBe('/api/playlists/6/items'); + expect(init?.method).toBe('POST'); + expect(JSON.parse(String(init?.body))).toEqual(body); + }); + it('messageFromPlaylistError prefers ApiError detail', () => { expect(messageFromPlaylistError(new ApiError(422, { detail: 'Name is required' }))).toBe('Name is required'); expect(messageFromPlaylistError('nope', 'fallback')).toBe('fallback'); diff --git a/web/src/api/playlists.ts b/web/src/api/playlists.ts index 5f96d73ba..b13af2366 100644 --- a/web/src/api/playlists.ts +++ b/web/src/api/playlists.ts @@ -1,5 +1,6 @@ import { ApiError, request } from './client'; import type { components } from './generated/v1'; +import type { AddItemsToCollectionRequest } from './collections'; export type PlaylistGroup = components['schemas']['PlaylistGroupResponseModel']; export type Playlist = components['schemas']['PlaylistResponseModel']; @@ -56,6 +57,17 @@ export function previewPlaylist(body: ReplacePlaylistRequest): Promise('/api/playlists/preview', { body, method: 'POST' }); } +// Adds media items (bucketed by kind) to an existing playlist. The request body is the same +// ten-array shape as AddItemsToCollectionRequest (movieIds/showIds/seasonIds/episodeIds/ +// artistIds/musicVideoIds/otherVideoIds/songIds/imageIds/remoteStreamIds); the server returns +// 204 on success, 404 for a missing playlist, 422 for validation failures. +// TODO(#208): switch to generated types after OpenAPI regen lands (endpoint added on a sibling +// backend branch, not yet in v1.d.ts). The body is structurally identical to the generated +// AddItemsToCollectionRequest, so we reuse that type here. +export function addItemsToPlaylist(playlistId: number, body: AddItemsToCollectionRequest): Promise { + return request(`/api/playlists/${playlistId}/items`, { body, method: 'POST' }); +} + export function messageFromPlaylistError(error: unknown, fallback = 'Unable to load playlists'): string { if (error instanceof ApiError) { return error.detail ?? error.message; diff --git a/web/src/api/search.test.ts b/web/src/api/search.test.ts index 5ea895499..ab0487df7 100644 --- a/web/src/api/search.test.ts +++ b/web/src/api/search.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getSearchResults } from './search'; +import { getSearchAllItems, getSearchResults, toAddItemsRequestFromSearch } from './search'; const emptyGroup = { totalCount: 0, items: [] }; const sampleResults = { @@ -61,3 +61,42 @@ describe('getSearchResults', () => { await expect(getSearchResults({ query: '' })).rejects.toMatchObject({ status: 422 }); }); }); + +describe('getSearchAllItems', () => { + beforeEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + it('GETs /api/search/all-items with the query', async () => { + const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ movieIds: [1], showIds: [2] }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }) + ); + + await getSearchAllItems('star wars'); + + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe('/api/search/all-items?query=star+wars'); + expect((init?.method ?? 'GET').toUpperCase()).toBe('GET'); + }); +}); + +describe('toAddItemsRequestFromSearch', () => { + it('fills every bucket, defaulting null/undefined arrays to []', () => { + expect(toAddItemsRequestFromSearch({ movieIds: [1, 2], showIds: null })).toEqual({ + artistIds: [], + episodeIds: [], + imageIds: [], + movieIds: [1, 2], + musicVideoIds: [], + otherVideoIds: [], + remoteStreamIds: [], + seasonIds: [], + showIds: [], + songIds: [] + }); + }); +}); diff --git a/web/src/api/search.ts b/web/src/api/search.ts index acefe1a4e..8c7a37f48 100644 --- a/web/src/api/search.ts +++ b/web/src/api/search.ts @@ -1,9 +1,28 @@ import { ApiError, request } from './client'; import type { components } from './generated/v1'; +import type { AddItemsToCollectionRequest } from './collections'; export type SearchResults = components['schemas']['SearchResultsResponseModel']; export type SearchResultGroup = components['schemas']['SearchResultGroupResponseModel']; +// The ten media-item id arrays a search query resolves to, one bucket per addable kind. Wire keys +// match AddItemsToCollectionRequest exactly, so a result pipes straight into addItemsToCollection / +// addItemsToPlaylist via toAddItemsRequestFromSearch below. +// TODO(#208): switch to generated types after OpenAPI regen lands (GET /api/search/all-items was +// added on a sibling backend branch, not yet in v1.d.ts). Locally typed until then. +export interface SearchAllItemIds { + artistIds?: null | number[]; + episodeIds?: null | number[]; + imageIds?: null | number[]; + movieIds?: null | number[]; + musicVideoIds?: null | number[]; + otherVideoIds?: null | number[]; + remoteStreamIds?: null | number[]; + seasonIds?: null | number[]; + showIds?: null | number[]; + songIds?: null | number[]; +} + export interface GetSearchResultsParams { query: string; pageSize?: number; @@ -20,6 +39,31 @@ export function getSearchResults(params: GetSearchResultsParams): Promise(`/api/search?${searchParams.toString()}`); } +// Resolves a search query to the full set of matching media-item ids, bucketed by kind. Backs the +// "Add all results" flow so the caller never has to page through every result to add them. +export function getSearchAllItems(query: string): Promise { + const searchParams = new URLSearchParams(); + searchParams.set('query', query); + return request(`/api/search/all-items?${searchParams.toString()}`); +} + +// Normalizes a SearchAllItemIds result (nullable arrays) into a full AddItemsToCollectionRequest +// so it can be piped straight into addItemsToCollection / addItemsToPlaylist. +export function toAddItemsRequestFromSearch(result: SearchAllItemIds): AddItemsToCollectionRequest { + return { + artistIds: result.artistIds ?? [], + episodeIds: result.episodeIds ?? [], + imageIds: result.imageIds ?? [], + movieIds: result.movieIds ?? [], + musicVideoIds: result.musicVideoIds ?? [], + otherVideoIds: result.otherVideoIds ?? [], + remoteStreamIds: result.remoteStreamIds ?? [], + seasonIds: result.seasonIds ?? [], + showIds: result.showIds ?? [], + songIds: result.songIds ?? [] + }; +} + export function messageFromSearchError(error: unknown, fallback = 'Unable to search library'): string { if (error instanceof ApiError) { return error.detail ?? error.message; diff --git a/web/src/media/MediaPosterCard.test.tsx b/web/src/media/MediaPosterCard.test.tsx new file mode 100644 index 000000000..885c94407 --- /dev/null +++ b/web/src/media/MediaPosterCard.test.tsx @@ -0,0 +1,66 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { MediaPosterCard } from './MediaPosterCard'; +import type { LibraryBrowseItem } from '../api'; + +const movieItem = { artwork: '', id: 12, mediaType: 'Movie', title: 'Blade Runner' } as unknown as LibraryBrowseItem; + +describe('MediaPosterCard', () => { + afterEach(() => { + cleanup(); + }); + + it('renders the item title', () => { + render(); + expect(screen.getByText('Blade Runner')).toBeTruthy(); + }); + + it('calls onOpen when the card is clicked', () => { + const onOpen = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button')); + expect(onOpen).toHaveBeenCalledWith(movieItem); + }); + + it('does not trigger open/select when the actions slot is clicked', () => { + const onOpen = vi.fn(); + const onAction = vi.fn(); + render( + + Act + + } + item={movieItem} + onOpen={onOpen} + /> + ); + + fireEvent.click(screen.getByRole('button', { name: 'Act' })); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onOpen).not.toHaveBeenCalled(); + }); + + it('still toggles selection from the card body while the actions slot stays isolated', () => { + const onToggleSelect = vi.fn(); + const onAction = vi.fn(); + render( + + Act + + } + item={movieItem} + onToggleSelect={onToggleSelect} + /> + ); + + fireEvent.click(screen.getByText('Blade Runner')); + expect(onToggleSelect).toHaveBeenCalledWith(movieItem); + + fireEvent.click(screen.getByRole('button', { name: 'Act' })); + expect(onToggleSelect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/src/media/MediaPosterCard.tsx b/web/src/media/MediaPosterCard.tsx index 1327f7877..d7cbecaf8 100644 --- a/web/src/media/MediaPosterCard.tsx +++ b/web/src/media/MediaPosterCard.tsx @@ -1,22 +1,26 @@ -import type { CSSProperties } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; import { Film } from 'lucide-react'; import type { LibraryBrowseItem } from '../api'; import { TYPE_ICON, TYPE_LABEL, hueOf, itemSubtitle } from './mediaKinds'; // A poster/thumbnail grid card for a browse/search item. Selectable + clickable for the media // browse/search/trash screens. Falls back to a deterministic gradient + type icon when no artwork. +// `actions` renders an overlay in the poster's top-right corner (e.g. an Add-to menu button); +// interactions inside it are isolated from the card's open/select gesture via stopPropagation. export function MediaPosterCard({ item, selected, onToggleSelect, onOpen, - height = 150 + height = 150, + actions }: { item: LibraryBrowseItem; selected?: boolean; onToggleSelect?: (item: LibraryBrowseItem) => void; onOpen?: (item: LibraryBrowseItem) => void; height?: number; + actions?: ReactNode; }) { const hue = hueOf(item.title); const Icon = TYPE_ICON[item.mediaType] ?? Film; @@ -72,6 +76,17 @@ export function MediaPosterCard({ + {actions && ( +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + {actions} +
+ )} {selected &&
}
diff --git a/web/src/media/addTo/AddToCollectionDialog.test.tsx b/web/src/media/addTo/AddToCollectionDialog.test.tsx new file mode 100644 index 000000000..2a83455b9 --- /dev/null +++ b/web/src/media/addTo/AddToCollectionDialog.test.tsx @@ -0,0 +1,129 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AddToCollectionDialog } from './AddToCollectionDialog'; +import type { LibraryBrowseItem } from '../../api'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status }); +} + +const collections = [ + { collectionType: 'Collection', id: 1, name: 'Favorites', useCustomPlaybackOrder: false }, + { collectionType: 'Collection', id: 2, name: 'Kids', useCustomPlaybackOrder: false } +]; + +const movieItem = { artwork: '', id: 12, mediaType: 'Movie', title: 'Blade Runner' } as unknown as LibraryBrowseItem; + +const expectedAddBody = { + artistIds: [], + episodeIds: [], + imageIds: [], + movieIds: [12], + musicVideoIds: [], + otherVideoIds: [], + remoteStreamIds: [], + seasonIds: [], + showIds: [], + songIds: [] +}; + +interface Recorded { + url: string; + method: string; + body: unknown; +} + +function mockApi(onCreate?: (body: unknown) => Response) { + const calls: Recorded[] = []; + const fetchMock = vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = (init?.method ?? 'GET').toUpperCase(); + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + calls.push({ body, method, url }); + + if (url === '/api/collections' && method === 'GET') { + return Promise.resolve(jsonResponse(collections)); + } + + if (url === '/api/collections' && method === 'POST') { + return Promise.resolve(onCreate ? onCreate(body) : jsonResponse({ collectionType: 'Collection', id: 99, name: (body as { name: string }).name, useCustomPlaybackOrder: false }, 201)); + } + + return Promise.resolve(new Response(null, { status: 204 })); + }); + + return { calls, fetchMock }; +} + +describe('AddToCollectionDialog', () => { + beforeEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it('renders nothing when closed', () => { + mockApi(); + const { container } = render( {}} open={false} />); + expect(container).toBeEmptyDOMElement(); + }); + + it('loads existing collections into the select', async () => { + mockApi(); + render( {}} open />); + + await waitFor(() => expect(screen.getByRole('option', { name: 'Favorites' })).toBeTruthy()); + expect(screen.getByRole('option', { name: 'Kids' })).toBeTruthy(); + expect(screen.getByRole('option', { name: '(New collection)' })).toBeTruthy(); + }); + + it('adds the items to the selected existing collection', async () => { + const onAdded = vi.fn(); + const onClose = vi.fn(); + const { calls } = mockApi(); + render(); + + await waitFor(() => expect(screen.getByRole('option', { name: 'Kids' })).toBeTruthy()); + fireEvent.change(screen.getByRole('combobox'), { target: { value: '2' } }); + fireEvent.click(screen.getByRole('button', { name: /Add to collection/ })); + + await waitFor(() => expect(onClose).toHaveBeenCalled()); + const addCall = calls.find((call) => call.url === '/api/collections/2/items'); + expect(addCall?.method).toBe('POST'); + expect(addCall?.body).toEqual(expectedAddBody); + expect(onAdded).toHaveBeenCalledWith('Kids'); + }); + + it('creates a new collection first, then adds the items to it', async () => { + const onAdded = vi.fn(); + const { calls } = mockApi(); + render( {}} open />); + + await waitFor(() => expect(screen.getByRole('option', { name: '(New collection)' })).toBeTruthy()); + fireEvent.change(screen.getByRole('combobox'), { target: { value: '__new__' } }); + fireEvent.change(screen.getByPlaceholderText('Collection name'), { target: { value: 'Sci-Fi' } }); + fireEvent.click(screen.getByRole('button', { name: /Add to collection/ })); + + await waitFor(() => expect(onAdded).toHaveBeenCalledWith('Sci-Fi')); + const createCall = calls.find((call) => call.url === '/api/collections' && call.method === 'POST'); + expect(createCall?.body).toEqual({ name: 'Sci-Fi' }); + const addCall = calls.find((call) => call.url === '/api/collections/99/items'); + expect(addCall?.body).toEqual(expectedAddBody); + }); + + it('supports a request override (query-based add-all) instead of item ids', async () => { + const override = { ...expectedAddBody, movieIds: [1, 2, 3] }; + const { calls } = mockApi(); + render( {}} open />); + + await waitFor(() => expect(screen.getByRole('option', { name: 'Favorites' })).toBeTruthy()); + fireEvent.click(screen.getByRole('button', { name: /Add to collection/ })); + + await waitFor(() => expect(calls.some((call) => call.url === '/api/collections/1/items')).toBe(true)); + const addCall = calls.find((call) => call.url === '/api/collections/1/items'); + expect(addCall?.body).toEqual(override); + }); +}); diff --git a/web/src/media/addTo/AddToCollectionDialog.tsx b/web/src/media/addTo/AddToCollectionDialog.tsx new file mode 100644 index 000000000..21ffcc22b --- /dev/null +++ b/web/src/media/addTo/AddToCollectionDialog.tsx @@ -0,0 +1,184 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Plus } from 'lucide-react'; +import { Button, Dialog, Input, Select, Spinner } from '../../components'; +import { + addItemsToCollection, + createCollection, + getCollections, + messageFromCollectionError, + toAddItemsRequest, + type AddItemsToCollectionRequest, + type MediaCollection +} from '../../api'; +import type { AddToItems } from './scheduleItem'; + +export interface AddToCollectionDialogProps { + open: boolean; + onClose: () => void; + items: AddToItems; + onAdded?: (collectionName: string) => void; +} + +const NEW_COLLECTION = '__new__'; + +function resolveRequest(items: AddToItems): AddItemsToCollectionRequest { + return Array.isArray(items) ? toAddItemsRequest(items) : items.requestOverride; +} + +export function AddToCollectionDialog(props: AddToCollectionDialogProps) { + if (!props.open) { + return null; + } + + return ; +} + +function AddToCollectionDialogBody({ onClose, items, onAdded }: AddToCollectionDialogProps) { + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [collections, setCollections] = useState([]); + const [selected, setSelected] = useState(NEW_COLLECTION); + const [newName, setNewName] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const activeRef = useRef(true); + + useEffect(() => { + activeRef.current = true; + + return () => { + activeRef.current = false; + }; + }, []); + + const load = useCallback(() => { + getCollections() + .then((result) => { + if (!activeRef.current) { + return; + } + + setCollections(result); + setSelected(result.length > 0 ? String(result[0].id) : NEW_COLLECTION); + setLoadError(null); + setLoading(false); + }) + .catch((error: unknown) => { + if (!activeRef.current) { + return; + } + + setLoadError(messageFromCollectionError(error)); + setLoading(false); + }); + }, []); + + useEffect(() => { + load(); + }, [load]); + + const isNew = selected === NEW_COLLECTION; + const canSubmit = !submitting && (!isNew || newName.trim().length > 0); + + const submit = () => { + if (!canSubmit) { + return; + } + + setSubmitting(true); + setSubmitError(null); + + const request = resolveRequest(items); + + const ensureCollection: Promise<{ id: number; name: string }> = isNew + ? createCollection({ name: newName.trim() }).then((collection) => ({ + id: collection.id, + name: collection.name ?? newName.trim() + })) + : Promise.resolve({ + id: Number(selected), + name: collections.find((collection) => String(collection.id) === selected)?.name ?? 'collection' + }); + + ensureCollection + .then((collection) => addItemsToCollection(collection.id, request).then(() => collection)) + .then((collection) => { + if (!activeRef.current) { + return; + } + + onAdded?.(collection.name); + onClose(); + }) + .catch((error: unknown) => { + if (!activeRef.current) { + return; + } + + setSubmitError(messageFromCollectionError(error, 'Unable to add items')); + setSubmitting(false); + }); + }; + + const options = [ + { value: NEW_COLLECTION, label: '(New collection)' }, + ...collections.map((collection) => ({ value: String(collection.id), label: collection.name ?? `Collection ${collection.id}` })) + ]; + + return ( + + + + + } + onClose={onClose} + open + title="Add to collection" + width={460} + > + {loading ? ( +
+ +
+ ) : loadError ? ( + + {loadError} + + ) : ( +
+ setNewName(event.target.value)} + placeholder="Collection name" + value={newName} + /> + )} + {submitError && ( + + {submitError} + + )} +
+ )} +
+ ); +} diff --git a/web/src/media/addTo/AddToMenu.test.tsx b/web/src/media/addTo/AddToMenu.test.tsx new file mode 100644 index 000000000..09f0e4f2c --- /dev/null +++ b/web/src/media/addTo/AddToMenu.test.tsx @@ -0,0 +1,81 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AddToMenu } from './AddToMenu'; +import type { LibraryBrowseItem } from '../../api'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status }); +} + +const movieItem = { artwork: '', id: 12, mediaType: 'Movie', title: 'Blade Runner' } as unknown as LibraryBrowseItem; + +function mockApi() { + vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => { + const url = input.toString(); + if (url === '/api/collections') { + return Promise.resolve(jsonResponse([{ collectionType: 'Collection', id: 1, name: 'Favorites', useCustomPlaybackOrder: false }])); + } + if (url === '/api/playlists/groups') { + return Promise.resolve(jsonResponse([])); + } + if (url === '/api/schedules') { + return Promise.resolve(jsonResponse([])); + } + return Promise.resolve(new Response(null, { status: 204 })); + }); +} + +describe('AddToMenu', () => { + beforeEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it('opens the menu and shows the requested targets', () => { + mockApi(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Add to…' })); + expect(screen.getByRole('menuitem', { name: /Add to collection/ })).toBeTruthy(); + expect(screen.getByRole('menuitem', { name: /Add to playlist/ })).toBeTruthy(); + expect(screen.getByRole('menuitem', { name: /Add to schedule/ })).toBeTruthy(); + }); + + it('hides the schedule target when more than one item is selected', () => { + mockApi(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Add to…' })); + expect(screen.queryByRole('menuitem', { name: /Add to schedule/ })).toBeNull(); + }); + + it('respects an explicit targets list', () => { + mockApi(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Add to…' })); + expect(screen.getByRole('menuitem', { name: /Add to collection/ })).toBeTruthy(); + expect(screen.queryByRole('menuitem', { name: /Add to playlist/ })).toBeNull(); + }); + + it('opens the collection dialog when the collection target is chosen', async () => { + mockApi(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Add to…' })); + fireEvent.click(screen.getByRole('menuitem', { name: /Add to collection/ })); + + await waitFor(() => expect(screen.getByRole('dialog')).toBeTruthy()); + expect(screen.getByRole('option', { name: 'Favorites' })).toBeTruthy(); + }); + + it('disables the trigger when there are no items', () => { + mockApi(); + render(); + expect(screen.getByRole('button', { name: 'Add to…' })).toHaveProperty('disabled', true); + }); +}); diff --git a/web/src/media/addTo/AddToMenu.tsx b/web/src/media/addTo/AddToMenu.tsx new file mode 100644 index 000000000..77f35fd7d --- /dev/null +++ b/web/src/media/addTo/AddToMenu.tsx @@ -0,0 +1,109 @@ +import { useEffect, useRef, useState } from 'react'; +import { CalendarClock, FolderPlus, ListVideo, Plus } from 'lucide-react'; +import { IconButton } from '../../components'; +import type { LibraryBrowseItem } from '../../api'; +import { AddToCollectionDialog } from './AddToCollectionDialog'; +import { AddToPlaylistDialog } from './AddToPlaylistDialog'; +import { AddToScheduleDialog } from './AddToScheduleDialog'; + +export type AddToTarget = 'collection' | 'playlist' | 'schedule'; + +const DEFAULT_TARGETS: AddToTarget[] = ['collection', 'playlist', 'schedule']; + +export interface AddToMenuProps { + items: LibraryBrowseItem[]; + targets?: AddToTarget[]; + onDone?: (message: string) => void; + compact?: boolean; +} + +// A small dropdown-button that opens the right Add-to dialog for the given items. Meant to be +// dropped onto media cards / detail pages by the wiring screens; it owns the popover + which +// dialog is open, and surfaces a success message to the caller via onDone. +export function AddToMenu({ items, targets = DEFAULT_TARGETS, onDone, compact = false }: AddToMenuProps) { + const [menuOpen, setMenuOpen] = useState(false); + const [dialog, setDialog] = useState(null); + const rootRef = useRef(null); + + useEffect(() => { + if (!menuOpen) { + return undefined; + } + + const onPointerDown = (event: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(event.target as Node)) { + setMenuOpen(false); + } + }; + document.addEventListener('mousedown', onPointerDown); + + return () => { + document.removeEventListener('mousedown', onPointerDown); + }; + }, [menuOpen]); + + // Schedule adds a single media item; only offer it when exactly one item is in play. + const scheduleItem = items.length === 1 ? items[0] : null; + const visibleTargets = targets.filter((target) => target !== 'schedule' || scheduleItem !== null); + const disabled = items.length === 0; + + const open = (target: AddToTarget) => { + setMenuOpen(false); + setDialog(target); + }; + + const closeDialog = () => setDialog(null); + + const handleAdded = (message: string) => { + setDialog(null); + onDone?.(message); + }; + + return ( +
+ setMenuOpen((current) => !current)} + size={compact ? 'sm' : 'md'} + title="Add to…" + variant="solid" + > + + {menuOpen && ( +
+ {visibleTargets.includes('collection') && ( + + )} + {visibleTargets.includes('playlist') && ( + + )} + {visibleTargets.includes('schedule') && ( + + )} +
+ )} + + handleAdded(`Added to “${name}”`)} onClose={closeDialog} open={dialog === 'collection'} /> + handleAdded(`Added to “${name}”`)} onClose={closeDialog} open={dialog === 'playlist'} /> + {scheduleItem && ( + handleAdded(`Added to “${name}”`)} + onClose={closeDialog} + open={dialog === 'schedule'} + /> + )} +
+ ); +} diff --git a/web/src/media/addTo/AddToPlaylistDialog.test.tsx b/web/src/media/addTo/AddToPlaylistDialog.test.tsx new file mode 100644 index 000000000..9dc137d7c --- /dev/null +++ b/web/src/media/addTo/AddToPlaylistDialog.test.tsx @@ -0,0 +1,95 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AddToPlaylistDialog } from './AddToPlaylistDialog'; +import type { LibraryBrowseItem } from '../../api'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status }); +} + +const groups = [ + { id: 1, isSystem: false, name: 'Idents', playlistCount: 2 }, + { id: 2, isSystem: false, name: 'Bumpers', playlistCount: 1 } +]; + +const playlistsByGroup: Record = { + '1': [{ id: 10, isSystem: false, name: 'Morning', playlistGroupId: 1 }], + '2': [{ id: 20, isSystem: false, name: 'Evening', playlistGroupId: 2 }] +}; + +const showItem = { artwork: '', id: 5, mediaType: 'TelevisionShow', title: 'The Office' } as unknown as LibraryBrowseItem; + +interface Recorded { + url: string; + method: string; + body: unknown; +} + +function mockApi() { + const calls: Recorded[] = []; + vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = (init?.method ?? 'GET').toUpperCase(); + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + calls.push({ body, method, url }); + + if (url === '/api/playlists/groups' && method === 'GET') { + return Promise.resolve(jsonResponse(groups)); + } + + const playlistsMatch = /^\/api\/playlists\?playlistGroupId=(\d+)$/.exec(url); + if (playlistsMatch && method === 'GET') { + return Promise.resolve(jsonResponse(playlistsByGroup[playlistsMatch[1]] ?? [])); + } + + return Promise.resolve(new Response(null, { status: 204 })); + }); + + return { calls }; +} + +describe('AddToPlaylistDialog', () => { + beforeEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it('loads the first group and its playlists', async () => { + mockApi(); + render( {}} open />); + + await waitFor(() => expect(screen.getByRole('option', { name: 'Morning' })).toBeTruthy()); + expect(screen.getByRole('option', { name: 'Idents' })).toBeTruthy(); + }); + + it('adds the items to the selected playlist', async () => { + const onAdded = vi.fn(); + const onClose = vi.fn(); + const { calls } = mockApi(); + render(); + + await waitFor(() => expect(screen.getByRole('option', { name: 'Morning' })).toBeTruthy()); + fireEvent.click(screen.getByRole('button', { name: /Add to playlist/ })); + + await waitFor(() => expect(onClose).toHaveBeenCalled()); + const addCall = calls.find((call) => call.url === '/api/playlists/10/items'); + expect(addCall?.method).toBe('POST'); + expect(addCall?.body).toMatchObject({ showIds: [5], movieIds: [] }); + expect(onAdded).toHaveBeenCalledWith('Morning'); + }); + + it('reloads playlists when the group changes', async () => { + mockApi(); + render( {}} open />); + + await waitFor(() => expect(screen.getByRole('option', { name: 'Morning' })).toBeTruthy()); + const [groupSelect] = screen.getAllByRole('combobox'); + fireEvent.change(groupSelect, { target: { value: '2' } }); + + await waitFor(() => expect(screen.getByRole('option', { name: 'Evening' })).toBeTruthy()); + }); +}); diff --git a/web/src/media/addTo/AddToPlaylistDialog.tsx b/web/src/media/addTo/AddToPlaylistDialog.tsx new file mode 100644 index 000000000..3c02eca11 --- /dev/null +++ b/web/src/media/addTo/AddToPlaylistDialog.tsx @@ -0,0 +1,212 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Plus } from 'lucide-react'; +import { Button, Dialog, Select, Spinner } from '../../components'; +import { + addItemsToPlaylist, + getPlaylistGroups, + getPlaylists, + messageFromPlaylistError, + toAddItemsRequest, + type AddItemsToCollectionRequest, + type Playlist, + type PlaylistGroup +} from '../../api'; +import type { AddToItems } from './scheduleItem'; + +export interface AddToPlaylistDialogProps { + open: boolean; + onClose: () => void; + items: AddToItems; + onAdded?: (playlistName: string) => void; +} + +function resolveRequest(items: AddToItems): AddItemsToCollectionRequest { + return Array.isArray(items) ? toAddItemsRequest(items) : items.requestOverride; +} + +export function AddToPlaylistDialog(props: AddToPlaylistDialogProps) { + if (!props.open) { + return null; + } + + return ; +} + +function AddToPlaylistDialogBody({ onClose, items, onAdded }: AddToPlaylistDialogProps) { + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [groups, setGroups] = useState([]); + const [selectedGroup, setSelectedGroup] = useState(''); + const [playlists, setPlaylists] = useState([]); + const [playlistsLoading, setPlaylistsLoading] = useState(false); + const [selectedPlaylist, setSelectedPlaylist] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const activeRef = useRef(true); + const groupSeqRef = useRef(0); + + useEffect(() => { + activeRef.current = true; + + return () => { + activeRef.current = false; + }; + }, []); + + const loadPlaylists = useCallback((groupId: number) => { + const seq = groupSeqRef.current + 1; + groupSeqRef.current = seq; + setPlaylistsLoading(true); + setPlaylists([]); + setSelectedPlaylist(''); + + getPlaylists(groupId) + .then((result) => { + if (!activeRef.current || groupSeqRef.current !== seq) { + return; + } + + setPlaylists(result); + setSelectedPlaylist(result.length > 0 ? String(result[0].id) : ''); + setPlaylistsLoading(false); + }) + .catch((error: unknown) => { + if (!activeRef.current || groupSeqRef.current !== seq) { + return; + } + + setSubmitError(messageFromPlaylistError(error, 'Unable to load playlists')); + setPlaylistsLoading(false); + }); + }, []); + + const load = useCallback(() => { + getPlaylistGroups() + .then((result) => { + if (!activeRef.current) { + return; + } + + setGroups(result); + setLoadError(null); + setLoading(false); + + if (result.length > 0) { + setSelectedGroup(String(result[0].id)); + loadPlaylists(result[0].id); + } + }) + .catch((error: unknown) => { + if (!activeRef.current) { + return; + } + + setLoadError(messageFromPlaylistError(error)); + setLoading(false); + }); + }, [loadPlaylists]); + + useEffect(() => { + load(); + }, [load]); + + const onGroupChange = (value: string) => { + setSelectedGroup(value); + setSubmitError(null); + loadPlaylists(Number(value)); + }; + + const canSubmit = !submitting && !playlistsLoading && selectedPlaylist !== ''; + + const submit = () => { + if (!canSubmit) { + return; + } + + setSubmitting(true); + setSubmitError(null); + + const playlistId = Number(selectedPlaylist); + const name = playlists.find((playlist) => String(playlist.id) === selectedPlaylist)?.name ?? 'playlist'; + + addItemsToPlaylist(playlistId, resolveRequest(items)) + .then(() => { + if (!activeRef.current) { + return; + } + + onAdded?.(name); + onClose(); + }) + .catch((error: unknown) => { + if (!activeRef.current) { + return; + } + + setSubmitError(messageFromPlaylistError(error, 'Unable to add items')); + setSubmitting(false); + }); + }; + + return ( + + + + + } + onClose={onClose} + open + title="Add to playlist" + width={460} + > + {loading ? ( +
+ +
+ ) : loadError ? ( + + {loadError} + + ) : groups.length === 0 ? ( +

No playlist groups exist yet. Create one first.

+ ) : ( +
+ setSelectedPlaylist(event.target.value)} + options={ + playlists.length === 0 + ? [{ value: '', label: playlistsLoading ? 'Loading…' : 'No playlists in this group' }] + : playlists.map((playlist) => ({ value: String(playlist.id), label: playlist.name ?? `Playlist ${playlist.id}` })) + } + value={selectedPlaylist} + /> + {submitError && ( + + {submitError} + + )} +
+ )} +
+ ); +} diff --git a/web/src/media/addTo/AddToScheduleDialog.test.tsx b/web/src/media/addTo/AddToScheduleDialog.test.tsx new file mode 100644 index 000000000..9cbb6f39b --- /dev/null +++ b/web/src/media/addTo/AddToScheduleDialog.test.tsx @@ -0,0 +1,137 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AddToScheduleDialog } from './AddToScheduleDialog'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status }); +} + +const schedules = [ + { id: 1, name: 'Weekdays', fixedStartTimeBehavior: 'Never', keepMultiPartEpisodesTogether: false, randomStartPoint: false, shuffleScheduleItems: false, treatCollectionsAsShows: false }, + { id: 2, name: 'Weekends', fixedStartTimeBehavior: 'Never', keepMultiPartEpisodesTogether: false, randomStartPoint: false, shuffleScheduleItems: false, treatCollectionsAsShows: false } +]; + +// Mirrors AddProgramScheduleItem.ForMediaItem defaults (AddProgramScheduleItem.cs:48-87), with the +// mapped collectionType + mediaItemId for a TelevisionShow item. +const expectedShowPayload = { + startType: 'Dynamic', + startTime: null, + fixedStartTimeBehavior: null, + playoutMode: 'One', + collectionType: 'TelevisionShow', + collectionId: null, + multiCollectionId: null, + smartCollectionId: null, + rerunCollectionId: null, + mediaItemId: 42, + playlistId: null, + searchTitle: null, + searchQuery: null, + playbackOrder: 'Shuffle', + marathonGroupBy: 'None', + marathonShuffleGroups: false, + marathonShuffleItems: false, + marathonBatchSize: null, + fillWithGroupMode: 'None', + multipleMode: 'Count', + multipleCount: null, + playoutDuration: null, + tailMode: 'None', + discardToFillAttempts: null, + customTitle: null, + guideMode: 'Normal', + preRollFillerId: null, + midRollFillerId: null, + postRollFillerId: null, + tailFillerId: null, + fallbackFillerId: null, + watermarkIds: [], + graphicsElementIds: [], + preferredAudioLanguageCode: null, + preferredAudioTitle: null, + preferredSubtitleLanguageCode: null, + subtitleMode: null +}; + +interface Recorded { + url: string; + method: string; + body: unknown; +} + +function mockApi() { + const calls: Recorded[] = []; + vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = (init?.method ?? 'GET').toUpperCase(); + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + calls.push({ body, method, url }); + + if (url === '/api/schedules' && method === 'GET') { + return Promise.resolve(jsonResponse(schedules)); + } + + if (/^\/api\/schedules\/\d+\/items$/.test(url) && method === 'POST') { + return Promise.resolve(jsonResponse({ id: 500 }, 201)); + } + + return Promise.resolve(new Response(null, { status: 204 })); + }); + + return { calls }; +} + +describe('AddToScheduleDialog', () => { + beforeEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it('loads schedules into the select', async () => { + mockApi(); + render( {}} open />); + + await waitFor(() => expect(screen.getByRole('option', { name: 'Weekdays' })).toBeTruthy()); + expect(screen.getByRole('option', { name: 'Weekends' })).toBeTruthy(); + }); + + it('adds the media item with the ForMediaItem defaults payload', async () => { + const onAdded = vi.fn(); + const onClose = vi.fn(); + const { calls } = mockApi(); + render( + + ); + + await waitFor(() => expect(screen.getByRole('option', { name: 'Weekends' })).toBeTruthy()); + fireEvent.change(screen.getByRole('combobox'), { target: { value: '2' } }); + fireEvent.click(screen.getByRole('button', { name: /Add to schedule/ })); + + await waitFor(() => expect(onClose).toHaveBeenCalled()); + const addCall = calls.find((call) => call.url === '/api/schedules/2/items'); + expect(addCall?.method).toBe('POST'); + expect(addCall?.body).toEqual(expectedShowPayload); + expect(onAdded).toHaveBeenCalledWith('Weekends'); + }); + + it('maps a Movie item to the Movie collection type', async () => { + const { calls } = mockApi(); + render( {}} open />); + + await waitFor(() => expect(screen.getByRole('option', { name: 'Weekdays' })).toBeTruthy()); + fireEvent.click(screen.getByRole('button', { name: /Add to schedule/ })); + + await waitFor(() => expect(calls.some((call) => call.url === '/api/schedules/1/items')).toBe(true)); + const addCall = calls.find((call) => call.url === '/api/schedules/1/items'); + expect(addCall?.body).toMatchObject({ collectionType: 'Movie', mediaItemId: 7 }); + }); +}); diff --git a/web/src/media/addTo/AddToScheduleDialog.tsx b/web/src/media/addTo/AddToScheduleDialog.tsx new file mode 100644 index 000000000..c0cf444c5 --- /dev/null +++ b/web/src/media/addTo/AddToScheduleDialog.tsx @@ -0,0 +1,175 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Plus } from 'lucide-react'; +import { Button, Dialog, Select, Spinner } from '../../components'; +import { + addScheduleItem, + getSchedules, + type LibraryBrowseMediaType, + type ProgramSchedule +} from '../../api'; +import { ApiError } from '../../api/client'; +import { collectionTypeForMediaType, scheduleItemRequestForMediaItem } from './scheduleItem'; + +export interface AddToScheduleItem { + mediaType: LibraryBrowseMediaType; + id: number; + title: string; +} + +export interface AddToScheduleDialogProps { + open: boolean; + onClose: () => void; + item: AddToScheduleItem; + onAdded?: (scheduleName: string) => void; +} + +function messageFromError(error: unknown, fallback: string): string { + if (error instanceof ApiError) { + return error.detail ?? error.message; + } + + if (error instanceof Error) { + return error.message; + } + + return fallback; +} + +export function AddToScheduleDialog(props: AddToScheduleDialogProps) { + if (!props.open) { + return null; + } + + return ; +} + +function AddToScheduleDialogBody({ onClose, item, onAdded }: AddToScheduleDialogProps) { + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [schedules, setSchedules] = useState([]); + const [selected, setSelected] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const activeRef = useRef(true); + + useEffect(() => { + activeRef.current = true; + + return () => { + activeRef.current = false; + }; + }, []); + + const load = useCallback(() => { + getSchedules() + .then((result) => { + if (!activeRef.current) { + return; + } + + setSchedules(result); + setSelected(result.length > 0 ? String(result[0].id) : ''); + setLoadError(null); + setLoading(false); + }) + .catch((error: unknown) => { + if (!activeRef.current) { + return; + } + + setLoadError(messageFromError(error, 'Unable to load schedules')); + setLoading(false); + }); + }, []); + + useEffect(() => { + load(); + }, [load]); + + const collectionType = collectionTypeForMediaType(item.mediaType); + const canSubmit = !submitting && selected !== '' && collectionType !== null; + + const submit = () => { + if (!canSubmit || collectionType === null) { + return; + } + + setSubmitting(true); + setSubmitError(null); + + const scheduleId = Number(selected); + const name = schedules.find((schedule) => String(schedule.id) === selected)?.name ?? 'schedule'; + const payload = scheduleItemRequestForMediaItem(collectionType, item.id); + + addScheduleItem(scheduleId, payload) + .then(() => { + if (!activeRef.current) { + return; + } + + onAdded?.(name); + onClose(); + }) + .catch((error: unknown) => { + if (!activeRef.current) { + return; + } + + setSubmitError(messageFromError(error, 'Unable to add to schedule')); + setSubmitting(false); + }); + }; + + return ( + + + + + } + onClose={onClose} + open + title={`Add “${item.title}” to schedule`} + width={460} + > + {loading ? ( +
+ +
+ ) : loadError ? ( + + {loadError} + + ) : collectionType === null ? ( +

This item kind can’t be added to a schedule.

+ ) : schedules.length === 0 ? ( +

No schedules exist yet. Create one first.

+ ) : ( +
+