Skip to content

Commit

Permalink
feat: detect invalid data on data.json
Browse files Browse the repository at this point in the history
It also notifies about outdated forms and invalid data

fixes #98
  • Loading branch information
danielo515 committed Oct 25, 2023
1 parent 4d41d68 commit 91aa99c
Show file tree
Hide file tree
Showing 6 changed files with 136 additions and 62 deletions.
3 changes: 2 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
}
70 changes: 45 additions & 25 deletions src/core/formDefinition.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -88,40 +89,59 @@ const FormDefinitionV1Schema = merge([FormDefinitionBasicSchema, object({
const FormDefinitionLatestSchema = FormDefinitionV1Schema;

type FormDefinitionV1 = Output<typeof FormDefinitionV1Schema>;
class MigrationError {
type FormDefinitionBasic = Output<typeof FormDefinitionBasicSchema>;

/**
* 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<MigrationError, FormDefinitionV1> {
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<MigrationError, FormDefinition> {
if (is(FormDefinitionLatestSchema, data)) {
return E.right(data);
}
return fromV0toV1(data);
export function migrateToLatest(data: unknown): E.Either<InvalidData, MigrationError | FormDefinition> {
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 {
Expand Down Expand Up @@ -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
})
);
Expand Down
27 changes: 19 additions & 8 deletions src/core/settings.ts
Original file line number Diff line number Diff line change
@@ -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<typeof OpenPositionSchema>;
Expand All @@ -27,18 +28,28 @@ const ModalFormSettingsSchema = object({

type ModalFormSettingsPartial = Output<typeof ModalFormSettingsSchema>;

export const DEFAULT_SETTINGS: ModalFormSettings = {
const DEFAULT_SETTINGS: ModalFormSettings = {
editorPosition: 'right',
formDefinitions: [],
};

export function parseSettings(maybeSettings: unknown): E.Either<ValiError, ModalFormSettingsPartial> {
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<ValiError | NullSettingsError, ModalFormSettingsPartial> {
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)[];
}
70 changes: 43 additions & 27 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -20,6 +20,22 @@ interface PublicAPI {
exampleForm(): Promise<FormResult>;
openForm(formReference: string | FormDefinition): Promise<FormResult>
}

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;
Expand All @@ -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) {
Expand All @@ -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 {
Expand All @@ -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();
}

Expand Down Expand Up @@ -123,31 +143,27 @@ export default class ModalFormPlugin extends Plugin {

async getSettings(): Promise<ModalFormSettings> {
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) {
Expand Down
21 changes: 21 additions & 0 deletions src/std/index.ts
Original file line number Diff line number Diff line change
@@ -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)
7 changes: 6 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@
"ES5",
"ES6",
"ES7"
]
],
"paths": {
"@std": [
"src/std"
]
}
},
"include": [
"src/**/*",
Expand Down

0 comments on commit 91aa99c

Please sign in to comment.