feat: bulk metadata edit for existing documents #331

Merged
marcel merged 23 commits from feat/issue-225-bulk-metadata-edit into main 2026-04-25 19:27:53 +02:00
4 changed files with 39 additions and 14 deletions
Showing only changes of commit 499beca124 - Show all commits

View File

@@ -134,6 +134,16 @@ async function handleDiscard() {
});
if (!ok) return;
}
if (mode === 'edit') {
// In edit mode the file map IS the user's bulk selection — discarding
// must clear the upstream store and bounce back to the list, otherwise
// the user is left on /documents/bulk-edit with an empty form and a
// stale count in the bottom bar (issue #225 Bulk-Edit Panel table).
bulkSelectionStore.clear();
discardAll();
await goto('/documents');
return;
}
discardAll();
}

View File

@@ -315,6 +315,30 @@ describe('BulkDocumentEditLayout', () => {
// ─── mode="edit" ─────────────────────────────────────────────────────────────
describe('BulkDocumentEditLayout — mode="edit" discard', () => {
it('discard in edit mode clears the selection store and navigates back to /documents', async () => {
const { bulkSelectionStore } = await import('$lib/stores/bulkSelection.svelte');
bulkSelectionStore.setAll(['doc-1']);
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [
{ id: 'doc-1', title: 'Brief 1', pdfUrl: '/api/documents/doc-1/file' },
{ id: 'doc-2', title: 'Brief 2', pdfUrl: '/api/documents/doc-2/file' }
]
});
const discardBtn = container.querySelector(
'button[data-testid="discard-all-btn"]'
) as HTMLButtonElement;
expect(discardBtn).not.toBeNull();
discardBtn.click();
await vi.waitFor(() => expect(goto).toHaveBeenCalledWith('/documents'), { timeout: 1000 });
expect(bulkSelectionStore.size).toBe(0);
});
});
describe('BulkDocumentEditLayout — mode="edit"', () => {
const editEntry = (i: number) => ({
id: `doc-${i}`,

View File

@@ -11,7 +11,6 @@ let {
archiveBox = $bindable(''),
archiveFolder = $bindable(''),
initialTitle = '',
initialDocumentLocation = '',
initialSummary = '',
titleRequired = false,
suggestedTitle = '',
@@ -24,7 +23,6 @@ let {
archiveBox?: string;
archiveFolder?: string;
initialTitle?: string;
initialDocumentLocation?: string;
initialSummary?: string;
titleRequired?: boolean;
suggestedTitle?: string;
@@ -32,14 +30,11 @@ let {
editMode?: boolean;
} = $props();
// currentTitle seeds from initialTitle once at mount; subsequent edits flow
// through the oninput handler that flips titleDirty.
let titleDirty = $state(false);
currentTitle = untrack(() => initialTitle);
const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || currentTitle);
// Initialize controlled location field once from the legacy initial-* props so
// callers that haven't switched to the bindable form keep their existing
// pre-fill behaviour.
documentLocation = untrack(() => documentLocation || initialDocumentLocation);
</script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">

View File

@@ -13,8 +13,6 @@ let {
senderId = $bindable(''),
selectedReceivers = $bindable<Person[]>([]),
dateIso = $bindable(''),
initialDateIso = '',
initialLocation = '',
initialSenderName = '',
suggestedDateIso = '',
suggestedSenderName = '',
@@ -24,8 +22,6 @@ let {
senderId?: string;
selectedReceivers?: Person[];
dateIso?: string;
initialDateIso?: string;
initialLocation?: string;
initialSenderName?: string;
suggestedDateIso?: string;
suggestedSenderName?: string;
@@ -33,8 +29,9 @@ let {
editMode?: boolean;
} = $props();
let dateDisplay = $state(untrack(() => isoToGerman(initialDateIso)));
dateIso = untrack(() => initialDateIso);
// Seed dateDisplay from the bindable's current value once at mount; subsequent
// edits flow through handleDateInput which writes back to dateIso.
let dateDisplay = $state(untrack(() => isoToGerman(dateIso)));
let dateDirty = $state(false);
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
@@ -122,7 +119,6 @@ $effect(() => {
id="location"
type="text"
name="location"
value={initialLocation}
placeholder={m.form_placeholder_location()}
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>