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