From aa86bedc6ea229445b8f1a3f4656bc86e986bb0c Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 16 May 2026 19:01:43 +0300 Subject: [PATCH] refactor(webui): key singles import state by file - replace index-based singles selection and search state with stable staging file keys\n- keep refreshes from shifting selected rows or open search panels when files are inserted or reordered\n- add a regression test that proves selection stays attached to the intended file across refreshes --- webui/src/routes/import/-import.helpers.ts | 4 + webui/src/routes/import/-import.store.ts | 66 ++++++---- webui/src/routes/import/-route.test.tsx | 66 ++++++++-- .../routes/import/-ui/import-page.module.css | 9 +- .../routes/import/-ui/singles-import-tab.tsx | 124 ++++++++++-------- 5 files changed, 177 insertions(+), 92 deletions(-) diff --git a/webui/src/routes/import/-import.helpers.ts b/webui/src/routes/import/-import.helpers.ts index d6f8d288..54a88f78 100644 --- a/webui/src/routes/import/-import.helpers.ts +++ b/webui/src/routes/import/-import.helpers.ts @@ -11,6 +11,10 @@ import type { export const IMPORT_PLACEHOLDER_IMAGE = '/static/placeholder.png'; +export function getStagingFileKey(file: ImportStagingFile): string { + return file.full_path; +} + export function formatImportBytes(bytes: number): string { if (bytes > 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`; if (bytes > 1_048_576) return `${(bytes / 1_048_576).toFixed(0)} MB`; diff --git a/webui/src/routes/import/-import.store.ts b/webui/src/routes/import/-import.store.ts index 88a09e2f..fe7ea66f 100644 --- a/webui/src/routes/import/-import.store.ts +++ b/webui/src/routes/import/-import.store.ts @@ -7,8 +7,10 @@ import type { ImportAlbumResult, ImportQueueEntry, ImportQueueJob, + ImportStagingFile, ImportTrackResult, } from './-import.types'; +import { getStagingFileKey } from './-import.helpers'; export type SingleSearchState = { query: string; @@ -37,10 +39,10 @@ function createInitialWorkflowState() { albumMatchError: null as string | null, albumMatchLoading: false, matchOverrides: {} as Record, - selectedSingles: new Set(), - singlesManualMatches: {} as Record, - openSingleSearch: null as number | null, - singleSearches: {} as Record, + selectedSingles: new Set(), + singlesManualMatches: {} as Record, + openSingleSearch: null as string | null, + singleSearches: {} as Record, }; } @@ -111,34 +113,53 @@ export const useImportWorkflowStore = create( setMatchOverrides: (updater: StateUpdater>) => { set((state) => ({ matchOverrides: resolveState(state.matchOverrides, updater) })); }, - toggleSingle: (index: number) => { + toggleSingle: (fileKey: string) => { set((state) => { const selectedSingles = new Set(state.selectedSingles); - if (selectedSingles.has(index)) selectedSingles.delete(index); - else selectedSingles.add(index); + if (selectedSingles.has(fileKey)) selectedSingles.delete(fileKey); + else selectedSingles.add(fileKey); return { selectedSingles }; }); }, - toggleAllSingles: (fileCount: number) => { + toggleAllSingles: (stagingFiles: ImportStagingFile[]) => { set((state) => ({ - selectedSingles: - state.selectedSingles.size === fileCount - ? new Set() - : new Set(Array.from({ length: fileCount }, (_, index) => index)), + selectedSingles: (() => { + const fileKeys = stagingFiles.map(getStagingFileKey); + return state.selectedSingles.size === fileKeys.length && + fileKeys.every((key) => state.selectedSingles.has(key)) + ? new Set() + : new Set(fileKeys); + })(), })); }, clearSinglesSelection: () => { set({ - selectedSingles: new Set(), + selectedSingles: new Set(), singlesManualMatches: {}, }); }, - setOpenSingleSearch: (openSingleSearch: number | null) => set({ openSingleSearch }), - ensureSingleSearch: (index: number, query: string) => { + syncSinglesWorkflow: (stagingFiles: ImportStagingFile[]) => { + const validKeys = new Set(stagingFiles.map(getStagingFileKey)); + set((state) => ({ + selectedSingles: new Set([...state.selectedSingles].filter((key) => validKeys.has(key))), + singlesManualMatches: Object.fromEntries( + Object.entries(state.singlesManualMatches).filter(([key]) => validKeys.has(key)), + ), + openSingleSearch: + state.openSingleSearch && validKeys.has(state.openSingleSearch) + ? state.openSingleSearch + : null, + singleSearches: Object.fromEntries( + Object.entries(state.singleSearches).filter(([key]) => validKeys.has(key)), + ), + })); + }, + setOpenSingleSearch: (openSingleSearch: string | null) => set({ openSingleSearch }), + ensureSingleSearch: (fileKey: string, query: string) => { set((state) => ({ singleSearches: { ...state.singleSearches, - [index]: state.singleSearches[index] ?? { + [fileKey]: state.singleSearches[fileKey] ?? { query, loading: false, error: null, @@ -147,9 +168,9 @@ export const useImportWorkflowStore = create( }, })); }, - setSingleSearch: (index: number, updater: StateUpdater) => { + setSingleSearch: (fileKey: string, updater: StateUpdater) => { set((state) => { - const current = state.singleSearches[index] ?? { + const current = state.singleSearches[fileKey] ?? { query: '', loading: false, error: null, @@ -158,15 +179,15 @@ export const useImportWorkflowStore = create( return { singleSearches: { ...state.singleSearches, - [index]: resolveState(current, updater), + [fileKey]: resolveState(current, updater), }, }; }); }, - selectSingleMatch: (fileIndex: number, track: ImportTrackResult) => { + selectSingleMatch: (fileKey: string, track: ImportTrackResult) => { set((state) => ({ - singlesManualMatches: { ...state.singlesManualMatches, [fileIndex]: track }, - selectedSingles: new Set(state.selectedSingles).add(fileIndex), + singlesManualMatches: { ...state.singlesManualMatches, [fileKey]: track }, + selectedSingles: new Set(state.selectedSingles).add(fileKey), openSingleSearch: null, })); }, @@ -229,6 +250,7 @@ export function useSinglesImportWorkflow() { setSingleSearch: state.setSingleSearch, singleSearches: state.singleSearches, singlesManualMatches: state.singlesManualMatches, + syncSinglesWorkflow: state.syncSinglesWorkflow, toggleAllSingles: state.toggleAllSingles, toggleSingleInStore: state.toggleSingle, })), diff --git a/webui/src/routes/import/-route.test.tsx b/webui/src/routes/import/-route.test.tsx index 2750a08b..322eeef4 100644 --- a/webui/src/routes/import/-route.test.tsx +++ b/webui/src/routes/import/-route.test.tsx @@ -7,6 +7,7 @@ import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge'; import { createAppQueryClient } from '@/app/query-client'; import { AppRouterProvider, createAppRouter } from '@/app/router'; +import type { ImportStagingFile } from './-import.types'; import { resetImportWorkflowStore } from './-import.store'; function createResponse(body: unknown, status = 200) { @@ -54,9 +55,30 @@ function getFetchUrls() { describe('import route', () => { let albumMatchBodies: Record[]; + let stagingFilesPayload: ImportStagingFile[]; beforeEach(() => { albumMatchBodies = []; + stagingFilesPayload = [ + { + filename: '01-track.flac', + rel_path: 'Album/01-track.flac', + full_path: '/music/Staging/Album/01-track.flac', + title: 'Track One', + artist: 'Artist A', + album: 'Album A', + extension: '.flac', + }, + { + filename: '02-track.flac', + rel_path: 'Album/02-track.flac', + full_path: '/music/Staging/Album/02-track.flac', + title: 'Track Two', + artist: 'Artist A', + album: 'Album A', + extension: '.flac', + }, + ]; resetImportWorkflowStore(); window.SoulSyncWebShellBridge = createShellBridge(); window.showToast = vi.fn(); @@ -71,17 +93,7 @@ describe('import route', () => { return createResponse({ success: true, staging_path: '/music/Staging', - files: [ - { - filename: '01-track.flac', - rel_path: 'Album/01-track.flac', - full_path: '/music/Staging/Album/01-track.flac', - title: 'Track One', - artist: 'Artist A', - album: 'Album A', - extension: '.flac', - }, - ], + files: stagingFilesPayload, }); } @@ -243,6 +255,38 @@ describe('import route', () => { expect(await screen.findByDisplayValue('half matched album')).toBeInTheDocument(); }); + it('keeps singles selection tied to file identity across refreshes', async () => { + renderImportRoute(['/import/singles']); + + const secondTrack = await screen.findByLabelText('Select 02-track.flac'); + fireEvent.click(secondTrack); + + stagingFilesPayload = [ + { + filename: '00-intro.flac', + rel_path: 'Album/00-intro.flac', + full_path: '/music/Staging/Album/00-intro.flac', + title: 'Intro', + artist: 'Artist A', + album: 'Album A', + extension: '.flac', + }, + ...stagingFilesPayload, + ]; + + fireEvent.click(screen.getByRole('button', { name: 'Refresh' })); + + await waitFor(() => + expect((screen.getByLabelText('Select 02-track.flac') as HTMLInputElement).checked).toBe( + true, + ), + ); + expect((screen.getByLabelText('Select 01-track.flac') as HTMLInputElement).checked).toBe( + false, + ); + expect(screen.getByText('Process Selected (1)')).toBeInTheDocument(); + }); + it('preserves album source details when matching an album', async () => { renderImportRoute(); diff --git a/webui/src/routes/import/-ui/import-page.module.css b/webui/src/routes/import/-ui/import-page.module.css index b53652d5..1ac434ef 100644 --- a/webui/src/routes/import/-ui/import-page.module.css +++ b/webui/src/routes/import/-ui/import-page.module.css @@ -629,7 +629,7 @@ } .importPageSingleCheckboxWrap { - grid-area: checkbox; + grid-column: 1; position: relative; width: 18px; height: 18px; @@ -685,6 +685,7 @@ } .importPageSingleInfo { + grid-column: 2; min-width: 0; } @@ -729,6 +730,8 @@ } .importPageSingleActions { + grid-column: 3; + justify-self: end; display: flex; gap: 6px; align-items: center; @@ -1084,6 +1087,10 @@ gap: 8px; } + .importPageSingleCheckboxWrap { + grid-area: checkbox; + } + .importPageSingleInfo { grid-area: info; } diff --git a/webui/src/routes/import/-ui/singles-import-tab.tsx b/webui/src/routes/import/-ui/singles-import-tab.tsx index 2b7133b9..63885b36 100644 --- a/webui/src/routes/import/-ui/singles-import-tab.tsx +++ b/webui/src/routes/import/-ui/singles-import-tab.tsx @@ -1,10 +1,12 @@ +import { useEffect } from 'react'; + import type { SingleSearchState } from '../-import.store'; import type { ImportTrackResult } from '../-import.types'; import type { ImportStagingFile } from '../-import.types'; import styles from './import-page.module.css'; import { searchImportTracks } from '../-import.api'; -import { formatDuration } from '../-import.helpers'; +import { formatDuration, getStagingFileKey } from '../-import.helpers'; import { useSinglesImportWorkflow } from '../-import.store'; import { fallbackImage, @@ -26,32 +28,37 @@ export function SinglesImportTab() { setSingleSearch, singleSearches, singlesManualMatches, + syncSinglesWorkflow, toggleAllSingles, toggleSingleInStore, } = useSinglesImportWorkflow(); - const openSingleSearchPanel = (index: number) => { - if (openSingleSearch === index) { + useEffect(() => { + syncSinglesWorkflow(stagingFiles); + }, [stagingFiles, syncSinglesWorkflow]); + + const openSingleSearchPanel = (file: ImportStagingFile) => { + const fileKey = getStagingFileKey(file); + if (openSingleSearch === fileKey) { setOpenSingleSearch(null); return; } - setOpenSingleSearch(index); - const file = stagingFiles[index]; + setOpenSingleSearch(fileKey); const defaultQuery = [file?.artist, file?.title].filter(Boolean).join(' ') || (file?.filename || '').replace(/\.[^.]+$/, ''); - ensureSingleSearch(index, defaultQuery); - if (defaultQuery && !singleSearches[index]?.results.length) { - void runSingleSearch(index, defaultQuery); + ensureSingleSearch(fileKey, defaultQuery); + if (defaultQuery && !singleSearches[fileKey]?.results.length) { + void runSingleSearch(fileKey, defaultQuery); } }; - const runSingleSearch = async (index: number, query: string) => { + const runSingleSearch = async (fileKey: string, query: string) => { const trimmed = query.trim(); if (!trimmed) return; - setSingleSearch(index, (current) => ({ + setSingleSearch(fileKey, (current) => ({ query: trimmed, loading: true, error: null, @@ -60,14 +67,14 @@ export function SinglesImportTab() { try { const payload = await searchImportTracks(trimmed); - setSingleSearch(index, { + setSingleSearch(fileKey, { query: trimmed, loading: false, error: null, results: payload.tracks ?? [], }); } catch (error) { - setSingleSearch(index, { + setSingleSearch(fileKey, { query: trimmed, loading: false, error: getErrorMessage(error), @@ -76,16 +83,15 @@ export function SinglesImportTab() { } }; - const selectSingleMatch = (fileIndex: number, track: ImportTrackResult) => { - selectSingleMatchInStore(fileIndex, track); + const selectSingleMatch = (fileKey: string, track: ImportTrackResult) => { + selectSingleMatchInStore(fileKey, track); }; const processSingles = () => { - if (selectedSingles.size === 0) return; - const filesToProcess = Array.from(selectedSingles).flatMap((index) => { - const file = stagingFiles[index]; - if (!file) return []; - const manualMatch = singlesManualMatches[index]; + const filesToProcess = stagingFiles.flatMap((file) => { + const fileKey = getStagingFileKey(file); + if (!selectedSingles.has(fileKey)) return []; + const manualMatch = singlesManualMatches[fileKey]; return manualMatch ? [{ ...file, manual_match: manualMatch }] : [file]; }); @@ -111,21 +117,21 @@ export function SinglesImportTab() { { - setSingleSearch(index, (current) => ({ + onSearchQueryChange={(fileKey, query) => { + setSingleSearch(fileKey, (current) => ({ query, loading: current.loading, error: current.error, results: current.results, })); }} - onSelectAll={() => toggleAllSingles(stagingFiles.length)} + onSelectAll={() => toggleAllSingles(stagingFiles)} onSelectMatch={selectSingleMatch} onToggleSingle={toggleSingleInStore} /> @@ -135,7 +141,7 @@ export function SinglesImportTab() { export function SinglesImportPanel({ files, manualMatches, - openSearchIndex, + openSearchKey, searchStates, selected, onOpenSearch, @@ -147,19 +153,20 @@ export function SinglesImportPanel({ onToggleSingle, }: { files: ImportStagingFile[]; - manualMatches: Record; - openSearchIndex: number | null; - searchStates: Record; - selected: Set; - onOpenSearch: (index: number) => void; + manualMatches: Record; + openSearchKey: string | null; + searchStates: Record; + selected: Set; + onOpenSearch: (file: ImportStagingFile) => void; onProcessSingles: () => void; - onRunSearch: (index: number, query: string) => void; - onSearchQueryChange: (index: number, query: string) => void; + onRunSearch: (fileKey: string, query: string) => void; + onSearchQueryChange: (fileKey: string, query: string) => void; onSelectAll: () => void; - onSelectMatch: (fileIndex: number, track: ImportTrackResult) => void; - onToggleSingle: (index: number) => void; + onSelectMatch: (fileKey: string, track: ImportTrackResult) => void; + onToggleSingle: (fileKey: string) => void; }) { - const allSelected = files.length > 0 && selected.size === files.length; + const selectedCount = files.filter((file) => selected.has(getStagingFileKey(file))).length; + const allSelected = files.length > 0 && selectedCount === files.length; return ( <> @@ -174,28 +181,29 @@ export function SinglesImportPanel({ type="button" className={styles.importPageProcessBtn} id="import-page-singles-process-btn" - disabled={selected.size === 0} + disabled={selectedCount === 0} onClick={onProcessSingles} > - Process Selected ({selected.size}) + Process Selected ({selectedCount}) -
+
{files.length === 0 ? (
No audio files found in import folder
) : ( - files.map((file, index) => { - const manualMatch = manualMatches[index]; - const isSelected = selected.has(index); - const searchState = searchStates[index]; + files.map((file) => { + const fileKey = getStagingFileKey(file); + const manualMatch = manualMatches[fileKey]; + const isSelected = selected.has(fileKey); + const searchState = searchStates[fileKey]; return (
@@ -220,7 +228,7 @@ export function SinglesImportPanel({ @@ -231,14 +239,14 @@ export function SinglesImportPanel({
- {openSearchIndex === index ? ( + {openSearchKey === fileKey ? ( void; - onRunSearch: (index: number, query: string) => void; - onSelectMatch: (fileIndex: number, track: ImportTrackResult) => void; + onQueryChange: (fileKey: string, query: string) => void; + onRunSearch: (fileKey: string, query: string) => void; + onSelectMatch: (fileKey: string, track: ImportTrackResult) => void; }) { const query = searchState?.query ?? ''; @@ -277,20 +285,20 @@ function SingleSearchPanel({ className={styles.importPageSingleSearchInput} value={query} placeholder="Search artist - title..." - onChange={(event) => onQueryChange(fileIndex, event.target.value)} + onChange={(event) => onQueryChange(fileKey, event.target.value)} onKeyDown={(event) => { - if (event.key === 'Enter') onRunSearch(fileIndex, query); + if (event.key === 'Enter') onRunSearch(fileKey, query); }} />
-
+
{searchState?.loading ? (
Searching...
) : searchState?.error ? ( @@ -303,7 +311,7 @@ function SingleSearchPanel({ key={`${track.source || 'source'}-${track.id}-${index}`} type="button" className={styles.importPageSingleResultItem} - onClick={() => onSelectMatch(fileIndex, track)} + onClick={() => onSelectMatch(fileKey, track)} > {track.image_url ? (