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/src/core/formDefinition.ts b/src/core/formDefinition.ts index d000a04b..cecb4e38 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, any, ValiError } from "valibot"; /** * Here are the core logic around the main domain of the plugin, * which is the form definition. @@ -88,40 +89,59 @@ 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(', ')}` } } -//=========== 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 +242,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.ts b/src/core/settings.ts index 96ce8b0b..53d4ec22 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,28 @@ const ModalFormSettingsSchema = object({ type ModalFormSettingsPartial = Output; -export const DEFAULT_SETTINGS: ModalFormSettings = { +const DEFAULT_SETTINGS: ModalFormSettings = { editorPosition: 'right', formDefinitions: [], }; -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); +export function getDefaultSettings(): ModalFormSettings { + return { ...DEFAULT_SETTINGS }; +} + +export class NullSettingsError { + readonly _tag = 'NullSettingsError'; +} + +export function parseSettings(maybeSettings: unknown): E.Either { + return pipe( + maybeSettings, + E.fromNullable(new NullSettingsError()), + E.chainW((s) => parse(ModalFormSettingsSchema, { ...DEFAULT_SETTINGS, ...s })), + ) } export interface ModalFormSettings { editorPosition: OpenPosition; - formDefinitions: FormDefinition[]; + formDefinitions: (FormDefinition | MigrationError)[]; } diff --git a/src/main.ts b/src/main.ts index aa15c9a9..18b6ee4a 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(); } @@ -123,31 +143,27 @@ export default class ModalFormPlugin extends Plugin { 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(data, + parseSettings, + 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..28bf041d --- /dev/null +++ b/src/std/index.ts @@ -0,0 +1,21 @@ +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 = { + Option +} + +export const parse = tryCatchK(parseV, (e: unknown) => e as ValiError) diff --git a/tsconfig.json b/tsconfig.json index e265fb81..55051ebc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,7 +24,12 @@ "ES5", "ES6", "ES7" - ] + ], + "paths": { + "@std": [ + "src/std" + ] + } }, "include": [ "src/**/*",