From f3207a8b64979d00d1e541bf85899cb43f5f5c68 Mon Sep 17 00:00:00 2001 From: tolvitty Date: Fri, 13 Feb 2026 13:22:00 +0100 Subject: [PATCH] feat: add FormModal for rendering and submitting forms Modal that renders all form fields, validates on submit with error display and shake animation, and processes via FormProcessor. Supports create and update modes with file picker for prompted target files. Co-Authored-By: Claude Opus 4.6 --- src/ui/form-modal.ts | 188 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 src/ui/form-modal.ts diff --git a/src/ui/form-modal.ts b/src/ui/form-modal.ts new file mode 100644 index 0000000..75dffa3 --- /dev/null +++ b/src/ui/form-modal.ts @@ -0,0 +1,188 @@ +import { App, Modal, TFile, Notice, FuzzySuggestModal } from 'obsidian'; +import { FormDefinition } from '../types'; +import { renderField, RenderedField, FieldValue } from './field-renderers'; +import { validateForm, ValidationError } from '../utils/validators'; +import { FormProcessor } from '../core/form-processor'; + +// --------------------------------------------------------------------------- +// FilePickerModal — FuzzySuggestModal for choosing a target file +// --------------------------------------------------------------------------- + +export class FilePickerModal extends FuzzySuggestModal { + private onChoose: (file: TFile) => void; + + constructor(app: App, onChoose: (file: TFile) => void) { + super(app); + this.onChoose = onChoose; + this.setPlaceholder('Pick a file...'); + } + + getItems(): TFile[] { + return this.app.vault.getMarkdownFiles(); + } + + getItemText(item: TFile): string { + return item.path; + } + + onChooseItem(item: TFile): void { + this.onChoose(item); + } +} + +// --------------------------------------------------------------------------- +// FormModal — renders a form and processes submission +// --------------------------------------------------------------------------- + +export class FormModal extends Modal { + private form: FormDefinition; + private renderedFields: Map = new Map(); + private targetFile: TFile | undefined; + + constructor(app: App, form: FormDefinition, targetFile?: TFile) { + super(app); + this.form = form; + this.targetFile = targetFile; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.addClass('ff-form-modal'); + contentEl.empty(); + + // If update mode with prompt and no target file yet, open file picker first + if ( + this.form.mode === 'update' && + this.form.targetFile === 'prompt' && + !this.targetFile + ) { + this.close(); + new FilePickerModal(this.app, (file: TFile) => { + new FormModal(this.app, this.form, file).open(); + }).open(); + return; + } + + // If update mode with active file, grab active file + if ( + this.form.mode === 'update' && + this.form.targetFile === 'active' && + !this.targetFile + ) { + const active = this.app.workspace.getActiveFile(); + if (!active) { + new Notice('No active file to update.'); + this.close(); + return; + } + this.targetFile = active; + } + + this.renderForm(); + } + + private renderForm(): void { + const { contentEl } = this; + + // Header + contentEl.createEl('h2', { text: this.form.name, cls: 'ff-form-title' }); + + // If updating a specific file, show which one + if (this.form.mode === 'update' && this.targetFile) { + contentEl.createDiv({ + cls: 'ff-form-target', + text: `Updating: ${this.targetFile.path}`, + }); + } + + // Fields container + const fieldsEl = contentEl.createDiv({ cls: 'ff-fields' }); + + // Get existing frontmatter for pre-fill (update mode) + let existingFrontmatter: Record = {}; + if (this.form.mode === 'update' && this.targetFile) { + const cache = this.app.metadataCache.getFileCache(this.targetFile); + if (cache?.frontmatter) { + existingFrontmatter = { ...cache.frontmatter }; + } + } + + // Render each field + for (const field of this.form.fields) { + const initial = existingFrontmatter[field.id] as FieldValue | undefined; + const defaultVal = field.defaultValue as FieldValue | undefined; + const rendered = renderField( + this.app, + fieldsEl, + field, + initial ?? defaultVal, + ); + this.renderedFields.set(field.id, rendered); + } + + // Footer + const footerEl = contentEl.createDiv({ cls: 'ff-form-footer' }); + const submitText = + this.form.mode === 'create' ? 'Create Note' : 'Update Frontmatter'; + const submitBtn = footerEl.createEl('button', { + text: submitText, + cls: 'mod-cta ff-submit-btn', + }); + + submitBtn.addEventListener('click', () => { + this.handleSubmit(); + }); + } + + private async handleSubmit(): Promise { + // Collect values + const values: Record = {}; + for (const field of this.form.fields) { + const rendered = this.renderedFields.get(field.id); + if (rendered) { + values[field.id] = rendered.getValue(); + } + } + + // Clear previous errors + for (const rendered of this.renderedFields.values()) { + rendered.setError(null); + } + + // Validate + const errors: ValidationError[] = validateForm(this.form.fields, values); + if (errors.length > 0) { + for (const err of errors) { + const rendered = this.renderedFields.get(err.fieldId); + if (rendered) { + rendered.setError(err.message); + } + } + // Shake the modal to indicate validation failure + this.contentEl.addClass('ff-shake'); + setTimeout(() => this.contentEl.removeClass('ff-shake'), 400); + return; + } + + // Process + try { + const processor = new FormProcessor(this.app); + const file = await processor.process(this.form, values, this.targetFile); + this.close(); + + // Open the created/updated file + if (this.form.mode === 'create') { + await this.app.workspace.getLeaf(false).openFile(file); + } + } catch (err) { + new Notice( + `Formfire error: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + onClose(): void { + this.contentEl.empty(); + this.renderedFields.clear(); + } +}