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:
parent
e356d82d51
commit
b8546eb249
@ -50,11 +50,7 @@ test('a pond admin imports an Obsidian vault through the settings dialog', async
|
|||||||
`/api/v1/ponds/${pond.id}/pages`,
|
`/api/v1/ponds/${pond.id}/pages`,
|
||||||
{ title: `Vault Mount ${ts}` },
|
{ title: `Vault Mount ${ts}` },
|
||||||
);
|
);
|
||||||
const label = await json<{ id: string; name: string }>(
|
const labelName = `import-${ts}`;
|
||||||
context,
|
|
||||||
`/api/v1/ponds/${pond.id}/labels`,
|
|
||||||
{ name: `import-${ts}` },
|
|
||||||
);
|
|
||||||
|
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await page.goto(`/p/${pond.slug}/settings`);
|
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',
|
mimeType: 'application/zip',
|
||||||
buffer: fixtureZip(),
|
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
|
await dialog
|
||||||
.locator('li', { hasText: `Vault Mount ${ts}` })
|
.locator('li', { hasText: `Vault Mount ${ts}` })
|
||||||
.locator('input')
|
.locator('input')
|
||||||
.check();
|
.check();
|
||||||
await dialog
|
await dialog.locator('.vault-import-dialog__label-create input').fill(labelName);
|
||||||
.locator('li', { hasText: `import-${ts}` })
|
await dialog.locator('.vault-import-dialog__label-create-submit').click();
|
||||||
.locator('input')
|
await expect(dialog.locator('li', { hasText: labelName }).locator('input')).toBeChecked();
|
||||||
.check();
|
|
||||||
await dialog.locator('.vault-import-dialog__start').click();
|
await dialog.locator('.vault-import-dialog__start').click();
|
||||||
|
|
||||||
// The job runs; the dialog offers the way into the imported pages.
|
// 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());
|
const tree = JSON.stringify(await labels.json());
|
||||||
expect(tree).toContain('status');
|
expect(tree).toContain('status');
|
||||||
expect(tree).toContain('aktiv');
|
expect(tree).toContain('aktiv');
|
||||||
expect(tree).toContain(label.name);
|
expect(tree).toContain(labelName);
|
||||||
|
|
||||||
await context.close();
|
await context.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
ConversionJobView,
|
ConversionJobView,
|
||||||
|
LabelView,
|
||||||
PageListItemView,
|
PageListItemView,
|
||||||
PageView,
|
PageView,
|
||||||
PondView,
|
PondView,
|
||||||
@ -14,7 +15,7 @@ import { Link } from 'react-router-dom';
|
|||||||
|
|
||||||
import { FormError } from '../components/forms';
|
import { FormError } from '../components/forms';
|
||||||
import { labelsKey, usePondLabels } from '../labels/use-pond-labels';
|
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';
|
import { useDismissable } from '../lib/use-dismissable';
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 1000;
|
const POLL_INTERVAL_MS = 1000;
|
||||||
@ -77,6 +78,8 @@ function VaultImportDialog({
|
|||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [parentId, setParentId] = useState<string | null>(null);
|
const [parentId, setParentId] = useState<string | null>(null);
|
||||||
const [labelIds, setLabelIds] = useState<Set<string>>(new Set());
|
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 [frontmatter, setFrontmatter] = useState<VaultFrontmatterMode>('strip');
|
||||||
const running = phase.kind === 'running';
|
const running = phase.kind === 'running';
|
||||||
useDismissable(dialogRef, !running, onClose);
|
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> {
|
async function start(): Promise<void> {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -230,9 +258,11 @@ function VaultImportDialog({
|
|||||||
</ul>
|
</ul>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
{flat.length > 0 && (
|
|
||||||
<fieldset className="vault-import-dialog__field" disabled={running}>
|
<fieldset className="vault-import-dialog__field" disabled={running}>
|
||||||
<legend>{t('vault.labels')}</legend>
|
<legend>{t('vault.labels')}</legend>
|
||||||
|
{flat.length === 0 ? (
|
||||||
|
<p className="vault-import-dialog__labels-empty">{t('vault.labelsEmpty')}</p>
|
||||||
|
) : (
|
||||||
<ul className="move-dialog__options">
|
<ul className="move-dialog__options">
|
||||||
{flat.map((label) => (
|
{flat.map((label) => (
|
||||||
<li
|
<li
|
||||||
@ -255,8 +285,37 @@ function VaultImportDialog({
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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}>
|
<fieldset className="vault-import-dialog__field" disabled={running}>
|
||||||
<legend>{t('vault.frontmatter')}</legend>
|
<legend>{t('vault.frontmatter')}</legend>
|
||||||
|
|||||||
@ -1853,6 +1853,24 @@ button {
|
|||||||
max-height: 12rem;
|
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 this page" on the not-found screen (issue #115). */
|
||||||
.create-missing-page {
|
.create-missing-page {
|
||||||
margin-top: var(--space-3);
|
margin-top: var(--space-3);
|
||||||
|
|||||||
@ -19,6 +19,9 @@
|
|||||||
"parent": "Einhängen unter",
|
"parent": "Einhängen unter",
|
||||||
"parentRoot": "Oberste Ebene",
|
"parentRoot": "Oberste Ebene",
|
||||||
"labels": "Zusätzliche Labels für alle importierten Seiten",
|
"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",
|
"frontmatter": "YAML-Frontmatter",
|
||||||
"frontmatterStrip": "Entfernen",
|
"frontmatterStrip": "Entfernen",
|
||||||
"frontmatterPreserve": "Als Code-Block erhalten",
|
"frontmatterPreserve": "Als Code-Block erhalten",
|
||||||
|
|||||||
@ -19,6 +19,9 @@
|
|||||||
"parent": "Mount under",
|
"parent": "Mount under",
|
||||||
"parentRoot": "Top level",
|
"parentRoot": "Top level",
|
||||||
"labels": "Additional labels for every imported page",
|
"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",
|
"frontmatter": "YAML frontmatter",
|
||||||
"frontmatterStrip": "Remove it",
|
"frontmatterStrip": "Remove it",
|
||||||
"frontmatterPreserve": "Keep it as a code block",
|
"frontmatterPreserve": "Keep it as a code block",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user