260 lines
7.9 KiB
TypeScript
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();
|
|
}
|
|
}
|