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 { computeVisibility } from '../utils/condition-engine'; 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 fieldContainers: 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 fieldWrapper = fieldsEl.createDiv({ cls: 'ff-field-wrapper' }); const initial = existingFrontmatter[field.id] as FieldValue | undefined; const defaultVal = field.defaultValue as FieldValue | undefined; const rendered = renderField( this.app, fieldWrapper, field, initial ?? defaultVal, ); this.renderedFields.set(field.id, rendered); this.fieldContainers.set(field.id, fieldWrapper); } // Attach change listeners for reactivity this.attachChangeListeners(fieldsEl); // Initial visibility evaluation this.reevaluateVisibility(); // 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(); }); // Keyboard navigation contentEl.addEventListener('keydown', (e: KeyboardEvent) => { // Ctrl+Enter always submits if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); this.handleSubmit(); return; } // Enter on single-line inputs submits (unless in suggest dropdown) if (e.key === 'Enter' && !e.shiftKey) { const target = e.target as HTMLElement; if ( target instanceof HTMLInputElement && target.type !== 'textarea' && !target.closest('.ff-suggest-wrapper') ) { e.preventDefault(); this.handleSubmit(); } } }); } private reevaluateVisibility(): void { const values = this.collectValues(); const visibility = computeVisibility(this.form.fields, values); for (const field of this.form.fields) { const container = this.fieldContainers.get(field.id); if (!container) continue; const isVisible = visibility.get(field.id) !== false; container.style.display = isVisible ? '' : 'none'; } } private collectValues(): Record { const values: Record = {}; for (const field of this.form.fields) { const rendered = this.renderedFields.get(field.id); if (rendered) { values[field.id] = rendered.getValue(); } } return values; } private attachChangeListeners(container: HTMLElement): void { container.addEventListener('input', () => this.reevaluateVisibility()); container.addEventListener('change', () => this.reevaluateVisibility()); container.addEventListener('click', () => { // Defer to let toggle/rating state update first requestAnimationFrame(() => this.reevaluateVisibility()); }); } private async handleSubmit(): Promise { // Collect values (only from visible fields) const allValues = this.collectValues(); const visibility = computeVisibility(this.form.fields, allValues); const values: Record = {}; for (const field of this.form.fields) { if (visibility.get(field.id) !== false) { values[field.id] = allValues[field.id]; } } // Clear previous errors for (const rendered of this.renderedFields.values()) { rendered.setError(null); } // Validate const visibleFields = this.form.fields.filter( (f) => visibility.get(f.id) !== false, ); const errors: ValidationError[] = validateForm(visibleFields, 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(); this.fieldContainers.clear(); } }