diff --git a/.eslintrc b/.eslintrc index b16e0a5b..abdafd74 100644 --- a/.eslintrc +++ b/.eslintrc @@ -16,6 +16,7 @@ "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], "@typescript-eslint/ban-ts-comment": "off", "no-prototype-builtins": "off", - "@typescript-eslint/no-empty-function": "off" + "@typescript-eslint/no-empty-function": "off", + "arrow-parens": ["error", "always"] } } diff --git a/jest.config.ts b/jest.config.ts index de342db8..5b1d06de 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,14 +1,14 @@ -import type { JestConfigWithTsJest } from 'ts-jest' +import { type JestConfigWithTsJest } from 'ts-jest' const jestConfig: JestConfigWithTsJest = { - // [...] - preset: 'ts-jest/presets/default-esm', // or other ESM presets + // preset: 'ts-jest/presets/default-esm', // or other ESM presets + preset: 'ts-jest', // or other ESM presets + moduleDirectories: ['node_modules', ''], + roots: ['src'], moduleNameMapper: { - '^(\\.{1,2}/.*)\\.js$': '$1', + '^@std$': '/src/std/$1', }, transform: { - // '^.+\\.[tj]sx?$' to process js/ts with `ts-jest` - // '^.+\\.m?[tj]sx?$' to process js/ts/mjs/mts with `ts-jest` '^.+\\.tsx?$': [ 'ts-jest', { @@ -17,5 +17,4 @@ const jestConfig: JestConfigWithTsJest = { ], }, } - export default jestConfig diff --git a/package.json b/package.json index 8f872f5d..2a6717d4 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "npm run check && node esbuild.config.mjs production", "check": "svelte-check --tsconfig tsconfig.json", "test": "jest", + "test-w": "jest --watch", "version": "node version-bump.mjs && git add manifest.json versions.json" }, "keywords": [], @@ -37,4 +38,4 @@ "fuse.js": "^6.6.2", "valibot": "^0.19.0" } -} +} \ No newline at end of file diff --git a/src/API.ts b/src/API.ts index a01ecc6d..44a28d69 100644 --- a/src/API.ts +++ b/src/API.ts @@ -1,12 +1,12 @@ import { App } from "obsidian"; -import type { FormDefinition, FormOptions } from "./core/formDefinition"; +import { MigrationError, type FormDefinition, type FormOptions } from "./core/formDefinition"; import FormResult from "./FormResult"; import { exampleModalDefinition } from "./exampleModalDefinition"; import ModalFormPlugin from "./main"; import { ModalFormError } from "./utils/Error"; import { FormModal } from "./FormModal"; -import { log_error } from "./utils/Log"; +import { log_error, log_notice } from "./utils/Log"; type pickOption = { pick: string[] } type omitOption = { omit: string[] } @@ -47,7 +47,17 @@ export class API { } getFormByName(name: string): FormDefinition | undefined { - return this.plugin.settings?.formDefinitions.find(form => form.name === name); + const form = this.plugin.settings?.formDefinitions.find((form) => form.name === name); + if (form instanceof MigrationError) { + log_notice('🚫 The form you tried to load has an invalid format', + `The form "${name}" has an invalid format.`+ + `We tried to automatically convert it but it failed, please fix it manually in the forms manager. + `) + return undefined; + } else { + + return form; + } } /** @@ -73,9 +83,9 @@ export class API { if (formDefinition) { if (isOmitOption(opts)) { const omit = opts.omit - newFormDefinition = { ...formDefinition, fields: formDefinition.fields.filter(field => !omit.includes(field.name)) } + newFormDefinition = { ...formDefinition, fields: formDefinition.fields.filter((field) => !omit.includes(field.name)) } } else if (isPickOption(opts)) { - newFormDefinition = { ...formDefinition, fields: formDefinition.fields.filter(field => opts.pick.includes(field.name)) } + newFormDefinition = { ...formDefinition, fields: formDefinition.fields.filter((field) => opts.pick.includes(field.name)) } } else { throw new ModalFormError('Invalid options provided to limitedForm', `GOT: ${JSON.stringify(opts)}`) } diff --git a/src/core/formDefinition.ts b/src/core/formDefinition.ts index d000a04b..2fbcc34d 100644 --- a/src/core/formDefinition.ts +++ b/src/core/formDefinition.ts @@ -1,5 +1,6 @@ +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, any, Issues } from "valibot"; +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. @@ -72,7 +73,7 @@ const FieldListSchema = array(FieldDefinitionSchema); const FormDefinitionBasicSchema = object({ title: nonEmptyString('form title'), name: nonEmptyString('form name'), - fields: array(any()), + fields: array(unknown()), }); /** @@ -88,40 +89,62 @@ const FormDefinitionV1Schema = merge([FormDefinitionBasicSchema, object({ const FormDefinitionLatestSchema = FormDefinitionV1Schema; type FormDefinitionV1 = Output; -class MigrationError { +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; - constructor(readonly issues: Issues) { } + public readonly name: string; + constructor(public form: FormDefinitionBasic, readonly error: ValiError) { + this.name = form.name; + } toString(): string { - return `MigrationError: ${this.issues.map(issue => issue.message).join(', ')}` + return `MigrationError: + ${this.error.message} + ${this.error.issues.map((issue) => issue.message).join(', ')}` + } + toJSON() { + return this.form } } -//=========== Migration logic -function fromV0toV1(data: unknown): E.Either { - const v0 = safeParse(FormDefinitionBasicSchema, data) - if (!v0.success) { - return E.left(new MigrationError(v0.issues)) - } - const unparsedV1 = { - ...v0.output, - version: "1", - } - const v1 = safeParse(FormDefinitionV1Schema, unparsedV1) - if (!v1.success) { - return E.left(new MigrationError(v1.issues)) +/** + * 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(', ')}` } - return E.right(v1.output) +} + +//=========== 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 { - if (is(FormDefinitionLatestSchema, data)) { - return E.right(data); - } - return fromV0toV1(data); +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 { @@ -222,9 +245,9 @@ export function validateFields(fields: unknown) { return [] } console.error('Fields issues', result.issues) - return result.issues.map(issue => + return result.issues.map((issue) => ({ - message: issue.message, path: issue.path?.map(item => item.key).join('.'), + message: issue.message, path: issue.path?.map((item) => item.key).join('.'), index: issue.path?.[0]?.key ?? 0 }) ); diff --git a/src/core/settings.test.ts b/src/core/settings.test.ts index aa820199..93e00833 100644 --- a/src/core/settings.test.ts +++ b/src/core/settings.test.ts @@ -1,16 +1,10 @@ -import { parseSettings } from "./settings"; +import { NullSettingsError, parseSettings } from "./settings"; import * as E from "fp-ts/Either"; describe("parseSettings", () => { - it("should return the default settings when given null", () => { + it("should notify a NullSettingsError when given null", () => { const result = parseSettings(null); - expect(E.isRight(result)).toBe(true); - if (E.isRight(result)) { - expect(result.right).toEqual({ - editorPosition: "right", - formDefinitions: [], - }); - } + expect(result).toEqual(E.left(new NullSettingsError())); }); it("should return the parsed settings when given valid input", () => { @@ -19,8 +13,7 @@ describe("parseSettings", () => { formDefinitions: [], }; const result = parseSettings(input); - expect(E.isRight(result)).toBe(true); - if (E.isRight(result)) expect(result.right).toEqual(input); + expect(result).toEqual(E.of(input)); }); it("should return a validation error when given invalid input", () => { @@ -30,6 +23,7 @@ describe("parseSettings", () => { }; const result = parseSettings(input); expect(E.isLeft(result)).toBe(true); - if (E.isLeft(result)) expect(result.left).toBeDefined(); + if (E.isLeft(result)) expect(result.left).toBeDefined() + else fail("Expected a left value") }); }); diff --git a/src/core/settings.ts b/src/core/settings.ts index 96ce8b0b..70a7480f 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -1,6 +1,7 @@ -import { Output, ValiError, array, enumType, is, object, optional, parse, unknown } from "valibot"; -import type { FormDefinition } from "./formDefinition"; +import { Output, ValiError, array, enumType, is, object, optional, unknown } from "valibot"; +import type { FormDefinition, MigrationError } from "./formDefinition"; import * as E from 'fp-ts/Either'; +import { pipe, parse } from "@std"; const OpenPositionSchema = enumType(['left', 'right', 'mainView']); export type OpenPosition = Output; @@ -27,18 +28,29 @@ const ModalFormSettingsSchema = object({ type ModalFormSettingsPartial = Output; -export const DEFAULT_SETTINGS: ModalFormSettings = { - editorPosition: 'right', - formDefinitions: [], -}; +export function getDefaultSettings(): ModalFormSettings { + return { editorPosition: 'right', formDefinitions: []}; +} + +export class NullSettingsError { + readonly _tag = 'NullSettingsError'; +} -export function parseSettings(maybeSettings: unknown): E.Either { - if (maybeSettings === null) return E.right({ ...DEFAULT_SETTINGS }) - ; - return E.tryCatch(() => parse(ModalFormSettingsSchema, { ...DEFAULT_SETTINGS, ...maybeSettings }), e => e as ValiError); +/** + * Parses the settings and returns a validation error if the settings are invalid. + * The reason why we don't return default settings when the settings are invalid is because + * in case of default settings there are several operations that could be skipped, + * like migrations and validation. + */ +export function parseSettings(maybeSettings: unknown): E.Either { + return pipe( + maybeSettings, + E.fromNullable(new NullSettingsError()), + E.chainW((s) => parse(ModalFormSettingsSchema, { ...getDefaultSettings(), ...s })), + ) } export interface ModalFormSettings { editorPosition: OpenPosition; - formDefinitions: FormDefinition[]; + formDefinitions: (MigrationError | FormDefinition)[]; } diff --git a/src/main.ts b/src/main.ts index aa15c9a9..33c5e949 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,9 +6,9 @@ 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 } from "src/core/formDefinition"; -import { parseSettings, type ModalFormSettings, type OpenPosition, DEFAULT_SETTINGS } from "src/core/settings"; -import { log_error, log_notice } from "./utils/Log"; +import { formNeedsMigration, type FormDefinition, migrateToLatest, MigrationError, InvalidData } from "src/core/formDefinition"; +import { parseSettings, type ModalFormSettings, type OpenPosition, getDefaultSettings } from "src/core/settings"; +import { log_notice } from "./utils/Log"; import * as E from "fp-ts/Either"; import { pipe } from "fp-ts/function"; import * as A from "fp-ts/Array" @@ -20,6 +20,22 @@ interface PublicAPI { exampleForm(): Promise; openForm(formReference: string | FormDefinition): Promise } + +function notifyParsingErrors(errors: InvalidData[]) { + if (errors.length === 0) { return } + log_notice('Some forms could not be parsed', + `We found some invalid data while parsing the form settings, please take a look at the following errors: + ${errors.join('\n')}` + ) +} + +function notifyMigrationErrors(errors: MigrationError[]) { + if (errors.length === 0) { return } + log_notice('Some forms could not be migrated', + `We tried to perform an automatic migration, but we failed. Go to the forms manager and fix the following forms: + ${errors.map((e) => e.name).join('\n')}` + ) +} // This is the plugin entrypoint export default class ModalFormPlugin extends Plugin { public settings: ModalFormSettings | undefined; @@ -35,7 +51,7 @@ export default class ModalFormPlugin extends Plugin { } formExists(formName: string): boolean { - return this.settings?.formDefinitions.some(form => form.name === formName) ?? false; + return this.settings?.formDefinitions.some((form) => form.name === formName) ?? false; } async duplicateForm(form: FormDefinition) { @@ -56,16 +72,20 @@ export default class ModalFormPlugin extends Plugin { // then if you save another form you will unexpectedly save the mutated form too. // Maybe we could instead do a deep copy instead, but until this proven to be a bottleneck I will leave it like this. const savedSettings = await this.getSettings(); - const formDefinition = savedSettings.formDefinitions.find(form => form.name === formName); + const formDefinition = savedSettings.formDefinitions.find((form) => form.name === formName); if (!formDefinition) { throw new ModalFormError(`Form ${formName} not found`) } + if (formDefinition instanceof MigrationError) { + notifyMigrationErrors([formDefinition]) + return + } await this.activateView(EDIT_FORM_VIEW, formDefinition); } async saveForm(formDefinition: FormDefinition) { - const index = this.settings?.formDefinitions.findIndex(form => form.name === formDefinition.name); + const index = this.settings?.formDefinitions.findIndex((form) => form.name === formDefinition.name); if (index === undefined || index === -1) { this.settings?.formDefinitions.push(formDefinition); } else { @@ -81,7 +101,7 @@ export default class ModalFormPlugin extends Plugin { if (!this.settings) { throw new ModalFormError('Settings not found') } - this.settings.formDefinitions = this.settings.formDefinitions.filter(form => form.name !== formName); + this.settings.formDefinitions = this.settings.formDefinitions.filter((form) => form.name !== formName); await this.saveSettings(); } @@ -121,33 +141,31 @@ export default class ModalFormPlugin extends Plugin { return leaf; } + // TODO: extract the migration logic to a separate function and test it + // TODO: collect actual migration events to decide if we need to migrate or not rather than this naive approach async getSettings(): Promise { const data = await this.loadData(); - const settingsParsed = parseSettings(data); - if (E.isLeft(settingsParsed)) { - const error = new ModalFormError('Settings are not valid, check the errors', JSON.stringify(settingsParsed.left.issues, null, 2)) - log_error(error) - return { ...DEFAULT_SETTINGS }; - } - const settings = settingsParsed.right; - const migrationIsNeeded = settings.formDefinitions.some(formNeedsMigration); - // Migrate to latest also validates and parses the form definitions, so we always execute it - const formDefinitions = pipe(settings.formDefinitions, A.partitionMap(migrateToLatest)) - if (formDefinitions.left.length > 0) { - log_notice('Some forms could not be parsed', - `We tried to perform an automatic migration, but we failed, please take a look at the following errors: - ${formDefinitions.left.join('\n')}` - ) - } + const [migrationIsNeeded, settings] = pipe( + parseSettings(data), + E.map((settings): [boolean, ModalFormSettings] => { + const migrationIsNeeded = settings.formDefinitions.some(formNeedsMigration); + const { right: formDefinitions, left: errors } = A.partitionMap(migrateToLatest)(settings.formDefinitions); + notifyParsingErrors(errors); + const validSettings: ModalFormSettings = { ...settings, formDefinitions } + return [migrationIsNeeded, validSettings] + }), + E.getOrElse(() => [false, getDefaultSettings()]) + ) + if (migrationIsNeeded) { - await this.saveSettings(); + await this.saveSettings(settings); console.info('Settings were migrated to the latest version') } - return { ...settings, formDefinitions: formDefinitions.right } + return settings; } - private async saveSettings() { - await this.saveData(this.settings); + private async saveSettings(newSettings?: ModalFormSettings) { + await this.saveData(newSettings || this.settings); } async setEditorPosition(position: OpenPosition) { diff --git a/src/std/index.ts b/src/std/index.ts new file mode 100644 index 00000000..c01cb50a --- /dev/null +++ b/src/std/index.ts @@ -0,0 +1,19 @@ +import { pipe as p } from "fp-ts/function"; +import { partitionMap } from "fp-ts/Array"; +import { isLeft, isRight, tryCatchK } from "fp-ts/Either"; +import { ValiError, parse as parseV } from "valibot"; + +export const pipe = p +export const A = { + partitionMap +} + +export const E = { + isLeft, + isRight, + tryCatchK, +} + +export const O = {} + +export const parse = tryCatchK(parseV, (e: unknown) => e as ValiError) diff --git a/src/views/ManageFormsView.ts b/src/views/ManageFormsView.ts index 68e29828..0224820a 100644 --- a/src/views/ManageFormsView.ts +++ b/src/views/ManageFormsView.ts @@ -1,3 +1,4 @@ +import { MigrationError } from "src/core/formDefinition"; import ModalFormPlugin from "../main"; import { ItemView, Notice, Setting, WorkspaceLeaf } from "obsidian"; @@ -31,7 +32,7 @@ export class ManageFormsView extends ItemView { } renderControls(root: HTMLElement) { - new Setting(root).addButton(button => { + new Setting(root).addButton((button) => { button.setButtonText('Add new form').onClick(() => { this.plugin.createNewForm(); }) @@ -45,7 +46,10 @@ export class ManageFormsView extends ItemView { root.empty(); const rows = root.createDiv(); rows.setCssStyles({ display: 'flex', flexDirection: 'column', gap: '10px' }); - forms.forEach(form => { + forms.forEach((form) => { + if (form instanceof MigrationError) { + return // TODO: UI for migration errors + } const row = rows.createDiv() row.setCssStyles({ display: 'flex', flexDirection: 'column', gap: '8px' }) row.createEl("h4", { text: form.name }); @@ -72,13 +76,13 @@ export class ManageFormsView extends ItemView { }); } ) - .addButton(btn => { + .addButton((btn) => { btn.setTooltip('duplicate ' + form.name) btn.setButtonText('Duplicate').onClick(() => { this.plugin.duplicateForm(form); }) }) - .addButton(button => { + .addButton((button) => { button.setIcon('clipboard-copy') button.onClick(() => { navigator.clipboard.writeText(JSON.stringify(form, null, 2)); diff --git a/tsconfig.json b/tsconfig.json index e265fb81..1db31acf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "baseUrl": ".", "inlineSources": true, + "resolveJsonModule": true, "module": "ESNext", "target": "ES6", "allowJs": true, @@ -24,9 +25,14 @@ "ES5", "ES6", "ES7" - ] + ], + "paths": { + "@std": [ + "src/std" + ] + } }, "include": [ - "src/**/*", + "src/**/*" ] } \ No newline at end of file