From b8546eb2495ce4bde020791bbbec613e7d8b8eb0 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Wed, 15 Jul 2026 10:58:50 +0200 Subject: [PATCH] Vault import: always show the label section, create labels inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The label multiselect was gated on the pond already having labels — but before a first import that is the common case, so the section silently vanished and no import-wide label could be chosen. Render the fieldset unconditionally (with a hint when empty) and add an inline create field: POST the new label directly to get its id back, refresh the shared label query, and tick it right away. Same pond_admin permission as the dialog itself. The e2e pack now creates its label through the dialog instead of the API, covering exactly the empty-pond path that slipped through. Fixes #121 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fb2VzvcoBPHkjh8bZ6PzQn --- apps/web/e2e/import-vault.spec.ts | 19 +++--- apps/web/src/import/VaultImportSection.tsx | 71 ++++++++++++++++++++-- apps/web/src/styles/base.css | 18 ++++++ packages/shared/i18n/de/import.json | 3 + packages/shared/i18n/en/import.json | 3 + 5 files changed, 97 insertions(+), 17 deletions(-) diff --git a/apps/web/e2e/import-vault.spec.ts b/apps/web/e2e/import-vault.spec.ts index 546bc2b..d0d05f8 100644 --- a/apps/web/e2e/import-vault.spec.ts +++ b/apps/web/e2e/import-vault.spec.ts @@ -50,11 +50,7 @@ test('a pond admin imports an Obsidian vault through the settings dialog', async `/api/v1/ponds/${pond.id}/pages`, { title: `Vault Mount ${ts}` }, ); - const label = await json<{ id: string; name: string }>( - context, - `/api/v1/ponds/${pond.id}/labels`, - { name: `import-${ts}` }, - ); + const labelName = `import-${ts}`; const page = await context.newPage(); await page.goto(`/p/${pond.slug}/settings`); @@ -72,15 +68,16 @@ test('a pond admin imports an Obsidian vault through the settings dialog', async mimeType: 'application/zip', buffer: fixtureZip(), }); - // Mount under the prepared page and tag everything with the extra label. + // Mount under the prepared page. The pond has no labels yet — the label + // section must still be there (#121) and let the admin create one inline; + // the fresh label comes back pre-ticked. await dialog .locator('li', { hasText: `Vault Mount ${ts}` }) .locator('input') .check(); - await dialog - .locator('li', { hasText: `import-${ts}` }) - .locator('input') - .check(); + await dialog.locator('.vault-import-dialog__label-create input').fill(labelName); + await dialog.locator('.vault-import-dialog__label-create-submit').click(); + await expect(dialog.locator('li', { hasText: labelName }).locator('input')).toBeChecked(); await dialog.locator('.vault-import-dialog__start').click(); // The job runs; the dialog offers the way into the imported pages. @@ -111,7 +108,7 @@ test('a pond admin imports an Obsidian vault through the settings dialog', async const tree = JSON.stringify(await labels.json()); expect(tree).toContain('status'); expect(tree).toContain('aktiv'); - expect(tree).toContain(label.name); + expect(tree).toContain(labelName); await context.close(); }); diff --git a/apps/web/src/import/VaultImportSection.tsx b/apps/web/src/import/VaultImportSection.tsx index 27cfcde..acc87f2 100644 --- a/apps/web/src/import/VaultImportSection.tsx +++ b/apps/web/src/import/VaultImportSection.tsx @@ -1,5 +1,6 @@ import type { ConversionJobView, + LabelView, PageListItemView, PageView, PondView, @@ -14,7 +15,7 @@ import { Link } from 'react-router-dom'; import { FormError } from '../components/forms'; import { labelsKey, usePondLabels } from '../labels/use-pond-labels'; -import { apiGet, apiUploadFile } from '../lib/api'; +import { ApiError, apiGet, apiPost, apiUploadFile } from '../lib/api'; import { useDismissable } from '../lib/use-dismissable'; const POLL_INTERVAL_MS = 1000; @@ -77,6 +78,8 @@ function VaultImportDialog({ const [file, setFile] = useState(null); const [parentId, setParentId] = useState(null); const [labelIds, setLabelIds] = useState>(new Set()); + const [newLabelName, setNewLabelName] = useState(''); + const [labelError, setLabelError] = useState(null); const [frontmatter, setFrontmatter] = useState('strip'); const running = phase.kind === 'running'; useDismissable(dialogRef, !running, onClose); @@ -111,6 +114,31 @@ function VaultImportDialog({ }); } + /** + * Create a label right in the dialog and tick it (#121): before the first + * import a pond usually has no labels at all, so the multiselect alone + * would stay empty. POST directly (instead of useLabelMutations) to get + * the new id back for auto-selection. Same pond_admin permission as the + * dialog itself. + */ + async function createLabel(): Promise { + const name = newLabelName.trim(); + if (!name) return; + setLabelError(null); + try { + const label = await apiPost(`/ponds/${pondId}/labels`, { name }); + await queryClient.invalidateQueries({ queryKey: labelsKey(pondId) }); + toggleLabel(label.id, true); + setNewLabelName(''); + } catch (err) { + setLabelError( + err instanceof ApiError + ? tErrors(err.body.code, { defaultValue: err.body.message, ...(err.body.details ?? {}) }) + : tErrors('internal_error'), + ); + } + } + async function start(): Promise { if (!file) return; setError(null); @@ -230,9 +258,11 @@ function VaultImportDialog({ - {flat.length > 0 && ( -
- {t('vault.labels')} +
+ {t('vault.labels')} + {flat.length === 0 ? ( +

{t('vault.labelsEmpty')}

+ ) : (
    {flat.map((label) => (
  • ))}
-
- )} + )} +
+ setNewLabelName(event.target.value)} + onKeyDown={(event) => { + // The dialog holds no
; Enter should create, not submit. + if (event.key === 'Enter') { + event.preventDefault(); + void createLabel(); + } + }} + /> + +
+ {labelError && ( +

+ {labelError} +

+ )} +
{t('vault.frontmatter')} diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 62eb6a8..ec884b3 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -1853,6 +1853,24 @@ button { max-height: 12rem; } +.vault-import-dialog__labels-empty { + color: var(--color-text-muted); + font-size: 0.9rem; + margin: 0 0 var(--space-1); +} + +/* Inline label creation (#121): input + button side by side. */ +.vault-import-dialog__label-create { + display: flex; + gap: var(--space-2); + margin-top: var(--space-1); +} + +.vault-import-dialog__label-create input { + flex: 1; + min-width: 0; +} + /* "Create this page" on the not-found screen (issue #115). */ .create-missing-page { margin-top: var(--space-3); diff --git a/packages/shared/i18n/de/import.json b/packages/shared/i18n/de/import.json index 52665d9..eac88cc 100644 --- a/packages/shared/i18n/de/import.json +++ b/packages/shared/i18n/de/import.json @@ -19,6 +19,9 @@ "parent": "Einhängen unter", "parentRoot": "Oberste Ebene", "labels": "Zusätzliche Labels für alle importierten Seiten", + "labelsEmpty": "Dieser Teich hat noch keine Labels — du kannst hier direkt eins anlegen.", + "labelName": "Name des neuen Labels", + "labelCreate": "Label anlegen", "frontmatter": "YAML-Frontmatter", "frontmatterStrip": "Entfernen", "frontmatterPreserve": "Als Code-Block erhalten", diff --git a/packages/shared/i18n/en/import.json b/packages/shared/i18n/en/import.json index 90d0b06..a7c7e11 100644 --- a/packages/shared/i18n/en/import.json +++ b/packages/shared/i18n/en/import.json @@ -19,6 +19,9 @@ "parent": "Mount under", "parentRoot": "Top level", "labels": "Additional labels for every imported page", + "labelsEmpty": "This pond has no labels yet — you can create one right here.", + "labelName": "Name of the new label", + "labelCreate": "Create label", "frontmatter": "YAML frontmatter", "frontmatterStrip": "Remove it", "frontmatterPreserve": "Keep it as a code block",