Vault import: always show the label section, create labels inline

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fb2VzvcoBPHkjh8bZ6PzQn
This commit is contained in:
Claude Fable 5 2026-07-15 10:58:50 +02:00
parent e356d82d51
commit b8546eb249
5 changed files with 97 additions and 17 deletions

View File

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

View File

@ -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<File | null>(null);
const [parentId, setParentId] = useState<string | null>(null);
const [labelIds, setLabelIds] = useState<Set<string>>(new Set());
const [newLabelName, setNewLabelName] = useState('');
const [labelError, setLabelError] = useState<string | null>(null);
const [frontmatter, setFrontmatter] = useState<VaultFrontmatterMode>('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<void> {
const name = newLabelName.trim();
if (!name) return;
setLabelError(null);
try {
const label = await apiPost<LabelView>(`/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<void> {
if (!file) return;
setError(null);
@ -230,9 +258,11 @@ function VaultImportDialog({
</ul>
</fieldset>
{flat.length > 0 && (
<fieldset className="vault-import-dialog__field" disabled={running}>
<legend>{t('vault.labels')}</legend>
{flat.length === 0 ? (
<p className="vault-import-dialog__labels-empty">{t('vault.labelsEmpty')}</p>
) : (
<ul className="move-dialog__options">
{flat.map((label) => (
<li
@ -255,8 +285,37 @@ function VaultImportDialog({
</li>
))}
</ul>
</fieldset>
)}
<div className="vault-import-dialog__label-create">
<input
type="text"
value={newLabelName}
placeholder={t('vault.labelName')}
aria-label={t('vault.labelName')}
onChange={(event) => setNewLabelName(event.target.value)}
onKeyDown={(event) => {
// The dialog holds no <form>; Enter should create, not submit.
if (event.key === 'Enter') {
event.preventDefault();
void createLabel();
}
}}
/>
<button
type="button"
className="button vault-import-dialog__label-create-submit"
disabled={!newLabelName.trim()}
onClick={() => void createLabel()}
>
{t('vault.labelCreate')}
</button>
</div>
{labelError && (
<p className="form-banner form-banner--error" role="alert">
{labelError}
</p>
)}
</fieldset>
<fieldset className="vault-import-dialog__field" disabled={running}>
<legend>{t('vault.frontmatter')}</legend>

View File

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

View File

@ -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",

View File

@ -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",