obsidian-formfire/src/ui/form-modal.ts
tolvitty f3207a8b64 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 <noreply@anthropic.com>
2026-02-13 13:22:00 +01:00

188 lines
5.4 KiB
TypeScript

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<TFile> {
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<string, RenderedField> = 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<string, unknown> = {};
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<void> {
// Collect values
const values: Record<string, unknown> = {};
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();
}
}