From 103ada6c06ed8675215c8858e345d3422fbef95d Mon Sep 17 00:00:00 2001 From: Danielo Rodriguez Date: Fri, 27 Oct 2023 06:33:04 +0200 Subject: [PATCH 01/15] WIP: manage forms as svelte component --- .prettierrc | 5 ++ package-lock.json | 31 +++++++- package.json | 2 + src/std/index.ts | 4 +- src/views/ManageForms.svelte | 135 +++++++++++++++++++++++++++++++++++ src/views/ManageFormsView.ts | 44 ++++++++++-- 6 files changed, 214 insertions(+), 7 deletions(-) create mode 100644 .prettierrc create mode 100644 src/views/ManageForms.svelte diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..07d11491 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "plugins": [ + "prettier-plugin-svelte" + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f7ea842a..e985825c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-modal-form", - "version": "1.21.0", + "version": "1.22.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-modal-form", - "version": "1.21.0", + "version": "1.22.1", "license": "MIT", "dependencies": { "fp-ts": "^2.16.1", @@ -25,6 +25,8 @@ "esbuild-svelte": "^0.8.0", "jest": "^29.7.0", "obsidian": "^1.4.11", + "prettier": "^3.0.3", + "prettier-plugin-svelte": "^3.0.3", "svelte": "^4.2.0", "svelte-check": "^3.5.2", "svelte-preprocess": "^5.0.4", @@ -4940,6 +4942,31 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz", + "integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.0.3.tgz", + "integrity": "sha512-dLhieh4obJEK1hnZ6koxF+tMUrZbV5YGvRpf2+OADyanjya5j0z1Llo8iGwiHmFWZVG/hLEw/AJD5chXd9r3XA==", + "dev": true, + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^3.2.0 || ^4.0.0-next.0" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", diff --git a/package.json b/package.json index 290eea66..86b6c888 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "esbuild-svelte": "^0.8.0", "jest": "^29.7.0", "obsidian": "^1.4.11", + "prettier": "^3.0.3", + "prettier-plugin-svelte": "^3.0.3", "svelte": "^4.2.0", "svelte-check": "^3.5.2", "svelte-preprocess": "^5.0.4", diff --git a/src/std/index.ts b/src/std/index.ts index 2d2a2cbf..7e316a5b 100644 --- a/src/std/index.ts +++ b/src/std/index.ts @@ -1,6 +1,6 @@ import { pipe as p } from "fp-ts/function"; import { partitionMap } from "fp-ts/Array"; -import { isLeft, isRight, tryCatchK, map, getOrElse } from "fp-ts/Either"; +import { isLeft, isRight, tryCatchK, map, getOrElse, right, left } from "fp-ts/Either"; import { ValiError, parse as parseV } from "valibot"; export const pipe = p @@ -11,6 +11,8 @@ export const A = { export const E = { isLeft, isRight, + left, + right, tryCatchK, getOrElse, map diff --git a/src/views/ManageForms.svelte b/src/views/ManageForms.svelte new file mode 100644 index 00000000..a63d4c68 --- /dev/null +++ b/src/views/ManageForms.svelte @@ -0,0 +1,135 @@ + + +

Manage forms

+ + + +
+ {#each forms as form} +
+

{form.name}

+
+ + + + +
+
+ {/each} +
+ + diff --git a/src/views/ManageFormsView.ts b/src/views/ManageFormsView.ts index 0224820a..56a8cbbb 100644 --- a/src/views/ManageFormsView.ts +++ b/src/views/ManageFormsView.ts @@ -1,6 +1,9 @@ -import { MigrationError } from "src/core/formDefinition"; +import { FormDefinition, MigrationError } from "src/core/formDefinition"; +import ManageForms from './ManageForms.svelte' import ModalFormPlugin from "../main"; +import * as A from 'fp-ts/Array' import { ItemView, Notice, Setting, WorkspaceLeaf } from "obsidian"; +import { E, pipe } from "@std"; export const MANAGE_FORMS_VIEW = "modal-form-manage-forms-view"; @@ -9,6 +12,7 @@ export const MANAGE_FORMS_VIEW = "modal-form-manage-forms-view"; * Manage existing forms and create new ones */ export class ManageFormsView extends ItemView { + component!: ManageForms; constructor(readonly leaf: WorkspaceLeaf, readonly plugin: ModalFormPlugin) { super(leaf); this.icon = "documents"; @@ -26,9 +30,40 @@ export class ManageFormsView extends ItemView { // console.log('On open manage forms'); const container = this.containerEl.children[1] || this.containerEl.createDiv(); container.empty(); - container.createEl("h3", { text: "Manage forms" }); - this.renderControls(container.createDiv()); - await this.renderForms(container.createDiv()); + + const settings = await this.plugin.getSettings(); + const allForms = settings.formDefinitions; + const { left: invalidForms, right: forms } = pipe( + allForms, + A.partitionMap((form) => { + if (form instanceof MigrationError) { + return E.left(form); + } else { + return E.right(form); + } + })); + this.component = new ManageForms({ + target: container, + props: { + forms, + invalidForms, + createNewForm: () => { + this.plugin.createNewForm(); + }, + editForm: (formName: string) => { + this.plugin.editForm(formName); + }, + deleteForm: (formName: string) => { + this.plugin.deleteForm(formName); + }, + duplicateForm: (form: FormDefinition) => { + this.plugin.duplicateForm(form); + } + } + }) + // container.createEl("h3", { text: "Manage forms" }); + // this.renderControls(container.createDiv()); + // await this.renderForms(container.createDiv()); } renderControls(root: HTMLElement) { @@ -94,6 +129,7 @@ export class ManageFormsView extends ItemView { } async onClose() { + this.component.$destroy(); } } From a06f88c0cf336c82114bb2ca0dca07c22dd970c8 Mon Sep 17 00:00:00 2001 From: Danielo Rodriguez Date: Fri, 27 Oct 2023 11:14:40 +0200 Subject: [PATCH 02/15] WIP: show invalid forms UI --- src/views/ManageForms.svelte | 172 ++++++++++++++------------- src/views/ManageFormsView.ts | 5 + src/views/components/Button.svelte | 28 +++++ src/views/components/KeyValue.svelte | 20 ++++ styles.css | 1 + 5 files changed, 143 insertions(+), 83 deletions(-) create mode 100644 src/views/components/Button.svelte create mode 100644 src/views/components/KeyValue.svelte diff --git a/src/views/ManageForms.svelte b/src/views/ManageForms.svelte index a63d4c68..0c70a432 100644 --- a/src/views/ManageForms.svelte +++ b/src/views/ManageForms.svelte @@ -1,16 +1,25 @@ -

Manage forms

- - +
+

Manage forms

+ + {#if invalidForms.length} + +

+ Please take a look at the invalid forms section for details and + potential fixes. +

+ {/if} +
{#each forms as form}
-

{form.name}

+

{form.name}

+
+ {#each Object.entries(form) as [key, value]} + {#if key !== "name"} + + {Array.isArray(value) + ? value.length + : value} + + {/if} + {/each} + + + {#each form.fields as field} + {field.name} + {/each} + +
- - - + + - +
{/each} + {#if invalidForms.length} + +
+ {#each invalidForms as form} +
+

{form.name}

+ {#each form.error.issues as error} + + {error.message} + + {/each} +
+ {/each} +
+ {/if}
diff --git a/src/views/ManageFormsView.ts b/src/views/ManageFormsView.ts index 56a8cbbb..ed5f8f8d 100644 --- a/src/views/ManageFormsView.ts +++ b/src/views/ManageFormsView.ts @@ -58,7 +58,12 @@ export class ManageFormsView extends ItemView { }, duplicateForm: (form: FormDefinition) => { this.plugin.duplicateForm(form); + }, + copyFormToClipboard: async (form: FormDefinition) => { + await navigator.clipboard.writeText(JSON.stringify(form, null, 2)); + new Notice("Form has been copied to the clipboard"); } + } }) // container.createEl("h3", { text: "Manage forms" }); diff --git a/src/views/components/Button.svelte b/src/views/components/Button.svelte new file mode 100644 index 00000000..ed1cf03a --- /dev/null +++ b/src/views/components/Button.svelte @@ -0,0 +1,28 @@ + + + diff --git a/src/views/components/KeyValue.svelte b/src/views/components/KeyValue.svelte new file mode 100644 index 00000000..f266a39c --- /dev/null +++ b/src/views/components/KeyValue.svelte @@ -0,0 +1,20 @@ + + +
+ {key}: + +
+ + diff --git a/styles.css b/styles.css index f1c0fa70..a03e55c8 100644 --- a/styles.css +++ b/styles.css @@ -9,6 +9,7 @@ If your plugin does not need CSS, delete this file. :root { --mf-spacing: 0.75rem; + --mf-spacing2: 1.5rem; } /* Utilities to remove styles from native obsidian elements when wrapped like this From 0e9d94ca4818cfcff0dde525c3a7abfbb933cec8 Mon Sep 17 00:00:00 2001 From: Danielo Rodriguez Date: Sat, 28 Oct 2023 09:00:51 +0200 Subject: [PATCH 03/15] refactor: move schemas to a separate file --- src/API.ts | 3 +- src/core/formDefinition.test.ts | 30 +++--- src/core/formDefinition.ts | 167 ++---------------------------- src/core/formDefinitionSchema.ts | 172 +++++++++++++++++++++++++++++++ src/core/settings.ts | 5 +- src/main.ts | 3 +- src/views/ManageForms.svelte | 6 +- src/views/ManageFormsView.ts | 3 +- 8 files changed, 210 insertions(+), 179 deletions(-) create mode 100644 src/core/formDefinitionSchema.ts diff --git a/src/API.ts b/src/API.ts index 6f95d3fb..72fc329f 100644 --- a/src/API.ts +++ b/src/API.ts @@ -1,6 +1,7 @@ import { App } from "obsidian"; -import { MigrationError, type FormDefinition, type FormOptions } from "./core/formDefinition"; +import { type FormDefinition, type FormOptions } from "./core/formDefinition"; +import { MigrationError } from "./core/formDefinitionSchema"; import FormResult from "./core/FormResult"; import { exampleModalDefinition } from "./exampleModalDefinition"; import ModalFormPlugin from "./main"; diff --git a/src/core/formDefinition.test.ts b/src/core/formDefinition.test.ts index a1953e91..40bb4efa 100644 --- a/src/core/formDefinition.test.ts +++ b/src/core/formDefinition.test.ts @@ -5,14 +5,14 @@ import { isInputSelectFixed, isInputSlider, isSelectFromNotes, - MultiselectSchema, } from "./formDefinition"; +import { MultiselectSchema } from "./formDefinitionSchema"; import { parse } from "valibot"; describe("isDataViewSource", () => { it("should return true for valid inputDataviewSource objects", () => { expect( - isDataViewSource({ type: "dataview", query: "some query" }) + isDataViewSource({ type: "dataview", query: "some query" }), ).toBe(true); }); @@ -20,7 +20,7 @@ describe("isDataViewSource", () => { expect(isDataViewSource({ type: "dataview" })).toBe(false); expect(isDataViewSource({ type: "dataview", query: 123 })).toBe(false); expect(isDataViewSource({ type: "select", query: "some query" })).toBe( - false + false, ); }); }); @@ -28,17 +28,17 @@ describe("isDataViewSource", () => { describe("isInputNoteFromFolder", () => { it("should return true for valid inputNoteFromFolder objects", () => { expect( - isInputNoteFromFolder({ type: "note", folder: "some folder" }) + isInputNoteFromFolder({ type: "note", folder: "some folder" }), ).toBe(true); }); it("should return false for invalid inputNoteFromFolder objects", () => { expect(isInputNoteFromFolder({ type: "note" })).toBe(false); expect(isInputNoteFromFolder({ type: "note", folder: 123 })).toBe( - false + false, ); expect( - isInputNoteFromFolder({ type: "select", folder: "some folder" }) + isInputNoteFromFolder({ type: "select", folder: "some folder" }), ).toBe(false); }); }); @@ -50,27 +50,27 @@ describe("isInputSelectFixed", () => { type: "select", source: "fixed", options: [{ value: "1", label: "Option 1" }], - }) + }), ).toBe(true); }); it("should return false for invalid inputSelectFixed objects", () => { expect(isInputSelectFixed({ type: "select", source: "fixed" })).toBe( - false + false, ); expect( isInputSelectFixed({ type: "select", source: "fixed", options: [{ value: "1", label: 123 }], - }) + }), ).toBe(false); expect( isInputSelectFixed({ type: "select", source: "notes", options: [{ value: "1", label: "Option 1" }], - }) + }), ).toBe(false); }); }); @@ -83,7 +83,7 @@ describe("isInputSlider", () => { it("should return false for invalid inputSlider objects", () => { expect(isInputSlider({ type: "slider" })).toBe(false); expect(isInputSlider({ type: "slider", min: "0", max: 10 })).toBe( - false + false, ); expect(isInputSlider({ type: "select", min: 0, max: 10 })).toBe(false); }); @@ -96,19 +96,19 @@ describe("isSelectFromNotes", () => { type: "select", source: "notes", folder: "some folder", - }) + }), ).toBe(true); }); it("should return false for invalid selectFromNotes objects", () => { expect(isSelectFromNotes({ type: "select", source: "notes" })).toBe( - false + false, ); expect( - isSelectFromNotes({ type: "select", source: "notes", folder: 123 }) + isSelectFromNotes({ type: "select", source: "notes", folder: 123 }), ).toBe(false); expect(isSelectFromNotes({ type: "note", folder: "some folder" })).toBe( - false + false, ); }); }); diff --git a/src/core/formDefinition.ts b/src/core/formDefinition.ts index 2fbcc34d..93186f1d 100644 --- a/src/core/formDefinition.ts +++ b/src/core/formDefinition.ts @@ -1,168 +1,23 @@ -import { parse, pipe } from "@std"; -import * as E from "fp-ts/Either"; -import { object, number, literal, type Output, is, array, string, union, optional, safeParse, minLength, toTrimmed, merge, unknown, ValiError } from "valibot"; -/** - * Here are the core logic around the main domain of the plugin, - * which is the form definition. - * Here are the types, validators, rules etc. - */ - -function nonEmptyString(name: string) { - return string(`${name} should be a string`, [toTrimmed(), minLength(1, `${name} should not be empty`)]) -} - -const FieldTypeSchema = union([literal("text"), literal("number"), literal("date"), literal("time"), literal("datetime"), literal("textarea"), literal("toggle")]); -type FieldType = Output; - -const fieldTypeMap: Record = { - text: "Text", - number: "Number", - date: "Date", - time: "Time", - datetime: "DateTime", - textarea: "Text area", - toggle: "Toggle" -}; -//=========== Schema definitions -const SelectFromNotesSchema = object({ type: literal("select"), source: literal("notes"), folder: nonEmptyString('folder name') }); -const InputSliderSchema = object({ type: literal("slider"), min: number(), max: number() }) -const InputNoteFromFolderSchema = object({ type: literal("note"), folder: nonEmptyString('folder name') }); -const InputDataviewSourceSchema = object({ type: literal("dataview"), query: nonEmptyString('dataview query') }); -const BasicInputSchema = object({ type: FieldTypeSchema }); - -const InputSelectFixedSchema = object({ - type: literal("select"), - source: literal("fixed"), - options: array(object({ - value: string([toTrimmed()]), label: string() - })) -}); - -const MultiSelectNotesSchema = object({ - type: literal("multiselect"), source: literal("notes"), - folder: nonEmptyString('multi select source folder') -}); -const MultiSelectFixedSchema = object({ type: literal("multiselect"), source: literal("fixed"), multi_select_options: array(string()) }); -export const MultiselectSchema = union([MultiSelectNotesSchema, MultiSelectFixedSchema]); - -const InputTypeSchema = union([ - BasicInputSchema, - InputNoteFromFolderSchema, - InputSliderSchema, - SelectFromNotesSchema, - InputDataviewSourceSchema, - InputSelectFixedSchema, - MultiselectSchema -]); - -const FieldDefinitionSchema = object({ - name: nonEmptyString('field name'), - label: optional(string()), - description: string(), - input: InputTypeSchema -}); - -const FieldListSchema = array(FieldDefinitionSchema); - -/** - * This is the most basic representation of a form definition. - * It is not useful for anything other than being the base for - * other versioned schemas. - * This is the V0 schema. - */ -const FormDefinitionBasicSchema = object({ - title: nonEmptyString('form title'), - name: nonEmptyString('form name'), - fields: array(unknown()), -}); - -/** - * This is the V1 schema. - */ -const FormDefinitionV1Schema = merge([FormDefinitionBasicSchema, object({ - version: literal("1"), - fields: FieldListSchema, -})]); - -// This is the latest schema. -// Make sure to update this when you add a new version. -const FormDefinitionLatestSchema = FormDefinitionV1Schema; - -type FormDefinitionV1 = Output; -type FormDefinitionBasic = Output; - -/** - * This means the basic structure of the form is valid - * but we were unable to perform an automatic migration - * and we need the user to fix the form manually. - */ -export class MigrationError { - static readonly _tag = "MigrationError" as const; - public readonly name: string; - constructor(public form: FormDefinitionBasic, readonly error: ValiError) { - this.name = form.name; - } - toString(): string { - return `MigrationError: - ${this.error.message} - ${this.error.issues.map((issue) => issue.message).join(', ')}` - } - toJSON() { - return this.form - } -} - -/** - * This represents totally invalid data. - */ -export class InvalidData { - static readonly _tag = "InvalidData" as const; - constructor(public data: unknown, readonly error: ValiError) { } - toString(): string { - return `InvalidData: ${this.error.issues.map((issue) => issue.message).join(', ')}` - } -} - -//=========== Migration logic -function fromV0toV1(data: FormDefinitionBasic): MigrationError | FormDefinitionV1 { - return pipe( - parse(FormDefinitionV1Schema, { ...data, version: "1" }), - E.getOrElseW((error) => (new MigrationError(data, error))) - ) -} - -/** - * - * Parses the form definition and migrates it to the latest version in one operation. - */ -export function migrateToLatest(data: unknown): E.Either { - return pipe( - // first try a quick one with the latest schema - parse(FormDefinitionLatestSchema, data, { abortEarly: true }), - E.orElse(() => pipe( - parse(FormDefinitionBasicSchema, data), - E.mapLeft((error) => new InvalidData(data, error)), - E.map(fromV0toV1), - )), - ) -} - -export function formNeedsMigration(data: unknown): boolean { - return !is(FormDefinitionLatestSchema, data); -} - +import { type Output, is, safeParse } from "valibot"; +import { SelectFromNotesSchema, InputSliderSchema, InputNoteFromFolderSchema, InputDataviewSourceSchema, InputSelectFixedSchema, InputBasicSchema, MultiselectSchema, InputTypeSchema, FieldDefinitionSchema, FormDefinitionLatestSchema, FieldListSchema, FormDefinitionBasicSchema } from "./formDefinitionSchema"; //=========== Types derived from schemas type selectFromNotes = Output; type inputSlider = Output; type inputNoteFromFolder = Output; type inputDataviewSource = Output; type inputSelectFixed = Output; -type basicInput = Output; +type basicInput = Output; type multiselect = Output; type inputType = Output; export const FieldTypeReadable: Record = { - ...fieldTypeMap, + text: "Text", + number: "Number", + date: "Date", + time: "Time", + datetime: "DateTime", + textarea: "Text area", + toggle: "Toggle", "note": "Note", "slider": "Slider", "select": "Select", @@ -226,7 +81,7 @@ export type EditableFormDefinition = { }; export function isValidBasicInput(input: unknown): input is basicInput { - return is(BasicInputSchema, input); + return is(InputBasicSchema, input); } export function isMultiSelect(input: unknown): input is multiselect { diff --git a/src/core/formDefinitionSchema.ts b/src/core/formDefinitionSchema.ts new file mode 100644 index 00000000..ccd6df4b --- /dev/null +++ b/src/core/formDefinitionSchema.ts @@ -0,0 +1,172 @@ +import { A, parse, pipe } from "@std"; +import * as E from "fp-ts/Either"; +import { object, number, literal, type Output, is, array, string, union, optional, minLength, toTrimmed, merge, unknown, ValiError, BaseSchema, enumType, passthrough } from "valibot"; +import { AllFieldTypes, FormDefinition } from "./formDefinition"; +import * as Separated from "fp-ts/Separated"; +import { findInputSchema, InvalidInputError } from "./findInputSchema"; + +/** + * Here are the core logic around the main domain of the plugin, + * which is the form definition. + * Here are the types, validators, rules etc. + */ +function nonEmptyString(name: string) { + return string(`${name} should be a string`, [toTrimmed(), minLength(1, `${name} should not be empty`)]); +} +const InputBasicTypeSchema = enumType(["text", "number", "date", "time", "datetime", "textarea", "toggle"]); +//=========== Schema definitions +export const SelectFromNotesSchema = object({ type: literal("select"), source: literal("notes"), folder: nonEmptyString('folder name') }); +export const InputSliderSchema = object({ type: literal("slider"), min: number(), max: number() }); +export const InputNoteFromFolderSchema = object({ type: literal("note"), folder: nonEmptyString('folder name') }); +export const InputDataviewSourceSchema = object({ type: literal("dataview"), query: nonEmptyString('dataview query') }); +export const InputBasicSchema = object({ type: InputBasicTypeSchema }); +export const InputSelectFixedSchema = object({ + type: literal("select"), + source: literal("fixed"), + options: array(object({ + value: string([toTrimmed()]), label: string() + })) +}); +const MultiSelectNotesSchema = object({ + type: literal("multiselect"), source: literal("notes"), + folder: nonEmptyString('multi select source folder') +}); +const MultiSelectFixedSchema = object({ type: literal("multiselect"), source: literal("fixed"), multi_select_options: array(string()) }); +export const MultiselectSchema = union([MultiSelectNotesSchema, MultiSelectFixedSchema]); +export const InputTypeSchema = union([ + InputBasicSchema, + InputNoteFromFolderSchema, + InputSliderSchema, + SelectFromNotesSchema, + InputDataviewSourceSchema, + InputSelectFixedSchema, + MultiselectSchema +]); +export const InputTypeToParserMap: Record = { + number: InputBasicSchema, + text: InputBasicSchema, + date: InputBasicSchema, + time: InputBasicSchema, + datetime: InputBasicSchema, + textarea: InputBasicSchema, + toggle: InputBasicSchema, + note: InputNoteFromFolderSchema, + slider: InputSliderSchema, + select: SelectFromNotesSchema, + dataview: InputDataviewSourceSchema, + multiselect: MultiselectSchema, +}; +export const FieldMinimalSchema = passthrough(object({ + name: string(), + input: object({ type: string() }) +})); + +export type FieldMinimal = Output; + +export const FieldDefinitionSchema = object({ + name: nonEmptyString('field name'), + label: optional(string()), + description: string(), + input: InputTypeSchema +}); +export const FieldListSchema = array(FieldDefinitionSchema); +/** + * This is the most basic representation of a form definition. + * It is not useful for anything other than being the base for + * other versioned schemas. + * This is the V0 schema. + */ +export const FormDefinitionBasicSchema = object({ + title: nonEmptyString('form title'), + name: nonEmptyString('form name'), + fields: array(unknown()), +}); +/** + * This is the V1 schema. + */ +const FormDefinitionV1Schema = merge([FormDefinitionBasicSchema, object({ + version: literal("1"), + fields: FieldListSchema, +})]); +// This is the latest schema. +// Make sure to update this when you add a new version. +export const FormDefinitionLatestSchema = FormDefinitionV1Schema; +type FormDefinitionV1 = Output; +type FormDefinitionBasic = Output; + +/** + * This means the basic structure of the form is valid + * but we were unable to perform an automatic migration + * and we need the user to fix the form manually. + */ +export class MigrationError { + static readonly _tag = "MigrationError" as const; + public readonly name: string; + constructor(public form: FormDefinitionBasic, readonly error: ValiError) { + this.name = form.name; + } + toString(): string { + return `MigrationError: + ${this.error.message} + ${this.error.issues.map((issue) => issue.message).join(', ')}`; + } + // This allows to store the error in the settings, along with the rest of the forms and + // have save all the data in one go transparently. + // This is required so we don't lose the form, even if it is invalid + toJSON() { + return this.form; + } + get fieldErrors() { + return pipe( + this.form.fields, + A.map((field) => { + return pipe( + findInputSchema(field), + E.chainW(([field, inputSchema]) => pipe( + parse(inputSchema, field.input), + E.mapLeft((error) => new InvalidInputError(field, error)) + )), + ) + }), + A.partition(E.isLeft), + Separated.right, + ); + } +} +/** + * This represents totally invalid data. + */ +export class InvalidData { + static readonly _tag = "InvalidData" as const; + constructor(public data: unknown, readonly error: ValiError) { } + toString(): string { + return `InvalidData: ${this.error.issues.map((issue) => issue.message).join(', ')}`; + } +} +//=========== Migration logic +function fromV0toV1(data: FormDefinitionBasic): MigrationError | FormDefinitionV1 { + return pipe( + parse(FormDefinitionV1Schema, { ...data, version: "1" }), + E.getOrElseW((error) => (new MigrationError(data, error))) + ); +} +/** + * + * Parses the form definition and migrates it to the latest version in one operation. + */ + +export function migrateToLatest(data: unknown): E.Either { + return pipe( + // first try a quick one with the latest schema + parse(FormDefinitionLatestSchema, data, { abortEarly: true }), + E.orElse(() => pipe( + parse(FormDefinitionBasicSchema, data), + E.mapLeft((error) => new InvalidData(data, error)), + E.map(fromV0toV1) + )) + ); +} + +export function formNeedsMigration(data: unknown): boolean { + return !is(FormDefinitionLatestSchema, data); +} diff --git a/src/core/settings.ts b/src/core/settings.ts index 70a7480f..08aeda2f 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -1,5 +1,6 @@ import { Output, ValiError, array, enumType, is, object, optional, unknown } from "valibot"; -import type { FormDefinition, MigrationError } from "./formDefinition"; +import type { FormDefinition } from "./formDefinition"; +import type { MigrationError } from "./formDefinitionSchema"; import * as E from 'fp-ts/Either'; import { pipe, parse } from "@std"; @@ -29,7 +30,7 @@ const ModalFormSettingsSchema = object({ type ModalFormSettingsPartial = Output; export function getDefaultSettings(): ModalFormSettings { - return { editorPosition: 'right', formDefinitions: []}; + return { editorPosition: 'right', formDefinitions: [] }; } export class NullSettingsError { diff --git a/src/main.ts b/src/main.ts index a2eb00a5..8ee272f1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,8 @@ import { API } from "src/API"; import { EDIT_FORM_VIEW, EditFormView } from "src/views/EditFormView"; import { MANAGE_FORMS_VIEW, ManageFormsView } from "src/views/ManageFormsView"; import { ModalFormError } from "src/utils/Error"; -import { formNeedsMigration, type FormDefinition, migrateToLatest, MigrationError, InvalidData } from "src/core/formDefinition"; +import { type FormDefinition } from "src/core/formDefinition"; +import { formNeedsMigration, migrateToLatest, MigrationError, InvalidData } from "./core/formDefinitionSchema"; import { parseSettings, type ModalFormSettings, type OpenPosition, getDefaultSettings } from "src/core/settings"; import { log_notice } from "./utils/Log"; import * as E from "fp-ts/Either"; diff --git a/src/views/ManageForms.svelte b/src/views/ManageForms.svelte index 0c70a432..eeb53e48 100644 --- a/src/views/ManageForms.svelte +++ b/src/views/ManageForms.svelte @@ -1,8 +1,8 @@