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>
188 lines
5.4 KiB
TypeScript
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();
|
|
}
|
|
}
|