diff --git a/src/ui/form-builder.ts b/src/ui/form-builder.ts new file mode 100644 index 0000000..d242126 --- /dev/null +++ b/src/ui/form-builder.ts @@ -0,0 +1,355 @@ +import { App, Modal, Notice } from 'obsidian'; +import { FormDefinition, FormField, FieldType } from '../types'; + +const FIELD_TYPES: FieldType[] = [ + 'text', + 'textarea', + 'number', + 'toggle', + 'date', + 'dropdown', + 'tags', + 'note-link', + 'folder-picker', + 'rating', +]; + +/** + * Modal for visually editing a FormDefinition. + * Works on a structuredClone draft and only calls onSave on confirmation. + */ +export class FormBuilderModal extends Modal { + private draft: FormDefinition; + private onSave: (form: FormDefinition) => void; + + constructor( + app: App, + form: FormDefinition, + onSave: (form: FormDefinition) => void, + ) { + super(app); + this.draft = structuredClone(form); + this.onSave = onSave; + } + + onOpen(): void { + this.render(); + } + + onClose(): void { + this.contentEl.empty(); + } + + private render(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('ff-builder-modal'); + + // --- General settings --- + contentEl.createEl('h2', { text: 'Form Settings' }); + + const generalEl = contentEl.createDiv({ cls: 'ff-builder-general' }); + + // Name + this.addTextSetting(generalEl, 'Form Name', this.draft.name, (v) => { + this.draft.name = v; + }); + + // Mode + this.addDropdownSetting( + generalEl, + 'Mode', + ['create', 'update'], + this.draft.mode, + (v) => { + this.draft.mode = v as 'create' | 'update'; + this.render(); // Re-render to show mode-specific settings + }, + ); + + // Mode-specific settings + if (this.draft.mode === 'create') { + this.addTextSetting( + generalEl, + 'Target Folder', + this.draft.targetFolder ?? '/', + (v) => { + this.draft.targetFolder = v; + }, + ); + this.addTextSetting( + generalEl, + 'File Name Template', + this.draft.fileNameTemplate ?? '{{date}}-{{title}}', + (v) => { + this.draft.fileNameTemplate = v; + }, + ); + this.addTextareaSetting( + generalEl, + 'Body Template', + this.draft.bodyTemplate ?? '', + (v) => { + this.draft.bodyTemplate = v; + }, + ); + } else { + this.addDropdownSetting( + generalEl, + 'Target File', + ['active', 'prompt'], + this.draft.targetFile ?? 'active', + (v) => { + this.draft.targetFile = v as 'active' | 'prompt'; + }, + ); + } + + // --- Fields section --- + contentEl.createEl('h3', { text: 'Fields' }); + + const fieldsContainer = contentEl.createDiv({ cls: 'ff-builder-fields' }); + this.renderFields(fieldsContainer); + + // Add field button + const addBtn = contentEl.createEl('button', { + cls: 'ff-builder-add-btn', + text: '+ Add Field', + }); + addBtn.addEventListener('click', () => { + const newField: FormField = { + id: `field_${Date.now()}`, + label: 'New Field', + type: 'text', + required: false, + }; + this.draft.fields.push(newField); + this.render(); + }); + + // --- Footer --- + const footer = contentEl.createDiv({ cls: 'ff-builder-footer' }); + + const cancelBtn = footer.createEl('button', { text: 'Cancel' }); + cancelBtn.addEventListener('click', () => { + this.close(); + }); + + const saveBtn = footer.createEl('button', { + text: 'Save', + cls: 'mod-cta', + }); + saveBtn.addEventListener('click', () => { + if (!this.draft.name.trim()) { + new Notice('Form name cannot be empty.'); + return; + } + this.onSave(this.draft); + this.close(); + }); + } + + private renderFields(container: HTMLDivElement): void { + for (let i = 0; i < this.draft.fields.length; i++) { + const field = this.draft.fields[i]; + const fieldEl = container.createDiv({ cls: 'ff-builder-field' }); + + // Field header with number, label preview, and action buttons + const headerEl = fieldEl.createDiv({ cls: 'ff-builder-field-header' }); + + headerEl.createSpan({ + cls: 'ff-builder-field-num', + text: `#${i + 1}`, + }); + + headerEl.createSpan({ + cls: 'ff-builder-field-preview', + text: field.label || 'Untitled', + }); + + const actionsEl = headerEl.createDiv({ cls: 'ff-builder-field-actions' }); + + // Move up + if (i > 0) { + const upBtn = actionsEl.createEl('button', { + cls: 'ff-builder-action-btn', + text: '\u2191', + }); + upBtn.setAttribute('aria-label', 'Move up'); + upBtn.addEventListener('click', () => { + [this.draft.fields[i - 1], this.draft.fields[i]] = [ + this.draft.fields[i], + this.draft.fields[i - 1], + ]; + this.render(); + }); + } + + // Move down + if (i < this.draft.fields.length - 1) { + const downBtn = actionsEl.createEl('button', { + cls: 'ff-builder-action-btn', + text: '\u2193', + }); + downBtn.setAttribute('aria-label', 'Move down'); + downBtn.addEventListener('click', () => { + [this.draft.fields[i], this.draft.fields[i + 1]] = [ + this.draft.fields[i + 1], + this.draft.fields[i], + ]; + this.render(); + }); + } + + // Delete + const deleteBtn = actionsEl.createEl('button', { + cls: 'ff-builder-action-btn ff-builder-delete', + text: '\u00D7', + }); + deleteBtn.setAttribute('aria-label', 'Delete field'); + deleteBtn.addEventListener('click', () => { + this.draft.fields.splice(i, 1); + this.render(); + }); + + // Field body — settings + const bodyEl = fieldEl.createDiv({ cls: 'ff-builder-field-body' }); + + // Label + this.addTextSetting(bodyEl, 'Label', field.label, (v) => { + field.label = v; + }); + + // Property key (id) + this.addTextSetting(bodyEl, 'Property Key', field.id, (v) => { + field.id = v; + }); + + // Type + this.addDropdownSetting( + bodyEl, + 'Type', + FIELD_TYPES, + field.type, + (v) => { + field.type = v as FieldType; + this.render(); + }, + ); + + // Required toggle + this.addToggleSetting(bodyEl, 'Required', field.required, (v) => { + field.required = v; + }); + + // Placeholder (for text, textarea, number, date, note-link, folder-picker) + if (['text', 'textarea', 'number', 'date', 'note-link', 'folder-picker'].includes(field.type)) { + this.addTextSetting( + bodyEl, + 'Placeholder', + field.placeholder ?? '', + (v) => { + field.placeholder = v || undefined; + }, + ); + } + + // Options (for dropdown, tags) + if (field.type === 'dropdown' || field.type === 'tags') { + this.addTextSetting( + bodyEl, + 'Options (comma-separated)', + (field.options ?? []).join(', '), + (v) => { + field.options = v + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + }, + ); + } + + // Folder (for note-link) + if (field.type === 'note-link') { + this.addTextSetting( + bodyEl, + 'Folder Filter', + field.folder ?? '', + (v) => { + field.folder = v || undefined; + }, + ); + } + } + } + + // ------------------------------------------------------------------ + // Setting helpers + // ------------------------------------------------------------------ + + private addTextSetting( + container: HTMLElement, + label: string, + value: string, + onChange: (value: string) => void, + ): void { + const row = container.createDiv({ cls: 'ff-builder-setting' }); + row.createEl('label', { cls: 'ff-builder-setting-label', text: label }); + const input = row.createEl('input', { + cls: 'ff-input', + type: 'text', + value: value, + }); + input.addEventListener('change', () => onChange(input.value)); + } + + private addTextareaSetting( + container: HTMLElement, + label: string, + value: string, + onChange: (value: string) => void, + ): void { + const row = container.createDiv({ cls: 'ff-builder-setting' }); + row.createEl('label', { cls: 'ff-builder-setting-label', text: label }); + const textarea = row.createEl('textarea', { + cls: 'ff-builder-textarea', + }); + textarea.value = value; + textarea.rows = 4; + textarea.addEventListener('change', () => onChange(textarea.value)); + } + + private addDropdownSetting( + container: HTMLElement, + label: string, + options: string[], + value: string, + onChange: (value: string) => void, + ): void { + const row = container.createDiv({ cls: 'ff-builder-setting' }); + row.createEl('label', { cls: 'ff-builder-setting-label', text: label }); + const select = row.createEl('select', { cls: 'dropdown ff-dropdown' }); + for (const opt of options) { + select.createEl('option', { value: opt, text: opt }); + } + select.value = value; + select.addEventListener('change', () => onChange(select.value)); + } + + private addToggleSetting( + container: HTMLElement, + label: string, + value: boolean, + onChange: (value: boolean) => void, + ): void { + const row = container.createDiv({ cls: 'ff-builder-setting' }); + row.createEl('label', { cls: 'ff-builder-setting-label', text: label }); + const toggle = row.createDiv({ cls: 'checkbox-container' }); + if (value) toggle.addClass('is-enabled'); + toggle.addEventListener('click', () => { + const enabled = !toggle.hasClass('is-enabled'); + toggle.toggleClass('is-enabled', enabled); + onChange(enabled); + }); + } +} diff --git a/src/ui/settings-tab.ts b/src/ui/settings-tab.ts new file mode 100644 index 0000000..4bc9607 --- /dev/null +++ b/src/ui/settings-tab.ts @@ -0,0 +1,106 @@ +import { App, Plugin, PluginSettingTab, Setting, Notice } from 'obsidian'; +import { FormStore } from '../core/form-store'; +import { FormBuilderModal } from './form-builder'; + +/** + * Interface for the subset of FormfirePlugin that the settings tab needs. + * Avoids circular dependency with main.ts. + */ +export interface SettingsPluginRef extends Plugin { + store: FormStore; + saveSettings: () => Promise; + refreshSidebar: () => void; +} + +/** + * Formfire settings tab — lists all forms with Edit / Duplicate / Delete, + * plus a button to create new forms. + */ +export class FormfireSettingTab extends PluginSettingTab { + private pluginRef: SettingsPluginRef; + + constructor(app: App, plugin: SettingsPluginRef) { + super(app, plugin); + this.pluginRef = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + containerEl.createEl('h2', { text: 'Formfire' }); + + // New form button + new Setting(containerEl) + .setName('Create a new form') + .setDesc('Add a new form definition to your collection.') + .addButton((btn) => { + btn.setButtonText('+ New Form').setCta().onClick(() => { + const blank = this.pluginRef.store.createBlank(); + new FormBuilderModal(this.app, blank, async (saved) => { + this.pluginRef.store.add(saved); + await this.pluginRef.saveSettings(); + this.pluginRef.refreshSidebar(); + this.display(); + }).open(); + }); + }); + + // Form list + const forms = this.pluginRef.store.getAll(); + + if (forms.length === 0) { + containerEl.createDiv({ + cls: 'ff-settings-empty', + text: 'No forms defined yet. Click the button above to create your first form.', + }); + return; + } + + containerEl.createEl('h3', { text: 'Your Forms' }); + + for (const form of forms) { + const modeDesc = + form.mode === 'create' + ? `Creates notes in ${form.targetFolder ?? '/'}` + : `Updates ${form.targetFile === 'active' ? 'active file' : 'prompted file'}`; + const fieldCount = form.fields.length; + const desc = `${modeDesc} \u2022 ${fieldCount} field${fieldCount !== 1 ? 's' : ''}`; + + new Setting(containerEl) + .setName(form.name) + .setDesc(desc) + .addButton((btn) => { + btn.setButtonText('Edit').onClick(() => { + new FormBuilderModal(this.app, form, async (saved) => { + this.pluginRef.store.update(form.id, saved); + await this.pluginRef.saveSettings(); + this.pluginRef.refreshSidebar(); + this.display(); + }).open(); + }); + }) + .addButton((btn) => { + btn.setButtonText('Duplicate').onClick(async () => { + this.pluginRef.store.duplicate(form.id); + await this.pluginRef.saveSettings(); + this.pluginRef.refreshSidebar(); + this.display(); + new Notice(`Duplicated "${form.name}".`); + }); + }) + .addButton((btn) => { + btn + .setButtonText('Delete') + .setWarning() + .onClick(async () => { + this.pluginRef.store.remove(form.id); + await this.pluginRef.saveSettings(); + this.pluginRef.refreshSidebar(); + this.display(); + new Notice(`Deleted "${form.name}".`); + }); + }); + } + } +}