14-task plan covering scaffolding, types, core logic, UI components, and integration testing. Uses A+B hybrid architecture with FormStore, FieldRenderers, and FormProcessor layers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2080 lines
56 KiB
Markdown
2080 lines
56 KiB
Markdown
# Formfire Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||
|
||
**Goal:** Build an Obsidian plugin for structured data input via forms that creates notes or updates frontmatter.
|
||
|
||
**Architecture:** A+B Hybrid — pragmatic like Promptfire, with clean separation like Logfire. Three core layers: FormStore (settings CRUD), FieldRenderers (UI per type), FormProcessor (note creation/frontmatter update). Visual Form Builder in Settings, FormModal for filling out, Sidebar for discovery.
|
||
|
||
**Tech Stack:** Obsidian Plugin API, TypeScript 5.6, esbuild, CSS with Obsidian CSS variables.
|
||
|
||
**Reference plugins:** obsidian-promptfire (flat structure, settings patterns, CSS conventions), obsidian-logfire (layered architecture, deepMerge, settings tab style).
|
||
|
||
---
|
||
|
||
### Task 1: Project Scaffolding
|
||
|
||
**Files:**
|
||
- Create: `package.json`
|
||
- Create: `manifest.json`
|
||
- Create: `tsconfig.json`
|
||
- Create: `esbuild.config.mjs`
|
||
- Create: `.gitignore`
|
||
|
||
**Step 1: Create package.json**
|
||
|
||
```json
|
||
{
|
||
"name": "obsidian-formfire",
|
||
"version": "0.1.0",
|
||
"description": "Structured data input for Obsidian – forms that create notes or update frontmatter",
|
||
"main": "main.js",
|
||
"scripts": {
|
||
"dev": "node esbuild.config.mjs",
|
||
"build": "node esbuild.config.mjs production"
|
||
},
|
||
"devDependencies": {
|
||
"@types/node": "^22.0.0",
|
||
"builtin-modules": "^4.0.0",
|
||
"esbuild": "^0.24.0",
|
||
"obsidian": "latest",
|
||
"typescript": "^5.6.0"
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Create manifest.json**
|
||
|
||
```json
|
||
{
|
||
"id": "formfire",
|
||
"name": "Formfire",
|
||
"version": "0.1.0",
|
||
"minAppVersion": "1.5.0",
|
||
"description": "Structured data input for Obsidian – forms that create notes or update frontmatter",
|
||
"author": "Luca",
|
||
"isDesktopOnly": true
|
||
}
|
||
```
|
||
|
||
**Step 3: Create tsconfig.json**
|
||
|
||
Follow Logfire's pattern (ES2022, strict):
|
||
|
||
```json
|
||
{
|
||
"compilerOptions": {
|
||
"baseUrl": ".",
|
||
"inlineSourceMap": true,
|
||
"inlineSources": true,
|
||
"module": "ESNext",
|
||
"target": "ES2022",
|
||
"strict": true,
|
||
"noImplicitAny": true,
|
||
"strictNullChecks": true,
|
||
"moduleResolution": "node",
|
||
"esModuleInterop": true,
|
||
"allowSyntheticDefaultImports": true,
|
||
"importHelpers": true,
|
||
"isolatedModules": true,
|
||
"skipLibCheck": true,
|
||
"lib": ["DOM", "ES2022"]
|
||
},
|
||
"include": ["src/**/*.ts"]
|
||
}
|
||
```
|
||
|
||
**Step 4: Create esbuild.config.mjs**
|
||
|
||
Follow Logfire's pattern (no native modules needed, simpler than Logfire):
|
||
|
||
```javascript
|
||
import esbuild from "esbuild";
|
||
import process from "process";
|
||
import builtins from "builtin-modules";
|
||
|
||
const prod = process.argv[2] === "production";
|
||
|
||
const context = await esbuild.context({
|
||
entryPoints: ["src/main.ts"],
|
||
bundle: true,
|
||
external: [
|
||
"obsidian",
|
||
"electron",
|
||
"@codemirror/autocomplete",
|
||
"@codemirror/collab",
|
||
"@codemirror/commands",
|
||
"@codemirror/language",
|
||
"@codemirror/lint",
|
||
"@codemirror/search",
|
||
"@codemirror/state",
|
||
"@codemirror/view",
|
||
"@lezer/common",
|
||
"@lezer/highlight",
|
||
"@lezer/lr",
|
||
...builtins,
|
||
],
|
||
format: "cjs",
|
||
target: "es2022",
|
||
logLevel: "info",
|
||
sourcemap: prod ? false : "inline",
|
||
treeShaking: true,
|
||
outfile: "main.js",
|
||
minify: prod,
|
||
});
|
||
|
||
if (prod) {
|
||
await context.rebuild();
|
||
process.exit(0);
|
||
} else {
|
||
await context.watch();
|
||
}
|
||
```
|
||
|
||
**Step 5: Create .gitignore**
|
||
|
||
```
|
||
node_modules/
|
||
main.js
|
||
*.js.map
|
||
```
|
||
|
||
**Step 6: Install dependencies**
|
||
|
||
Run: `npm install`
|
||
Expected: `node_modules/` created, no errors.
|
||
|
||
**Step 7: Verify build works**
|
||
|
||
Create a minimal `src/main.ts`:
|
||
|
||
```typescript
|
||
import { Plugin } from 'obsidian';
|
||
|
||
export default class FormfirePlugin extends Plugin {
|
||
async onload(): Promise<void> {
|
||
console.log('[Formfire] Loading plugin...');
|
||
}
|
||
onunload(): void {
|
||
console.log('[Formfire] Unloading plugin.');
|
||
}
|
||
}
|
||
```
|
||
|
||
Run: `npm run build`
|
||
Expected: `main.js` created, no errors.
|
||
|
||
**Step 8: Commit**
|
||
|
||
```bash
|
||
git add package.json manifest.json tsconfig.json esbuild.config.mjs .gitignore src/main.ts
|
||
git commit -m "feat: scaffold Formfire plugin project"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Type Definitions
|
||
|
||
**Files:**
|
||
- Create: `src/types.ts`
|
||
|
||
**Step 1: Write all type definitions**
|
||
|
||
```typescript
|
||
// ---------------------------------------------------------------------------
|
||
// Field Types
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export type FieldType =
|
||
| 'text'
|
||
| 'textarea'
|
||
| 'number'
|
||
| 'toggle'
|
||
| 'date'
|
||
| 'dropdown'
|
||
| 'tags'
|
||
| 'note-link'
|
||
| 'folder-picker'
|
||
| 'rating';
|
||
|
||
export interface FormField {
|
||
id: string;
|
||
label: string;
|
||
type: FieldType;
|
||
required: boolean;
|
||
defaultValue?: string | number | boolean | string[];
|
||
placeholder?: string;
|
||
/** For dropdown: list of options. For tags: suggested tags. */
|
||
options?: string[];
|
||
/** For note-link: restrict to files in this folder. */
|
||
folder?: string;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Form Definition
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface FormDefinition {
|
||
id: string;
|
||
name: string;
|
||
icon: string;
|
||
mode: 'create' | 'update';
|
||
|
||
// mode: "create" only
|
||
targetFolder?: string;
|
||
fileNameTemplate?: string;
|
||
bodyTemplate?: string;
|
||
|
||
// mode: "update" only
|
||
targetFile?: 'active' | 'prompt';
|
||
|
||
fields: FormField[];
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Settings
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface FormfireSettings {
|
||
forms: FormDefinition[];
|
||
}
|
||
|
||
export const DEFAULT_SETTINGS: FormfireSettings = {
|
||
forms: [],
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Utilities
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Recursively merge source into target. Arrays are replaced, not merged. */
|
||
export function deepMerge<T extends Record<string, unknown>>(
|
||
target: T,
|
||
source: Partial<T>,
|
||
): T {
|
||
const result = { ...target };
|
||
for (const key of Object.keys(source) as (keyof T)[]) {
|
||
const srcVal = source[key];
|
||
const tgtVal = target[key];
|
||
if (
|
||
srcVal !== null &&
|
||
typeof srcVal === 'object' &&
|
||
!Array.isArray(srcVal) &&
|
||
tgtVal !== null &&
|
||
typeof tgtVal === 'object' &&
|
||
!Array.isArray(tgtVal)
|
||
) {
|
||
result[key] = deepMerge(
|
||
tgtVal as Record<string, unknown>,
|
||
srcVal as Record<string, unknown>,
|
||
) as T[keyof T];
|
||
} else if (srcVal !== undefined) {
|
||
result[key] = srcVal as T[keyof T];
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
```
|
||
|
||
**Step 2: Verify it compiles**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: No errors.
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/types.ts
|
||
git commit -m "feat: add type definitions (FormDefinition, FormField, settings)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Template Engine
|
||
|
||
**Files:**
|
||
- Create: `src/utils/template-engine.ts`
|
||
|
||
**Step 1: Write template engine**
|
||
|
||
```typescript
|
||
/**
|
||
* Simple {{variable}} substitution engine.
|
||
* Built-in variables: {{date}}, {{time}}, {{datetime}}.
|
||
* All other {{keys}} are resolved from the provided values map.
|
||
*/
|
||
export function renderTemplate(
|
||
template: string,
|
||
values: Record<string, string>,
|
||
): string {
|
||
const now = new Date();
|
||
const builtins: Record<string, string> = {
|
||
date: now.toISOString().slice(0, 10),
|
||
time: now.toTimeString().slice(0, 5),
|
||
datetime: `${now.toISOString().slice(0, 10)} ${now.toTimeString().slice(0, 5)}`,
|
||
};
|
||
|
||
const allValues = { ...builtins, ...values };
|
||
|
||
return template.replace(/\{\{(\w[\w-]*)\}\}/g, (match, key: string) => {
|
||
return allValues[key] ?? match;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Sanitize a string for use as a file name.
|
||
* Removes characters illegal on Windows/macOS/Linux.
|
||
*/
|
||
export function sanitizeFileName(name: string): string {
|
||
return name
|
||
.replace(/[\\/:*?"<>|]/g, '-')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
```
|
||
|
||
**Step 2: Verify it compiles**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: No errors.
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/utils/template-engine.ts
|
||
git commit -m "feat: add template engine with {{variable}} substitution"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Validators
|
||
|
||
**Files:**
|
||
- Create: `src/utils/validators.ts`
|
||
|
||
**Step 1: Write validator**
|
||
|
||
```typescript
|
||
import { FormField } from '../types';
|
||
|
||
export interface ValidationError {
|
||
fieldId: string;
|
||
message: string;
|
||
}
|
||
|
||
/**
|
||
* Validate form values against field definitions.
|
||
* Returns an empty array if everything is valid.
|
||
*/
|
||
export function validateForm(
|
||
fields: FormField[],
|
||
values: Record<string, unknown>,
|
||
): ValidationError[] {
|
||
const errors: ValidationError[] = [];
|
||
|
||
for (const field of fields) {
|
||
const value = values[field.id];
|
||
|
||
if (field.required) {
|
||
if (value === undefined || value === null || value === '') {
|
||
errors.push({ fieldId: field.id, message: `${field.label} is required.` });
|
||
continue;
|
||
}
|
||
if (Array.isArray(value) && value.length === 0) {
|
||
errors.push({ fieldId: field.id, message: `${field.label} is required.` });
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if (value !== undefined && value !== null && value !== '') {
|
||
if (field.type === 'number' && typeof value === 'string') {
|
||
if (isNaN(Number(value))) {
|
||
errors.push({ fieldId: field.id, message: `${field.label} must be a number.` });
|
||
}
|
||
}
|
||
if (field.type === 'rating') {
|
||
const n = Number(value);
|
||
if (isNaN(n) || n < 1 || n > 5) {
|
||
errors.push({ fieldId: field.id, message: `${field.label} must be between 1 and 5.` });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return errors;
|
||
}
|
||
```
|
||
|
||
**Step 2: Verify it compiles**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: No errors.
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/utils/validators.ts
|
||
git commit -m "feat: add form field validators"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: FormStore (CRUD for Form Definitions)
|
||
|
||
**Files:**
|
||
- Create: `src/core/form-store.ts`
|
||
|
||
**Step 1: Write FormStore**
|
||
|
||
```typescript
|
||
import { FormDefinition, FormfireSettings } from '../types';
|
||
|
||
/**
|
||
* Manages CRUD operations on FormDefinitions within plugin settings.
|
||
* Does NOT persist — the caller (plugin) calls saveSettings() after mutations.
|
||
*/
|
||
export class FormStore {
|
||
constructor(private settings: FormfireSettings) {}
|
||
|
||
getAll(): FormDefinition[] {
|
||
return this.settings.forms;
|
||
}
|
||
|
||
getById(id: string): FormDefinition | undefined {
|
||
return this.settings.forms.find(f => f.id === id);
|
||
}
|
||
|
||
getByName(name: string): FormDefinition | undefined {
|
||
return this.settings.forms.find(
|
||
f => f.name.toLowerCase() === name.toLowerCase(),
|
||
);
|
||
}
|
||
|
||
add(form: FormDefinition): void {
|
||
this.settings.forms.push(form);
|
||
}
|
||
|
||
update(id: string, patch: Partial<FormDefinition>): void {
|
||
const idx = this.settings.forms.findIndex(f => f.id === id);
|
||
if (idx === -1) return;
|
||
this.settings.forms[idx] = { ...this.settings.forms[idx], ...patch };
|
||
}
|
||
|
||
remove(id: string): void {
|
||
this.settings.forms = this.settings.forms.filter(f => f.id !== id);
|
||
}
|
||
|
||
duplicate(id: string): FormDefinition | undefined {
|
||
const original = this.getById(id);
|
||
if (!original) return undefined;
|
||
const copy: FormDefinition = {
|
||
...structuredClone(original),
|
||
id: crypto.randomUUID(),
|
||
name: `${original.name} (Copy)`,
|
||
};
|
||
this.settings.forms.push(copy);
|
||
return copy;
|
||
}
|
||
|
||
/** Move a form up or down in the list. */
|
||
reorder(id: string, direction: 'up' | 'down'): void {
|
||
const idx = this.settings.forms.findIndex(f => f.id === id);
|
||
if (idx === -1) return;
|
||
const swap = direction === 'up' ? idx - 1 : idx + 1;
|
||
if (swap < 0 || swap >= this.settings.forms.length) return;
|
||
[this.settings.forms[idx], this.settings.forms[swap]] =
|
||
[this.settings.forms[swap], this.settings.forms[idx]];
|
||
}
|
||
|
||
/** Create a blank FormDefinition with sensible defaults. */
|
||
createBlank(): FormDefinition {
|
||
return {
|
||
id: crypto.randomUUID(),
|
||
name: 'New Form',
|
||
icon: 'file-input',
|
||
mode: 'create',
|
||
targetFolder: '/',
|
||
fileNameTemplate: '{{date}}-{{title}}',
|
||
bodyTemplate: '',
|
||
fields: [],
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Verify it compiles**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: No errors.
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/core/form-store.ts
|
||
git commit -m "feat: add FormStore for CRUD on form definitions"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: FormProcessor (Note Creation & Frontmatter Update)
|
||
|
||
**Files:**
|
||
- Create: `src/core/form-processor.ts`
|
||
|
||
**Step 1: Write FormProcessor**
|
||
|
||
```typescript
|
||
import { App, TFile, TFolder, Notice, normalizePath } from 'obsidian';
|
||
import { FormDefinition } from '../types';
|
||
import { renderTemplate, sanitizeFileName } from '../utils/template-engine';
|
||
|
||
/**
|
||
* Processes a submitted form: creates a new note or updates frontmatter.
|
||
*/
|
||
export class FormProcessor {
|
||
constructor(private app: App) {}
|
||
|
||
/**
|
||
* Process form submission based on mode.
|
||
* Returns the file that was created or updated.
|
||
*/
|
||
async process(
|
||
form: FormDefinition,
|
||
values: Record<string, unknown>,
|
||
targetFile?: TFile,
|
||
): Promise<TFile> {
|
||
if (form.mode === 'create') {
|
||
return this.createNote(form, values);
|
||
} else {
|
||
return this.updateNote(form, values, targetFile);
|
||
}
|
||
}
|
||
|
||
private async createNote(
|
||
form: FormDefinition,
|
||
values: Record<string, unknown>,
|
||
): Promise<TFile> {
|
||
const stringValues = this.toStringValues(values);
|
||
|
||
// Build file name
|
||
const rawName = form.fileNameTemplate
|
||
? renderTemplate(form.fileNameTemplate, stringValues)
|
||
: `${stringValues['date'] ?? new Date().toISOString().slice(0, 10)}-untitled`;
|
||
const fileName = sanitizeFileName(rawName);
|
||
|
||
// Ensure target folder exists
|
||
const folder = normalizePath(form.targetFolder ?? '/');
|
||
await this.ensureFolder(folder);
|
||
|
||
// Build file path (avoid duplicates)
|
||
let filePath = normalizePath(`${folder}/${fileName}.md`);
|
||
let counter = 1;
|
||
while (this.app.vault.getAbstractFileByPath(filePath)) {
|
||
filePath = normalizePath(`${folder}/${fileName}-${counter}.md`);
|
||
counter++;
|
||
}
|
||
|
||
// Build content
|
||
const frontmatter = this.buildFrontmatter(form, values);
|
||
const body = form.bodyTemplate
|
||
? renderTemplate(form.bodyTemplate, stringValues)
|
||
: '';
|
||
const content = `---\n${frontmatter}---\n${body}`;
|
||
|
||
const file = await this.app.vault.create(filePath, content);
|
||
new Notice(`Created: ${file.path}`);
|
||
return file;
|
||
}
|
||
|
||
private async updateNote(
|
||
form: FormDefinition,
|
||
values: Record<string, unknown>,
|
||
targetFile?: TFile,
|
||
): Promise<TFile> {
|
||
if (!targetFile) {
|
||
throw new Error('No target file specified for update.');
|
||
}
|
||
|
||
await this.app.fileManager.processFrontMatter(targetFile, (fm) => {
|
||
for (const field of form.fields) {
|
||
const value = values[field.id];
|
||
if (value !== undefined) {
|
||
fm[field.id] = value;
|
||
}
|
||
}
|
||
});
|
||
|
||
new Notice(`Updated: ${targetFile.path}`);
|
||
return targetFile;
|
||
}
|
||
|
||
private buildFrontmatter(
|
||
form: FormDefinition,
|
||
values: Record<string, unknown>,
|
||
): string {
|
||
let yaml = '';
|
||
for (const field of form.fields) {
|
||
const value = values[field.id];
|
||
if (value === undefined || value === null) continue;
|
||
|
||
if (Array.isArray(value)) {
|
||
yaml += `${field.id}:\n`;
|
||
for (const item of value) {
|
||
yaml += ` - ${this.yamlEscape(String(item))}\n`;
|
||
}
|
||
} else if (typeof value === 'boolean') {
|
||
yaml += `${field.id}: ${value}\n`;
|
||
} else if (typeof value === 'number') {
|
||
yaml += `${field.id}: ${value}\n`;
|
||
} else {
|
||
yaml += `${field.id}: ${this.yamlEscape(String(value))}\n`;
|
||
}
|
||
}
|
||
return yaml;
|
||
}
|
||
|
||
private yamlEscape(value: string): string {
|
||
if (/[:#{}[\],&*?|>!%@`]/.test(value) || value.includes('\n')) {
|
||
return `"${value.replace(/"/g, '\\"')}"`;
|
||
}
|
||
return value;
|
||
}
|
||
|
||
private toStringValues(values: Record<string, unknown>): Record<string, string> {
|
||
const result: Record<string, string> = {};
|
||
for (const [key, value] of Object.entries(values)) {
|
||
if (Array.isArray(value)) {
|
||
result[key] = value.join(', ');
|
||
} else {
|
||
result[key] = String(value ?? '');
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
private async ensureFolder(path: string): Promise<void> {
|
||
if (path === '/' || path === '') return;
|
||
const existing = this.app.vault.getAbstractFileByPath(path);
|
||
if (existing instanceof TFolder) return;
|
||
await this.app.vault.createFolder(path);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Verify it compiles**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: No errors.
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/core/form-processor.ts
|
||
git commit -m "feat: add FormProcessor for note creation and frontmatter updates"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Field Renderers
|
||
|
||
**Files:**
|
||
- Create: `src/ui/field-renderers.ts`
|
||
|
||
This is the most important UI file. Each field type gets a renderer function that creates Obsidian-native UI elements inside a container.
|
||
|
||
**Step 1: Write field renderers**
|
||
|
||
```typescript
|
||
import { App, TFolder } from 'obsidian';
|
||
import { FormField } from '../types';
|
||
|
||
export type FieldValue = string | number | boolean | string[];
|
||
|
||
export interface RenderedField {
|
||
getValue: () => FieldValue;
|
||
setValue: (value: FieldValue) => void;
|
||
setError: (message: string | null) => void;
|
||
}
|
||
|
||
/**
|
||
* Render a single form field into the given container element.
|
||
* Returns accessor functions for value get/set and error display.
|
||
*/
|
||
export function renderField(
|
||
app: App,
|
||
container: HTMLElement,
|
||
field: FormField,
|
||
initialValue?: FieldValue,
|
||
): RenderedField {
|
||
switch (field.type) {
|
||
case 'text': return renderTextField(container, field, initialValue);
|
||
case 'textarea': return renderTextareaField(container, field, initialValue);
|
||
case 'number': return renderNumberField(container, field, initialValue);
|
||
case 'toggle': return renderToggleField(container, field, initialValue);
|
||
case 'date': return renderDateField(container, field, initialValue);
|
||
case 'dropdown': return renderDropdownField(container, field, initialValue);
|
||
case 'tags': return renderTagsField(container, field, initialValue);
|
||
case 'note-link': return renderNoteLinkField(app, container, field, initialValue);
|
||
case 'folder-picker': return renderFolderPickerField(app, container, field, initialValue);
|
||
case 'rating': return renderRatingField(container, field, initialValue);
|
||
default: return renderTextField(container, field, initialValue);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helper: create a wrapper with label and error area
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function createFieldWrapper(
|
||
container: HTMLElement,
|
||
field: FormField,
|
||
): { wrapper: HTMLElement; controlEl: HTMLElement; errorEl: HTMLElement } {
|
||
const wrapper = container.createDiv({ cls: 'ff-field' });
|
||
|
||
const labelEl = wrapper.createEl('label', {
|
||
cls: 'ff-field-label',
|
||
text: field.label,
|
||
});
|
||
if (field.required) {
|
||
labelEl.createSpan({ cls: 'ff-field-required', text: ' *' });
|
||
}
|
||
|
||
const controlEl = wrapper.createDiv({ cls: 'ff-field-control' });
|
||
const errorEl = wrapper.createDiv({ cls: 'ff-field-error' });
|
||
errorEl.style.display = 'none';
|
||
|
||
return { wrapper, controlEl, errorEl };
|
||
}
|
||
|
||
function makeSetError(errorEl: HTMLElement): (message: string | null) => void {
|
||
return (message: string | null) => {
|
||
if (message) {
|
||
errorEl.textContent = message;
|
||
errorEl.style.display = 'block';
|
||
} else {
|
||
errorEl.textContent = '';
|
||
errorEl.style.display = 'none';
|
||
}
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Individual renderers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function renderTextField(
|
||
container: HTMLElement,
|
||
field: FormField,
|
||
initial?: FieldValue,
|
||
): RenderedField {
|
||
const { controlEl, errorEl } = createFieldWrapper(container, field);
|
||
const input = controlEl.createEl('input', {
|
||
type: 'text',
|
||
cls: 'ff-input',
|
||
placeholder: field.placeholder ?? '',
|
||
});
|
||
if (initial !== undefined) input.value = String(initial);
|
||
|
||
return {
|
||
getValue: () => input.value,
|
||
setValue: (v) => { input.value = String(v); },
|
||
setError: makeSetError(errorEl),
|
||
};
|
||
}
|
||
|
||
function renderTextareaField(
|
||
container: HTMLElement,
|
||
field: FormField,
|
||
initial?: FieldValue,
|
||
): RenderedField {
|
||
const { controlEl, errorEl } = createFieldWrapper(container, field);
|
||
const textarea = controlEl.createEl('textarea', {
|
||
cls: 'ff-textarea',
|
||
placeholder: field.placeholder ?? '',
|
||
});
|
||
textarea.rows = 4;
|
||
if (initial !== undefined) textarea.value = String(initial);
|
||
|
||
return {
|
||
getValue: () => textarea.value,
|
||
setValue: (v) => { textarea.value = String(v); },
|
||
setError: makeSetError(errorEl),
|
||
};
|
||
}
|
||
|
||
function renderNumberField(
|
||
container: HTMLElement,
|
||
field: FormField,
|
||
initial?: FieldValue,
|
||
): RenderedField {
|
||
const { controlEl, errorEl } = createFieldWrapper(container, field);
|
||
const input = controlEl.createEl('input', {
|
||
type: 'number',
|
||
cls: 'ff-input',
|
||
placeholder: field.placeholder ?? '',
|
||
});
|
||
if (initial !== undefined) input.value = String(initial);
|
||
|
||
return {
|
||
getValue: () => input.value === '' ? '' : Number(input.value),
|
||
setValue: (v) => { input.value = String(v); },
|
||
setError: makeSetError(errorEl),
|
||
};
|
||
}
|
||
|
||
function renderToggleField(
|
||
container: HTMLElement,
|
||
field: FormField,
|
||
initial?: FieldValue,
|
||
): RenderedField {
|
||
const { controlEl, errorEl } = createFieldWrapper(container, field);
|
||
let currentValue = initial === true || initial === 'true';
|
||
|
||
const toggleEl = controlEl.createDiv({ cls: 'checkbox-container' });
|
||
if (currentValue) toggleEl.addClass('is-enabled');
|
||
|
||
toggleEl.addEventListener('click', () => {
|
||
currentValue = !currentValue;
|
||
toggleEl.toggleClass('is-enabled', currentValue);
|
||
});
|
||
|
||
return {
|
||
getValue: () => currentValue,
|
||
setValue: (v) => {
|
||
currentValue = v === true || v === 'true';
|
||
toggleEl.toggleClass('is-enabled', currentValue);
|
||
},
|
||
setError: makeSetError(errorEl),
|
||
};
|
||
}
|
||
|
||
function renderDateField(
|
||
container: HTMLElement,
|
||
field: FormField,
|
||
initial?: FieldValue,
|
||
): RenderedField {
|
||
const { controlEl, errorEl } = createFieldWrapper(container, field);
|
||
const input = controlEl.createEl('input', {
|
||
type: 'date',
|
||
cls: 'ff-input',
|
||
});
|
||
if (initial) {
|
||
input.value = String(initial);
|
||
} else if (field.defaultValue === undefined) {
|
||
input.value = new Date().toISOString().slice(0, 10);
|
||
}
|
||
|
||
return {
|
||
getValue: () => input.value,
|
||
setValue: (v) => { input.value = String(v); },
|
||
setError: makeSetError(errorEl),
|
||
};
|
||
}
|
||
|
||
function renderDropdownField(
|
||
container: HTMLElement,
|
||
field: FormField,
|
||
initial?: FieldValue,
|
||
): RenderedField {
|
||
const { controlEl, errorEl } = createFieldWrapper(container, field);
|
||
const select = controlEl.createEl('select', { cls: 'dropdown ff-dropdown' });
|
||
|
||
// Empty option
|
||
select.createEl('option', { value: '', text: field.placeholder ?? '-- Select --' });
|
||
|
||
for (const opt of field.options ?? []) {
|
||
select.createEl('option', { value: opt, text: opt });
|
||
}
|
||
|
||
if (initial !== undefined) select.value = String(initial);
|
||
|
||
return {
|
||
getValue: () => select.value,
|
||
setValue: (v) => { select.value = String(v); },
|
||
setError: makeSetError(errorEl),
|
||
};
|
||
}
|
||
|
||
function renderTagsField(
|
||
container: HTMLElement,
|
||
field: FormField,
|
||
initial?: FieldValue,
|
||
): RenderedField {
|
||
const { controlEl, errorEl } = createFieldWrapper(container, field);
|
||
let selectedTags: string[] = Array.isArray(initial)
|
||
? [...initial]
|
||
: (initial ? String(initial).split(',').map(s => s.trim()).filter(Boolean) : []);
|
||
|
||
const tagsContainer = controlEl.createDiv({ cls: 'ff-tags-container' });
|
||
const input = controlEl.createEl('input', {
|
||
type: 'text',
|
||
cls: 'ff-input ff-tags-input',
|
||
placeholder: field.placeholder ?? 'Type and press Enter...',
|
||
});
|
||
|
||
function renderTags() {
|
||
tagsContainer.empty();
|
||
for (const tag of selectedTags) {
|
||
const chip = tagsContainer.createSpan({ cls: 'ff-tag-chip', text: tag });
|
||
const remove = chip.createSpan({ cls: 'ff-tag-remove', text: ' x' });
|
||
remove.addEventListener('click', () => {
|
||
selectedTags = selectedTags.filter(t => t !== tag);
|
||
renderTags();
|
||
});
|
||
}
|
||
}
|
||
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' && input.value.trim()) {
|
||
e.preventDefault();
|
||
const tag = input.value.trim();
|
||
if (!selectedTags.includes(tag)) {
|
||
selectedTags.push(tag);
|
||
renderTags();
|
||
}
|
||
input.value = '';
|
||
}
|
||
});
|
||
|
||
renderTags();
|
||
|
||
return {
|
||
getValue: () => [...selectedTags],
|
||
setValue: (v) => {
|
||
selectedTags = Array.isArray(v) ? [...v] : [];
|
||
renderTags();
|
||
},
|
||
setError: makeSetError(errorEl),
|
||
};
|
||
}
|
||
|
||
function renderNoteLinkField(
|
||
app: App,
|
||
container: HTMLElement,
|
||
field: FormField,
|
||
initial?: FieldValue,
|
||
): RenderedField {
|
||
const { controlEl, errorEl } = createFieldWrapper(container, field);
|
||
const input = controlEl.createEl('input', {
|
||
type: 'text',
|
||
cls: 'ff-input',
|
||
placeholder: field.placeholder ?? 'Start typing to search notes...',
|
||
});
|
||
if (initial !== undefined) input.value = String(initial);
|
||
|
||
const suggestEl = controlEl.createDiv({ cls: 'ff-suggest-list' });
|
||
suggestEl.style.display = 'none';
|
||
|
||
function updateSuggestions() {
|
||
const query = input.value.toLowerCase();
|
||
if (!query) { suggestEl.style.display = 'none'; return; }
|
||
|
||
const files = app.vault.getMarkdownFiles()
|
||
.filter(f => {
|
||
if (field.folder && !f.path.startsWith(field.folder)) return false;
|
||
return f.basename.toLowerCase().includes(query);
|
||
})
|
||
.slice(0, 10);
|
||
|
||
suggestEl.empty();
|
||
if (files.length === 0) { suggestEl.style.display = 'none'; return; }
|
||
|
||
for (const file of files) {
|
||
const item = suggestEl.createDiv({ cls: 'ff-suggest-item', text: file.basename });
|
||
item.addEventListener('mousedown', (e) => {
|
||
e.preventDefault();
|
||
input.value = file.basename;
|
||
suggestEl.style.display = 'none';
|
||
});
|
||
}
|
||
suggestEl.style.display = 'block';
|
||
}
|
||
|
||
input.addEventListener('input', updateSuggestions);
|
||
input.addEventListener('focus', updateSuggestions);
|
||
input.addEventListener('blur', () => {
|
||
setTimeout(() => { suggestEl.style.display = 'none'; }, 200);
|
||
});
|
||
|
||
return {
|
||
getValue: () => input.value,
|
||
setValue: (v) => { input.value = String(v); },
|
||
setError: makeSetError(errorEl),
|
||
};
|
||
}
|
||
|
||
function renderFolderPickerField(
|
||
app: App,
|
||
container: HTMLElement,
|
||
field: FormField,
|
||
initial?: FieldValue,
|
||
): RenderedField {
|
||
const { controlEl, errorEl } = createFieldWrapper(container, field);
|
||
const input = controlEl.createEl('input', {
|
||
type: 'text',
|
||
cls: 'ff-input',
|
||
placeholder: field.placeholder ?? 'Start typing to search folders...',
|
||
});
|
||
if (initial !== undefined) input.value = String(initial);
|
||
|
||
const suggestEl = controlEl.createDiv({ cls: 'ff-suggest-list' });
|
||
suggestEl.style.display = 'none';
|
||
|
||
function getAllFolders(): string[] {
|
||
const folders: string[] = ['/'];
|
||
app.vault.getAllLoadedFiles().forEach(f => {
|
||
if (f instanceof TFolder && f.path !== '/') {
|
||
folders.push(f.path);
|
||
}
|
||
});
|
||
return folders.sort();
|
||
}
|
||
|
||
function updateSuggestions() {
|
||
const query = input.value.toLowerCase();
|
||
const folders = getAllFolders().filter(f => f.toLowerCase().includes(query)).slice(0, 10);
|
||
|
||
suggestEl.empty();
|
||
if (folders.length === 0) { suggestEl.style.display = 'none'; return; }
|
||
|
||
for (const folder of folders) {
|
||
const item = suggestEl.createDiv({ cls: 'ff-suggest-item', text: folder });
|
||
item.addEventListener('mousedown', (e) => {
|
||
e.preventDefault();
|
||
input.value = folder;
|
||
suggestEl.style.display = 'none';
|
||
});
|
||
}
|
||
suggestEl.style.display = 'block';
|
||
}
|
||
|
||
input.addEventListener('input', updateSuggestions);
|
||
input.addEventListener('focus', updateSuggestions);
|
||
input.addEventListener('blur', () => {
|
||
setTimeout(() => { suggestEl.style.display = 'none'; }, 200);
|
||
});
|
||
|
||
return {
|
||
getValue: () => input.value,
|
||
setValue: (v) => { input.value = String(v); },
|
||
setError: makeSetError(errorEl),
|
||
};
|
||
}
|
||
|
||
function renderRatingField(
|
||
container: HTMLElement,
|
||
field: FormField,
|
||
initial?: FieldValue,
|
||
): RenderedField {
|
||
const { controlEl, errorEl } = createFieldWrapper(container, field);
|
||
let currentRating = initial ? Number(initial) : 0;
|
||
|
||
const starsEl = controlEl.createDiv({ cls: 'ff-rating' });
|
||
const stars: HTMLElement[] = [];
|
||
|
||
function renderStars() {
|
||
for (let i = 0; i < 5; i++) {
|
||
stars[i].textContent = i < currentRating ? '\u2605' : '\u2606';
|
||
stars[i].toggleClass('ff-star-active', i < currentRating);
|
||
}
|
||
}
|
||
|
||
for (let i = 0; i < 5; i++) {
|
||
const star = starsEl.createSpan({ cls: 'ff-star' });
|
||
star.addEventListener('click', () => {
|
||
currentRating = i + 1;
|
||
renderStars();
|
||
});
|
||
stars.push(star);
|
||
}
|
||
|
||
renderStars();
|
||
|
||
return {
|
||
getValue: () => currentRating,
|
||
setValue: (v) => { currentRating = Number(v) || 0; renderStars(); },
|
||
setError: makeSetError(errorEl),
|
||
};
|
||
}
|
||
```
|
||
|
||
**Step 2: Verify it compiles**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: No errors.
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/ui/field-renderers.ts
|
||
git commit -m "feat: add field renderers for all 10 field types"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: FormModal (The Form UI)
|
||
|
||
**Files:**
|
||
- Create: `src/ui/form-modal.ts`
|
||
|
||
**Step 1: Write FormModal**
|
||
|
||
```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';
|
||
|
||
/**
|
||
* Modal that renders a form for the user to fill out and submit.
|
||
*/
|
||
export class FormModal extends Modal {
|
||
private renderedFields: Map<string, RenderedField> = new Map();
|
||
private processor: FormProcessor;
|
||
|
||
constructor(
|
||
app: App,
|
||
private form: FormDefinition,
|
||
private onSubmit?: (file: TFile) => void,
|
||
) {
|
||
super(app);
|
||
this.processor = new FormProcessor(app);
|
||
}
|
||
|
||
async onOpen(): Promise<void> {
|
||
const { contentEl } = this;
|
||
contentEl.addClass('ff-form-modal');
|
||
|
||
// Title
|
||
contentEl.createEl('h2', { text: this.form.name });
|
||
|
||
// If update mode with prompt, pick file first
|
||
if (this.form.mode === 'update' && this.form.targetFile === 'prompt') {
|
||
await this.renderWithFilePicker(contentEl);
|
||
} else {
|
||
this.renderForm(contentEl);
|
||
}
|
||
}
|
||
|
||
private async renderWithFilePicker(contentEl: HTMLElement): Promise<void> {
|
||
const file = await this.pickFile();
|
||
if (!file) {
|
||
this.close();
|
||
return;
|
||
}
|
||
this.renderForm(contentEl, file);
|
||
}
|
||
|
||
private renderForm(contentEl: HTMLElement, prefillFile?: TFile): void {
|
||
const fieldsContainer = contentEl.createDiv({ cls: 'ff-fields' });
|
||
|
||
// Get existing frontmatter for pre-filling (update mode)
|
||
let existingValues: Record<string, unknown> = {};
|
||
if (prefillFile) {
|
||
const cache = this.app.metadataCache.getFileCache(prefillFile);
|
||
existingValues = cache?.frontmatter ?? {};
|
||
}
|
||
|
||
// Render fields
|
||
for (const field of this.form.fields) {
|
||
const initial = existingValues[field.id] ?? field.defaultValue;
|
||
const rendered = renderField(this.app, fieldsContainer, field, initial as FieldValue);
|
||
this.renderedFields.set(field.id, rendered);
|
||
}
|
||
|
||
// Submit button
|
||
const footer = contentEl.createDiv({ cls: 'ff-form-footer' });
|
||
const submitBtn = footer.createEl('button', {
|
||
text: this.form.mode === 'create' ? 'Create Note' : 'Update Frontmatter',
|
||
cls: 'mod-cta',
|
||
});
|
||
|
||
submitBtn.addEventListener('click', async () => {
|
||
await this.handleSubmit(prefillFile);
|
||
});
|
||
}
|
||
|
||
private async handleSubmit(targetFile?: TFile): 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();
|
||
}
|
||
}
|
||
|
||
// Validate
|
||
const errors = validateForm(this.form.fields, values);
|
||
this.clearErrors();
|
||
if (errors.length > 0) {
|
||
this.showErrors(errors);
|
||
return;
|
||
}
|
||
|
||
// Resolve target file for update mode
|
||
let resolvedTarget = targetFile;
|
||
if (this.form.mode === 'update' && this.form.targetFile === 'active' && !resolvedTarget) {
|
||
const activeFile = this.app.workspace.getActiveFile();
|
||
if (!activeFile) {
|
||
new Notice('No active file to update.');
|
||
return;
|
||
}
|
||
resolvedTarget = activeFile;
|
||
}
|
||
|
||
try {
|
||
const file = await this.processor.process(this.form, values, resolvedTarget);
|
||
this.onSubmit?.(file);
|
||
this.close();
|
||
} catch (error) {
|
||
new Notice(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
||
}
|
||
}
|
||
|
||
private clearErrors(): void {
|
||
for (const rendered of this.renderedFields.values()) {
|
||
rendered.setError(null);
|
||
}
|
||
}
|
||
|
||
private showErrors(errors: ValidationError[]): void {
|
||
for (const error of errors) {
|
||
const rendered = this.renderedFields.get(error.fieldId);
|
||
rendered?.setError(error.message);
|
||
}
|
||
}
|
||
|
||
private pickFile(): Promise<TFile | null> {
|
||
return new Promise((resolve) => {
|
||
let resolved = false;
|
||
const modal = new FilePickerModal(this.app, (file) => {
|
||
resolved = true;
|
||
resolve(file);
|
||
});
|
||
modal.onClose = () => {
|
||
if (!resolved) resolve(null);
|
||
};
|
||
modal.open();
|
||
});
|
||
}
|
||
|
||
onClose(): void {
|
||
this.contentEl.empty();
|
||
this.renderedFields.clear();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Simple fuzzy file picker modal.
|
||
*/
|
||
class FilePickerModal extends FuzzySuggestModal<TFile> {
|
||
constructor(app: App, private onPick: (file: TFile) => void) {
|
||
super(app);
|
||
this.setPlaceholder('Pick a note to update...');
|
||
}
|
||
|
||
getItems(): TFile[] {
|
||
return this.app.vault.getMarkdownFiles();
|
||
}
|
||
|
||
getItemText(item: TFile): string {
|
||
return item.path;
|
||
}
|
||
|
||
onChooseItem(item: TFile): void {
|
||
this.onPick(item);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Verify it compiles**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: No errors.
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/ui/form-modal.ts
|
||
git commit -m "feat: add FormModal for rendering and submitting forms"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Form Picker Modal
|
||
|
||
**Files:**
|
||
- Create: `src/ui/form-picker-modal.ts`
|
||
|
||
**Step 1: Write FormPickerModal**
|
||
|
||
```typescript
|
||
import { App, FuzzySuggestModal } from 'obsidian';
|
||
import { FormDefinition } from '../types';
|
||
|
||
/**
|
||
* Fuzzy picker to choose which form to open.
|
||
*/
|
||
export class FormPickerModal extends FuzzySuggestModal<FormDefinition> {
|
||
constructor(
|
||
app: App,
|
||
private forms: FormDefinition[],
|
||
private onPick: (form: FormDefinition) => void,
|
||
) {
|
||
super(app);
|
||
this.setPlaceholder('Pick a form...');
|
||
}
|
||
|
||
getItems(): FormDefinition[] {
|
||
return this.forms;
|
||
}
|
||
|
||
getItemText(item: FormDefinition): string {
|
||
return item.name;
|
||
}
|
||
|
||
onChooseItem(item: FormDefinition): void {
|
||
this.onPick(item);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Commit**
|
||
|
||
```bash
|
||
git add src/ui/form-picker-modal.ts
|
||
git commit -m "feat: add FormPickerModal for quick form selection"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Sidebar View
|
||
|
||
**Files:**
|
||
- Create: `src/ui/form-sidebar.ts`
|
||
|
||
**Step 1: Write sidebar view**
|
||
|
||
```typescript
|
||
import { ItemView, WorkspaceLeaf, setIcon } from 'obsidian';
|
||
import { FormDefinition } from '../types';
|
||
import type FormfirePlugin from '../main';
|
||
|
||
export const FORM_SIDEBAR_VIEW_TYPE = 'formfire-sidebar';
|
||
|
||
export class FormSidebarView extends ItemView {
|
||
constructor(leaf: WorkspaceLeaf, private plugin: FormfirePlugin) {
|
||
super(leaf);
|
||
}
|
||
|
||
getViewType(): string {
|
||
return FORM_SIDEBAR_VIEW_TYPE;
|
||
}
|
||
|
||
getDisplayText(): string {
|
||
return 'Formfire';
|
||
}
|
||
|
||
getIcon(): string {
|
||
return 'file-input';
|
||
}
|
||
|
||
async onOpen(): Promise<void> {
|
||
this.render();
|
||
}
|
||
|
||
render(): void {
|
||
const { contentEl } = this;
|
||
contentEl.empty();
|
||
contentEl.addClass('ff-sidebar');
|
||
|
||
const header = contentEl.createDiv({ cls: 'ff-sidebar-header' });
|
||
header.createEl('h3', { text: 'Forms' });
|
||
|
||
const settingsBtn = header.createEl('button', {
|
||
cls: 'ff-sidebar-settings clickable-icon',
|
||
attr: { 'aria-label': 'Open settings' },
|
||
});
|
||
setIcon(settingsBtn, 'settings');
|
||
settingsBtn.addEventListener('click', () => {
|
||
(this.app as any).setting.open();
|
||
(this.app as any).setting.openTabById('formfire');
|
||
});
|
||
|
||
const forms = this.plugin.formStore.getAll();
|
||
|
||
if (forms.length === 0) {
|
||
contentEl.createDiv({
|
||
cls: 'ff-sidebar-empty',
|
||
text: 'No forms yet. Open settings to create one.',
|
||
});
|
||
return;
|
||
}
|
||
|
||
const list = contentEl.createDiv({ cls: 'ff-sidebar-list' });
|
||
for (const form of forms) {
|
||
const item = list.createDiv({ cls: 'ff-sidebar-item' });
|
||
|
||
item.createSpan({
|
||
cls: `ff-sidebar-badge ff-badge-${form.mode}`,
|
||
text: form.mode === 'create' ? 'C' : 'U',
|
||
});
|
||
|
||
item.createSpan({ cls: 'ff-sidebar-name', text: form.name });
|
||
|
||
item.addEventListener('click', () => {
|
||
this.plugin.openForm(form.id);
|
||
});
|
||
}
|
||
}
|
||
|
||
async onClose(): Promise<void> {
|
||
this.contentEl.empty();
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Verify it compiles**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: May have errors due to `FormfirePlugin` not yet having `formStore` and `openForm` — that's fine, we wire it up in Task 12.
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/ui/form-sidebar.ts
|
||
git commit -m "feat: add sidebar view with form list"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Settings Tab with Visual Form Builder
|
||
|
||
**Files:**
|
||
- Create: `src/ui/settings-tab.ts`
|
||
- Create: `src/ui/form-builder.ts`
|
||
|
||
This is the largest UI task. The Settings Tab shows a list of forms with CRUD buttons. The Form Builder is a modal for editing a single FormDefinition.
|
||
|
||
**Step 1: Write form-builder.ts (modal for editing a form definition)**
|
||
|
||
```typescript
|
||
import { App, Modal, Setting, Notice } from 'obsidian';
|
||
import { FormDefinition, FormField, FieldType } from '../types';
|
||
|
||
const FIELD_TYPES: { value: FieldType; label: string }[] = [
|
||
{ value: 'text', label: 'Text' },
|
||
{ value: 'textarea', label: 'Textarea' },
|
||
{ value: 'number', label: 'Number' },
|
||
{ value: 'toggle', label: 'Toggle' },
|
||
{ value: 'date', label: 'Date' },
|
||
{ value: 'dropdown', label: 'Dropdown' },
|
||
{ value: 'tags', label: 'Tags' },
|
||
{ value: 'note-link', label: 'Note Link' },
|
||
{ value: 'folder-picker', label: 'Folder Picker' },
|
||
{ value: 'rating', label: 'Rating (1-5)' },
|
||
];
|
||
|
||
/**
|
||
* Modal for creating or editing a FormDefinition.
|
||
*/
|
||
export class FormBuilderModal extends Modal {
|
||
private draft: FormDefinition;
|
||
|
||
constructor(
|
||
app: App,
|
||
form: FormDefinition,
|
||
private onSave: (form: FormDefinition) => void,
|
||
) {
|
||
super(app);
|
||
this.draft = structuredClone(form);
|
||
}
|
||
|
||
onOpen(): void {
|
||
const { contentEl } = this;
|
||
contentEl.addClass('ff-builder-modal');
|
||
this.render();
|
||
}
|
||
|
||
private render(): void {
|
||
const { contentEl } = this;
|
||
contentEl.empty();
|
||
|
||
contentEl.createEl('h2', { text: this.draft.name ? `Edit: ${this.draft.name}` : 'New Form' });
|
||
|
||
// --- General settings ---
|
||
new Setting(contentEl)
|
||
.setName('Form name')
|
||
.addText(t => t
|
||
.setValue(this.draft.name)
|
||
.setPlaceholder('e.g. Meeting Note')
|
||
.onChange(v => { this.draft.name = v; }));
|
||
|
||
new Setting(contentEl)
|
||
.setName('Mode')
|
||
.setDesc('Create a new note or update an existing one')
|
||
.addDropdown(d => d
|
||
.addOption('create', 'Create new note')
|
||
.addOption('update', 'Update frontmatter')
|
||
.setValue(this.draft.mode)
|
||
.onChange(v => {
|
||
this.draft.mode = v as 'create' | 'update';
|
||
this.render();
|
||
}));
|
||
|
||
// --- Mode-specific settings ---
|
||
if (this.draft.mode === 'create') {
|
||
new Setting(contentEl)
|
||
.setName('Target folder')
|
||
.addText(t => t
|
||
.setValue(this.draft.targetFolder ?? '')
|
||
.setPlaceholder('/')
|
||
.onChange(v => { this.draft.targetFolder = v; }));
|
||
|
||
new Setting(contentEl)
|
||
.setName('File name template')
|
||
.setDesc('Use {{fieldId}} for variables, e.g. {{date}}-{{title}}')
|
||
.addText(t => t
|
||
.setValue(this.draft.fileNameTemplate ?? '')
|
||
.setPlaceholder('{{date}}-{{title}}')
|
||
.onChange(v => { this.draft.fileNameTemplate = v; }));
|
||
|
||
new Setting(contentEl)
|
||
.setName('Body template')
|
||
.setDesc('Markdown content with {{variables}}')
|
||
.addTextArea(t => {
|
||
t.setValue(this.draft.bodyTemplate ?? '')
|
||
.setPlaceholder('# {{title}}\n\n')
|
||
.onChange(v => { this.draft.bodyTemplate = v; });
|
||
t.inputEl.rows = 6;
|
||
t.inputEl.addClass('ff-builder-textarea');
|
||
});
|
||
} else {
|
||
new Setting(contentEl)
|
||
.setName('Target file')
|
||
.addDropdown(d => d
|
||
.addOption('active', 'Active file')
|
||
.addOption('prompt', 'Prompt to choose')
|
||
.setValue(this.draft.targetFile ?? 'active')
|
||
.onChange(v => { this.draft.targetFile = v as 'active' | 'prompt'; }));
|
||
}
|
||
|
||
// --- Fields ---
|
||
const fieldsSection = contentEl.createDiv({ cls: 'ff-builder-fields' });
|
||
fieldsSection.createEl('h3', { text: 'Fields' });
|
||
|
||
for (let i = 0; i < this.draft.fields.length; i++) {
|
||
this.renderFieldEditor(fieldsSection, i);
|
||
}
|
||
|
||
// Add field button
|
||
const addBtn = fieldsSection.createEl('button', {
|
||
text: '+ Add field',
|
||
cls: 'ff-builder-add-btn',
|
||
});
|
||
addBtn.addEventListener('click', () => {
|
||
this.draft.fields.push({
|
||
id: `field_${Date.now()}`,
|
||
label: '',
|
||
type: 'text',
|
||
required: false,
|
||
});
|
||
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 is required.');
|
||
return;
|
||
}
|
||
this.onSave(this.draft);
|
||
this.close();
|
||
});
|
||
}
|
||
|
||
private renderFieldEditor(container: HTMLElement, index: number): void {
|
||
const field = this.draft.fields[index];
|
||
const fieldEl = container.createDiv({ cls: 'ff-builder-field' });
|
||
|
||
// Header row with reorder/delete
|
||
const headerRow = fieldEl.createDiv({ cls: 'ff-builder-field-header' });
|
||
headerRow.createSpan({
|
||
cls: 'ff-builder-field-num',
|
||
text: `#${index + 1}`,
|
||
});
|
||
|
||
const actions = headerRow.createDiv({ cls: 'ff-builder-field-actions' });
|
||
|
||
if (index > 0) {
|
||
const upBtn = actions.createEl('button', { text: '\u2191', cls: 'ff-builder-action-btn' });
|
||
upBtn.addEventListener('click', () => {
|
||
[this.draft.fields[index - 1], this.draft.fields[index]] =
|
||
[this.draft.fields[index], this.draft.fields[index - 1]];
|
||
this.render();
|
||
});
|
||
}
|
||
|
||
if (index < this.draft.fields.length - 1) {
|
||
const downBtn = actions.createEl('button', { text: '\u2193', cls: 'ff-builder-action-btn' });
|
||
downBtn.addEventListener('click', () => {
|
||
[this.draft.fields[index], this.draft.fields[index + 1]] =
|
||
[this.draft.fields[index + 1], this.draft.fields[index]];
|
||
this.render();
|
||
});
|
||
}
|
||
|
||
const deleteBtn = actions.createEl('button', { text: '\u00d7', cls: 'ff-builder-action-btn ff-builder-delete' });
|
||
deleteBtn.addEventListener('click', () => {
|
||
this.draft.fields.splice(index, 1);
|
||
this.render();
|
||
});
|
||
|
||
// Field settings
|
||
new Setting(fieldEl)
|
||
.setName('Label')
|
||
.addText(t => t
|
||
.setValue(field.label)
|
||
.setPlaceholder('Display name')
|
||
.onChange(v => { field.label = v; }));
|
||
|
||
new Setting(fieldEl)
|
||
.setName('Property key')
|
||
.setDesc('Frontmatter property name')
|
||
.addText(t => t
|
||
.setValue(field.id)
|
||
.setPlaceholder('e.g. title, author, status')
|
||
.onChange(v => { field.id = v; }));
|
||
|
||
new Setting(fieldEl)
|
||
.setName('Type')
|
||
.addDropdown(d => {
|
||
for (const ft of FIELD_TYPES) {
|
||
d.addOption(ft.value, ft.label);
|
||
}
|
||
d.setValue(field.type)
|
||
.onChange(v => {
|
||
field.type = v as FieldType;
|
||
this.render();
|
||
});
|
||
});
|
||
|
||
new Setting(fieldEl)
|
||
.setName('Required')
|
||
.addToggle(t => t
|
||
.setValue(field.required)
|
||
.onChange(v => { field.required = v; }));
|
||
|
||
// Type-specific settings
|
||
if (field.type === 'dropdown' || field.type === 'tags') {
|
||
new Setting(fieldEl)
|
||
.setName('Options')
|
||
.setDesc('Comma-separated list')
|
||
.addText(t => t
|
||
.setValue((field.options ?? []).join(', '))
|
||
.setPlaceholder('Option A, Option B, Option C')
|
||
.onChange(v => {
|
||
field.options = v.split(',').map(s => s.trim()).filter(Boolean);
|
||
}));
|
||
}
|
||
|
||
if (field.type === 'note-link') {
|
||
new Setting(fieldEl)
|
||
.setName('Restrict to folder')
|
||
.addText(t => t
|
||
.setValue(field.folder ?? '')
|
||
.setPlaceholder('Leave empty for all notes')
|
||
.onChange(v => { field.folder = v || undefined; }));
|
||
}
|
||
|
||
new Setting(fieldEl)
|
||
.setName('Placeholder')
|
||
.addText(t => t
|
||
.setValue(field.placeholder ?? '')
|
||
.onChange(v => { field.placeholder = v || undefined; }));
|
||
}
|
||
|
||
onClose(): void {
|
||
this.contentEl.empty();
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Write settings-tab.ts**
|
||
|
||
```typescript
|
||
import { App, PluginSettingTab, Setting } from 'obsidian';
|
||
import type FormfirePlugin from '../main';
|
||
import { FormBuilderModal } from './form-builder';
|
||
|
||
export class FormfireSettingTab extends PluginSettingTab {
|
||
constructor(app: App, private plugin: FormfirePlugin) {
|
||
super(app, plugin);
|
||
}
|
||
|
||
display(): void {
|
||
const { containerEl } = this;
|
||
containerEl.empty();
|
||
|
||
containerEl.createEl('h1', { text: 'Formfire' });
|
||
containerEl.createEl('p', {
|
||
text: 'Create forms for structured data input. Forms can create new notes or update existing frontmatter.',
|
||
cls: 'setting-item-description',
|
||
});
|
||
|
||
// Form list
|
||
const forms = this.plugin.formStore.getAll();
|
||
|
||
if (forms.length === 0) {
|
||
containerEl.createDiv({
|
||
cls: 'ff-settings-empty',
|
||
text: 'No forms yet. Click the button below to create one.',
|
||
});
|
||
}
|
||
|
||
for (const form of forms) {
|
||
new Setting(containerEl)
|
||
.setName(form.name)
|
||
.setDesc(`${form.mode === 'create' ? 'Creates new note' : 'Updates frontmatter'} \u2022 ${form.fields.length} field(s)`)
|
||
.addButton(btn => btn
|
||
.setButtonText('Edit')
|
||
.onClick(() => {
|
||
new FormBuilderModal(this.app, form, async (updated) => {
|
||
this.plugin.formStore.update(form.id, updated);
|
||
await this.plugin.saveSettings();
|
||
this.plugin.refreshCommands();
|
||
this.display();
|
||
}).open();
|
||
}))
|
||
.addButton(btn => btn
|
||
.setButtonText('Duplicate')
|
||
.onClick(async () => {
|
||
this.plugin.formStore.duplicate(form.id);
|
||
await this.plugin.saveSettings();
|
||
this.plugin.refreshCommands();
|
||
this.display();
|
||
}))
|
||
.addButton(btn => btn
|
||
.setButtonText('Delete')
|
||
.setWarning()
|
||
.onClick(async () => {
|
||
this.plugin.formStore.remove(form.id);
|
||
await this.plugin.saveSettings();
|
||
this.plugin.refreshCommands();
|
||
this.display();
|
||
}));
|
||
}
|
||
|
||
// Add form button
|
||
new Setting(containerEl)
|
||
.addButton(btn => btn
|
||
.setButtonText('+ New Form')
|
||
.setCta()
|
||
.onClick(() => {
|
||
const blank = this.plugin.formStore.createBlank();
|
||
new FormBuilderModal(this.app, blank, async (created) => {
|
||
this.plugin.formStore.add(created);
|
||
await this.plugin.saveSettings();
|
||
this.plugin.refreshCommands();
|
||
this.display();
|
||
}).open();
|
||
}));
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 3: Verify it compiles**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: May error on `FormfirePlugin` references — that's fine, wired up in Task 12.
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/ui/settings-tab.ts src/ui/form-builder.ts
|
||
git commit -m "feat: add settings tab with visual form builder"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Wire Everything in main.ts
|
||
|
||
**Files:**
|
||
- Modify: `src/main.ts`
|
||
|
||
This is the final wiring task. Replace the minimal main.ts with the full plugin class.
|
||
|
||
**Step 1: Write the complete main.ts**
|
||
|
||
```typescript
|
||
import { Plugin, Notice, WorkspaceLeaf } from 'obsidian';
|
||
import { FormfireSettings, DEFAULT_SETTINGS, deepMerge } from './types';
|
||
import { FormStore } from './core/form-store';
|
||
import { FormModal } from './ui/form-modal';
|
||
import { FormPickerModal } from './ui/form-picker-modal';
|
||
import { FormfireSettingTab } from './ui/settings-tab';
|
||
import { FormSidebarView, FORM_SIDEBAR_VIEW_TYPE } from './ui/form-sidebar';
|
||
|
||
export default class FormfirePlugin extends Plugin {
|
||
settings!: FormfireSettings;
|
||
formStore!: FormStore;
|
||
|
||
private dynamicCommandIds: string[] = [];
|
||
|
||
async onload(): Promise<void> {
|
||
console.log('[Formfire] Loading plugin...');
|
||
|
||
await this.loadSettings();
|
||
this.formStore = new FormStore(this.settings);
|
||
|
||
// Settings tab
|
||
this.addSettingTab(new FormfireSettingTab(this.app, this));
|
||
|
||
// Sidebar view
|
||
this.registerView(
|
||
FORM_SIDEBAR_VIEW_TYPE,
|
||
(leaf) => new FormSidebarView(leaf, this),
|
||
);
|
||
|
||
// Ribbon icon
|
||
this.addRibbonIcon('file-input', 'Formfire: Open form', () => {
|
||
this.openFormPicker();
|
||
});
|
||
|
||
// Static commands
|
||
this.addCommand({
|
||
id: 'open-form',
|
||
name: 'Open form',
|
||
callback: () => this.openFormPicker(),
|
||
});
|
||
|
||
this.addCommand({
|
||
id: 'open-sidebar',
|
||
name: 'Open sidebar',
|
||
callback: () => this.activateSidebar(),
|
||
});
|
||
|
||
// Dynamic per-form commands
|
||
this.registerDynamicCommands();
|
||
}
|
||
|
||
onunload(): void {
|
||
console.log('[Formfire] Unloading plugin.');
|
||
}
|
||
|
||
async loadSettings(): Promise<void> {
|
||
const saved = (await this.loadData()) as Partial<FormfireSettings> | null;
|
||
this.settings = deepMerge(DEFAULT_SETTINGS, saved ?? {});
|
||
}
|
||
|
||
async saveSettings(): Promise<void> {
|
||
await this.saveData(this.settings);
|
||
this.refreshSidebar();
|
||
}
|
||
|
||
/** Open the form picker, then the chosen form. */
|
||
openFormPicker(): void {
|
||
const forms = this.formStore.getAll();
|
||
if (forms.length === 0) {
|
||
new Notice('No forms defined. Open Formfire settings to create one.');
|
||
return;
|
||
}
|
||
if (forms.length === 1) {
|
||
this.openForm(forms[0].id);
|
||
return;
|
||
}
|
||
new FormPickerModal(this.app, forms, (form) => {
|
||
this.openForm(form.id);
|
||
}).open();
|
||
}
|
||
|
||
/** Open a specific form by ID. */
|
||
openForm(formId: string): void {
|
||
const form = this.formStore.getById(formId);
|
||
if (!form) {
|
||
new Notice('Form not found.');
|
||
return;
|
||
}
|
||
new FormModal(this.app, form).open();
|
||
}
|
||
|
||
/** Register a command for each form for direct access. */
|
||
registerDynamicCommands(): void {
|
||
for (const form of this.formStore.getAll()) {
|
||
const commandId = `open-form-${form.id}`;
|
||
this.addCommand({
|
||
id: commandId,
|
||
name: `Open form: ${form.name}`,
|
||
callback: () => this.openForm(form.id),
|
||
});
|
||
this.dynamicCommandIds.push(commandId);
|
||
}
|
||
}
|
||
|
||
/** Called after form CRUD to re-register commands. */
|
||
refreshCommands(): void {
|
||
// Obsidian doesn't support removing commands at runtime.
|
||
// New commands are registered on next plugin reload.
|
||
// Removed forms' commands become no-ops (form not found).
|
||
}
|
||
|
||
/** Activate or reveal the sidebar view. */
|
||
async activateSidebar(): Promise<void> {
|
||
const existing = this.app.workspace.getLeavesOfType(FORM_SIDEBAR_VIEW_TYPE);
|
||
if (existing.length > 0) {
|
||
this.app.workspace.revealLeaf(existing[0]);
|
||
return;
|
||
}
|
||
const leaf = this.app.workspace.getRightLeaf(false);
|
||
if (leaf) {
|
||
await leaf.setViewState({ type: FORM_SIDEBAR_VIEW_TYPE, active: true });
|
||
this.app.workspace.revealLeaf(leaf);
|
||
}
|
||
}
|
||
|
||
/** Re-render sidebar if it's open. */
|
||
private refreshSidebar(): void {
|
||
const leaves = this.app.workspace.getLeavesOfType(FORM_SIDEBAR_VIEW_TYPE);
|
||
for (const leaf of leaves) {
|
||
const view = leaf.view;
|
||
if (view instanceof FormSidebarView) {
|
||
view.render();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Verify full build**
|
||
|
||
Run: `npm run build`
|
||
Expected: `main.js` created, no errors.
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/main.ts
|
||
git commit -m "feat: wire up main plugin with commands, sidebar, settings"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: Styles
|
||
|
||
**Files:**
|
||
- Create: `styles.css`
|
||
|
||
Use the **frontend-design skill** for polished, production-grade CSS. The CSS must cover these class selectors (all prefixed `ff-`):
|
||
|
||
**Form fields:** `.ff-field`, `.ff-field-label`, `.ff-field-required`, `.ff-field-control`, `.ff-field-error`, `.ff-input`, `.ff-textarea`, `.ff-dropdown`
|
||
|
||
**Tags field:** `.ff-tags-container`, `.ff-tag-chip`, `.ff-tag-remove`, `.ff-tags-input`
|
||
|
||
**Autocomplete:** `.ff-suggest-list`, `.ff-suggest-item`
|
||
|
||
**Rating:** `.ff-rating`, `.ff-star`, `.ff-star-active`
|
||
|
||
**Form modal:** `.ff-form-modal`, `.ff-fields`, `.ff-form-footer`
|
||
|
||
**Form builder:** `.ff-builder-modal`, `.ff-builder-fields`, `.ff-builder-field`, `.ff-builder-field-header`, `.ff-builder-field-actions`, `.ff-builder-action-btn`, `.ff-builder-delete`, `.ff-builder-add-btn`, `.ff-builder-footer`, `.ff-builder-textarea`, `.ff-builder-field-num`
|
||
|
||
**Sidebar:** `.ff-sidebar`, `.ff-sidebar-header`, `.ff-sidebar-settings`, `.ff-sidebar-list`, `.ff-sidebar-item`, `.ff-sidebar-badge`, `.ff-badge-create`, `.ff-badge-update`, `.ff-sidebar-name`, `.ff-sidebar-empty`
|
||
|
||
**Settings:** `.ff-settings-empty`
|
||
|
||
All colors must use Obsidian CSS variables (`--background-primary`, `--text-normal`, `--interactive-accent`, `--background-modifier-border`, `--text-muted`, `--text-faint`, `--text-error`, `--background-modifier-hover`, `--background-secondary`).
|
||
|
||
**Step 1: Invoke frontend-design skill to create styles.css**
|
||
|
||
**Step 2: Verify build with styles**
|
||
|
||
Run: `npm run build`
|
||
Expected: No errors. `styles.css` is picked up by Obsidian directly (not bundled by esbuild).
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add styles.css
|
||
git commit -m "feat: add Formfire styles"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: Manual Integration Test
|
||
|
||
**No code to write.** This is a verification task.
|
||
|
||
**Step 1: Install plugin in Obsidian vault**
|
||
|
||
Copy `main.js`, `manifest.json`, and `styles.css` to `.obsidian/plugins/formfire/` in a test vault.
|
||
|
||
**Step 2: Enable plugin**
|
||
|
||
Open Obsidian -> Settings -> Community Plugins -> Enable Formfire.
|
||
|
||
**Step 3: Test form creation flow**
|
||
|
||
1. Open Formfire settings
|
||
2. Click "+ New Form"
|
||
3. Set name: "Meeting Note"
|
||
4. Mode: Create
|
||
5. Target folder: "meetings"
|
||
6. File name template: `{{date}}-{{title}}`
|
||
7. Add fields: title (text, required), date (date), attendees (tags), status (dropdown: planned/done/cancelled), notes (textarea)
|
||
8. Body template: `# {{title}}\n\n## Attendees\n{{attendees}}\n\n## Notes\n`
|
||
9. Save
|
||
|
||
**Step 4: Test form submission**
|
||
|
||
1. Open command palette -> "Formfire: Open form: Meeting Note"
|
||
2. Fill out the form
|
||
3. Submit
|
||
4. Verify: new note created in meetings/ with correct frontmatter and body
|
||
|
||
**Step 5: Test update mode**
|
||
|
||
1. Create a second form "Update Status" with mode: update, targetFile: active
|
||
2. Open a note, run the command
|
||
3. Verify: frontmatter properties updated correctly
|
||
|
||
**Step 6: Test sidebar**
|
||
|
||
1. Open command palette -> "Formfire: Open sidebar"
|
||
2. Verify: sidebar shows both forms
|
||
3. Click a form -> verify modal opens
|
||
|
||
**Step 7: Commit (if any fixes needed)**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "fix: integration test fixes"
|
||
```
|
||
|
||
---
|
||
|
||
## Summary
|
||
|
||
| Task | Component | Size |
|
||
|------|-----------|------|
|
||
| 1 | Project scaffolding | Small |
|
||
| 2 | Type definitions | Small |
|
||
| 3 | Template engine | Small |
|
||
| 4 | Validators | Small |
|
||
| 5 | FormStore | Small |
|
||
| 6 | FormProcessor | Medium |
|
||
| 7 | Field renderers (10 types) | Large |
|
||
| 8 | FormModal | Medium |
|
||
| 9 | FormPickerModal | Small |
|
||
| 10 | Sidebar view | Medium |
|
||
| 11 | Settings tab + form builder | Large |
|
||
| 12 | Main plugin wiring | Medium |
|
||
| 13 | Styles (frontend-design skill) | Medium |
|
||
| 14 | Manual integration test | Medium |
|