feat: add settings tab with visual form builder
FormBuilderModal for editing form definitions with general settings, mode-specific options, and a field editor supporting add/reorder/delete with per-field type, label, property key, required toggle, and conditional options. FormfireSettingTab lists forms with Edit/Duplicate/ Delete actions and a New Form button. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5eb79fbf08
commit
7ba7048b3e
2 changed files with 461 additions and 0 deletions
355
src/ui/form-builder.ts
Normal file
355
src/ui/form-builder.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/ui/settings-tab.ts
Normal file
106
src/ui/settings-tab.ts
Normal file
|
|
@ -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<void>;
|
||||||
|
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}".`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue