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
pull/686/head
Antti Kettunen 1 week ago
parent 89252cf6e4
commit aa86bedc6e
No known key found for this signature in database
GPG Key ID: C6B2A3D250359BD7

@ -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`;

@ -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<number, number>,
selectedSingles: new Set<number>(),
singlesManualMatches: {} as Record<number, ImportTrackResult>,
openSingleSearch: null as number | null,
singleSearches: {} as Record<number, SingleSearchState>,
selectedSingles: new Set<string>(),
singlesManualMatches: {} as Record<string, ImportTrackResult>,
openSingleSearch: null as string | null,
singleSearches: {} as Record<string, SingleSearchState>,
};
}
@ -111,34 +113,53 @@ export const useImportWorkflowStore = create(
setMatchOverrides: (updater: StateUpdater<Record<number, number>>) => {
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<number>()
: 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<string>()
: new Set(fileKeys);
})(),
}));
},
clearSinglesSelection: () => {
set({
selectedSingles: new Set<number>(),
selectedSingles: new Set<string>(),
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<SingleSearchState>) => {
setSingleSearch: (fileKey: string, updater: StateUpdater<SingleSearchState>) => {
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,
})),

@ -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<string, unknown>[];
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();

@ -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;
}

@ -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() {
<SinglesImportPanel
files={stagingFiles}
manualMatches={singlesManualMatches}
openSearchIndex={openSingleSearch}
openSearchKey={openSingleSearch}
searchStates={singleSearches}
selected={selectedSingles}
onOpenSearch={openSingleSearchPanel}
onProcessSingles={processSingles}
onRunSearch={runSingleSearch}
onSearchQueryChange={(index, query) => {
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<number, ImportTrackResult>;
openSearchIndex: number | null;
searchStates: Record<number, SingleSearchState>;
selected: Set<number>;
onOpenSearch: (index: number) => void;
manualMatches: Record<string, ImportTrackResult>;
openSearchKey: string | null;
searchStates: Record<string, SingleSearchState>;
selected: Set<string>;
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})
</button>
</div>
</div>
<div className={styles.importPageSinglesList} id="import-page-singles-list">
<div className={styles.importPageSinglesList} id="import-page-singles-list">
{files.length === 0 ? (
<div className={styles.importPageEmptyState}>No audio files found in import folder</div>
) : (
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 (
<div
key={`${file.full_path}-${index}`}
key={fileKey}
className={`${styles.importPageSingleItem} ${
manualMatch ? styles.matched : ''
}`}
data-single-idx={index}
data-single-key={fileKey}
>
<label className={styles.importPageSingleCheckboxWrap}>
<input
@ -203,7 +211,7 @@ export function SinglesImportPanel({
aria-label={`Select ${file.filename}`}
className={styles.importPageSingleCheckboxInput}
checked={isSelected}
onChange={() => onToggleSingle(index)}
onChange={() => onToggleSingle(fileKey)}
/>
<span className={styles.importPageSingleCheckbox} aria-hidden="true" />
</label>
@ -220,7 +228,7 @@ export function SinglesImportPanel({
<button
type="button"
className={styles.importPageSingleMatchedChange}
onClick={() => onOpenSearch(index)}
onClick={() => onOpenSearch(file)}
>
change
</button>
@ -231,14 +239,14 @@ export function SinglesImportPanel({
<button
type="button"
className={styles.importPageIdentifyBtn}
onClick={() => onOpenSearch(index)}
onClick={() => onOpenSearch(file)}
>
🔍 Identify
</button>
</div>
{openSearchIndex === index ? (
{openSearchKey === fileKey ? (
<SingleSearchPanel
fileIndex={index}
fileKey={fileKey}
searchState={searchState}
onQueryChange={onSearchQueryChange}
onRunSearch={onRunSearch}
@ -255,17 +263,17 @@ export function SinglesImportPanel({
}
function SingleSearchPanel({
fileIndex,
fileKey,
searchState,
onQueryChange,
onRunSearch,
onSelectMatch,
}: {
fileIndex: number;
fileKey: string;
searchState: SingleSearchState | undefined;
onQueryChange: (index: number, query: string) => 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);
}}
/>
<button
type="button"
className={styles.importPageSingleSearchGo}
onClick={() => onRunSearch(fileIndex, query)}
onClick={() => onRunSearch(fileKey, query)}
>
Search
</button>
</div>
<div className={styles.importPageSingleSearchResults} id={`import-single-results-${fileIndex}`}>
<div className={styles.importPageSingleSearchResults}>
{searchState?.loading ? (
<div className={styles.importPageEmptyState}>Searching...</div>
) : 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 ? (
<img

Loading…
Cancel
Save