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