obsidian-formfire/src/ui/form-modal.ts
tolvitty bfe4dc6d5e feat: add reactive conditional visibility to form modal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 20:37:12 +01:00

260 lines
7.9 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 { computeVisibility } from '../utils/condition-engine';
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 fieldContainers: Map<string, HTMLElement> = 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 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<string, unknown> {
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();
}
}
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<void> {
// Collect values (only from visible fields)
const allValues = this.collectValues();
const visibility = computeVisibility(this.form.fields, allValues);
const values: Record<string, unknown> = {};
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();
}
}