From 6cdb1041114ac10e1ec5c580ce02b90d6568a745 Mon Sep 17 00:00:00 2001 From: Danielo Rodriguez Date: Sun, 21 Jan 2024 11:45:36 +0100 Subject: [PATCH] feat(import): ability to edit forms imported from JSON --- src/core/formDefinitionSchema.test.ts | 38 ++++++--- src/core/template/templateParser.ts | 108 +++++++++++++------------- src/main.ts | 7 +- src/std/index.ts | 34 ++++---- src/views/FormBuilder.svelte | 3 +- src/views/FormImport.svelte | 16 ++-- src/views/FormImport.ts | 69 +++++++++++++--- src/views/FormImportView.ts | 14 +++- 8 files changed, 175 insertions(+), 114 deletions(-) diff --git a/src/core/formDefinitionSchema.test.ts b/src/core/formDefinitionSchema.test.ts index b652114c..8dd92a89 100644 --- a/src/core/formDefinitionSchema.test.ts +++ b/src/core/formDefinitionSchema.test.ts @@ -1,23 +1,39 @@ -import { A, E, pipe } from "@std"; +import { E, pipe } from "@std"; import { findFieldErrors } from "./findInputDefinitionSchema"; +import { separated } from "fp-ts"; describe("findFieldErrors", () => { it("should return an empty array of detailed errors or unchanged fields if they are correct", () => { const fields = [ - { name: 'fieldName', description: 'field description', input: { type: 'text' } }, - { name: 'fieldName', input: { type: 'text' } }, - { name: 'fieldName', description: '', input: { type: '' } }, + { name: "fieldName", description: "field description", input: { type: "text" } }, + { name: "fieldName", input: { type: "text" } }, + { name: "fieldName", description: "", input: { type: "" } }, {}, ]; const errors = pipe( + // multi line prettier findFieldErrors(fields), - A.map(E.mapLeft((e) => e.toString())) - ) + separated.mapLeft((e) => e.toString()), + separated.left, + ); expect(errors).toHaveLength(4); - expect(errors[0]).toEqual(E.right({ name: 'fieldName', description: 'field description', input: { type: 'text' } })); - expect(errors[1]).toEqual(E.left('InvalidFieldError: description: Invalid type got undefined')); - expect(errors[2]).toEqual(E.left('InvalidInputTypeError: "input.type" is invalid, got: ""')); - expect(errors[3]).toEqual(E.left('InvalidFieldError: name: field name should be a string got undefined, description: Invalid type got undefined, input: Invalid type got undefined')); + expect(errors[0]).toEqual( + E.right({ + name: "fieldName", + description: "field description", + input: { type: "text" }, + }), + ); + expect(errors[1]).toEqual( + E.left("InvalidFieldError: description: Invalid type got undefined"), + ); + expect(errors[2]).toEqual( + E.left('InvalidInputTypeError: "input.type" is invalid, got: ""'), + ); + expect(errors[3]).toEqual( + E.left( + "InvalidFieldError: name: field name should be a string got undefined, description: Invalid type got undefined, input: Invalid type got undefined", + ), + ); }); - }); diff --git a/src/core/template/templateParser.ts b/src/core/template/templateParser.ts index 922689d9..2a601981 100644 --- a/src/core/template/templateParser.ts +++ b/src/core/template/templateParser.ts @@ -1,4 +1,4 @@ -import * as R from 'fp-ts/Record'; +import * as R from "fp-ts/Record"; import * as E from "fp-ts/Either"; import * as St from "fp-ts/string"; import * as P from "parser-ts/Parser"; @@ -20,10 +20,7 @@ function TemplateVariable(value: string): TemplateVariable { return { _tag: "variable", value }; } -function FrontmatterCommand( - pick: string[] = [], - omit: string[] = [], -): FrontmatterCommand { +function FrontmatterCommand(pick: string[] = [], omit: string[] = []): FrontmatterCommand { return { _tag: "frontmatter-command", pick, omit }; } @@ -37,10 +34,7 @@ const EofStr = pipe( ); // === Variable Parser === const open = S.fold([S.string("{{"), S.spaces]); -const close = P.expected( - S.fold([S.spaces, S.string("}}")]), - 'closing variable tag: "}}"', -); +const close = P.expected(S.fold([S.spaces, S.string("}}")]), 'closing variable tag: "}}"'); const identifier = S.many1(C.alphanum); const templateIdentifier: TokenParser = pipe( identifier, @@ -50,22 +44,21 @@ const templateIdentifier: TokenParser = pipe( // === Command Parser === const commandOpen = S.fold([S.string("{#"), S.spaces]); -const commandClose = P.expected( - S.fold([S.spaces, S.string("#}")]), - 'a closing command tag: "#}"', -); -const sepByComma = P.sepBy(S.fold([C.char(','), S.spaces]), identifier); -const commandOptionParser = (option: string) => pipe( - S.fold([S.string(option), S.spaces]), - P.apSecond(sepByComma), -) +const commandClose = P.expected(S.fold([S.spaces, S.string("#}")]), 'a closing command tag: "#}"'); +const sepByComma = P.sepBy(S.fold([C.char(","), S.spaces]), identifier); +const commandOptionParser = (option: string) => + pipe( + S.fold([S.string(option), S.spaces]), + // dam prettier + P.apSecond(sepByComma), + ); const frontmatterCommandParser = pipe( S.fold([S.string("frontmatter"), S.spaces]), P.apSecond(P.optional(commandOptionParser("pick:"))), //P.apFirst(S.spaces), // P.chain(commandOptionParser("pick:")), -) +); // the frontmatter command looks like this: // {# frontmatter pick: name, age, omit: id #} @@ -76,19 +69,17 @@ const commandParser = pipe( return pipe( value, O.fold(() => [], identity), - FrontmatterCommand - ) + FrontmatterCommand, + ); }), -) +); export const OpenOrEof = pipe( open, P.alt(() => commandOpen), - P.alt(() => EofStr)); -export const anythingUntilOpenOrEOF = P.many1Till( - P.item(), - P.lookAhead(OpenOrEof), + P.alt(() => EofStr), ); +export const anythingUntilOpenOrEOF = P.many1Till(P.item(), P.lookAhead(OpenOrEof)); const text: TokenParser = pipe( anythingUntilOpenOrEOF, @@ -103,16 +94,16 @@ const TextOrVariable: TokenParser = pipe( const Template = pipe( P.many(TextOrVariable), - P.apFirst(P.eof())); + // dam prettier + P.apFirst(P.eof()), +); /** * Given a template string, parse it into an array of tokens. * Templates look like this: * "Hello {{name}}! You are {{age}} years old." * @param template a template string to convert into an array of tokens or an error */ -export function parseTemplate( - template: string, -): Either { +export function parseTemplate(template: string): Either { return pipe( Template, S.run(template), @@ -124,9 +115,7 @@ export function parseTemplate( // return S.run(template)(P.many(Template)) } -export function templateVariables( - parsedTemplate: ReturnType, -): string[] { +export function templateVariables(parsedTemplate: ReturnType): string[] { return pipe( parsedTemplate, E.fold( @@ -161,7 +150,9 @@ function tokenToString(token: Token): string { case "variable": return `{{${token.value}}}`; case "frontmatter-command": - return `{{# frontmatter pick: ${token.pick.join(", ")}, omit: ${token.omit.join(", ")} #}}`; + return `{{# frontmatter pick: ${token.pick.join(", ")}, omit: ${token.omit.join( + ", ", + )} #}}`; default: return absurd(tag); } @@ -196,37 +187,42 @@ function matchToken( */ export function parsedTemplateToString(parsedTemplate: ParsedTemplate): string { return pipe( + // prettier shut up parsedTemplate, - A.foldMap(St.Monoid)(tokenToString)); + A.foldMap(St.Monoid)(tokenToString), + ); } function asFrontmatterString(data: Record) { - return ({ pick, omit }: { pick: string[], omit: string[] }): string => pipe( - data, - R.filterMapWithIndex((key, value) => { - if (pick.length === 0) return O.some(value); - return pick.includes(key) ? O.some(value) : O.none; - }), - R.filterMapWithIndex((key, value) => !omit.includes(key) ? O.some(value) : O.none), - stringifyYaml, - ) + return ({ pick, omit }: { pick: string[]; omit: string[] }): string => + pipe( + data, + R.filterMapWithIndex((key, value) => { + if (pick.length === 0) return O.some(value); + return pick.includes(key) ? O.some(value) : O.none; + }), + R.filterMapWithIndex((key, value) => (!omit.includes(key) ? O.some(value) : O.none)), + stringifyYaml, + ); } -export function executeTemplate( - parsedTemplate: ParsedTemplate, - formData: ModalFormData, -) { +export function executeTemplate(parsedTemplate: ParsedTemplate, formData: ModalFormData) { const toFrontmatter = asFrontmatterString(formData); // Build it upfront rater than on every call return pipe( parsedTemplate, - A.filterMap(matchToken( - O.some, - (key) => O.fromNullable(formData[key]), - (command) => pipe( - command, - toFrontmatter, - O.some) - )), + A.filterMap( + matchToken( + O.some, + (key) => O.fromNullable(formData[key]), + (command) => + pipe( + //prettier + command, + toFrontmatter, + O.some, + ), + ), + ), A.foldMap(St.Monoid)(String), ); } diff --git a/src/main.ts b/src/main.ts index df9c7bff..8adee5b9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -246,7 +246,12 @@ export default class ModalFormPlugin extends Plugin { id: "import-form", name: "Import form", callback: () => { - const importModal = new FormImportModal(this.app); + const importModal = new FormImportModal(this.app, { + createForm: (form) => { + importModal.close(); + this.activateView(EDIT_FORM_VIEW, form); + }, + }); importModal.open(); }, }); diff --git a/src/std/index.ts b/src/std/index.ts index ed24d79e..1dae9cfe 100644 --- a/src/std/index.ts +++ b/src/std/index.ts @@ -10,17 +10,7 @@ import { filterMap, flatten, } from "fp-ts/Array"; -import { - map as mapO, - getOrElse as getOrElseOpt, - some, - none, - fromNullable as fromNullableOpt, - fold as ofold, - chain as ochain, - alt as OAlt, - fromPredicate, -} from "fp-ts/Option"; +import * as _O from "fp-ts/Option"; import { isLeft, isRight, @@ -40,6 +30,7 @@ import { chainW, match, } from "fp-ts/Either"; +export type Option = _O.Option; import { BaseSchema, Output, ValiError, parse as parseV } from "valibot"; import { Semigroup, concatAll } from "fp-ts/Semigroup"; import { NonEmptyArray, concatAll as concatAllNea } from "fp-ts/NonEmptyArray"; @@ -86,15 +77,18 @@ export const E = { }; export const O = { - map: mapO, - getOrElse: getOrElseOpt, - some, - none, - fold: ofold, - fromNullable: fromNullableOpt, - chain: ochain, - fromPredicate: fromPredicate, - alt: OAlt, + map: _O.map, + getOrElse: _O.getOrElse, + some: _O.some, + none: _O.none, + fold: _O.fold, + fromNullable: _O.fromNullable, + chain: _O.chain, + fromPredicate: _O.fromPredicate, + isNone: _O.isNone, + isSome: _O.isSome, + alt: _O.alt, + match: _O.match, }; export const parse = tryCatchK(parseV, (e: unknown) => e as ValiError); diff --git a/src/views/FormBuilder.svelte b/src/views/FormBuilder.svelte index 45e220c3..9441cd71 100644 --- a/src/views/FormBuilder.svelte +++ b/src/views/FormBuilder.svelte @@ -15,8 +15,7 @@ import FormRow from "./components/FormRow.svelte"; import Toggle from "./components/Toggle.svelte"; import TemplateEditor from "./components/TemplateEditor.svelte"; - import { pipe } from "fp-ts/lib/function"; - import { A } from "@std"; + import { A, pipe } from "@std"; import Tabs from "./components/Tabs.svelte"; import { ParsedTemplate, parsedTemplateToString } from "src/core/template/templateParser"; import InputBuilderDocumentBlock from "./components/InputBuilderDocumentBlock.svelte"; diff --git a/src/views/FormImport.svelte b/src/views/FormImport.svelte index 9abcae9c..a2acdf71 100644 --- a/src/views/FormImport.svelte +++ b/src/views/FormImport.svelte @@ -1,9 +1,9 @@
@@ -23,14 +23,14 @@ />
- {#if $errors.length > 0} + {#if ui.errors.length > 0}
    - {#each $errors as error} + {#each ui.errors as error}
  • {error}
  • {/each}
@@ -38,12 +38,8 @@
{/if} -
diff --git a/src/views/FormImport.ts b/src/views/FormImport.ts index c6b5278e..52d49ef2 100644 --- a/src/views/FormImport.ts +++ b/src/views/FormImport.ts @@ -1,21 +1,70 @@ import * as J from "fp-ts/Json"; -import { ensureError, pipe } from "@std"; +import { O, type Option, ensureError, pipe } from "@std"; import * as E from "fp-ts/Either"; import { InvalidData, MigrationError, migrateToLatest } from "src/core/formDefinitionSchema"; import { Readable, writable } from "svelte/store"; +import { FormDefinition } from "src/core/formDefinition"; + +type State = E.Either>; +type UiState = { + canSubmit: boolean; + errors: string[]; + onSubmit: () => void; + buttonHint: string; +}; export interface FormImportModel { - readonly errors: Readable; + readonly state: Readable; readonly validate: (value: string) => void; + uiState(state: State): UiState; +} + +export interface Matchers { + empty(): T; + ok(form: FormDefinition): T; + error(errors: string[]): T; +} + +export interface FormImportDeps { + createForm(form: FormDefinition): void; } -export function makeFormInputModel(): FormImportModel { - const errors = writable([]); +function matchState(state: State, matchers: Matchers): T { + return pipe( + state, + E.match(matchers.error, (form) => + pipe( + form, + // prettier, shut up + O.match(matchers.empty, matchers.ok), + ), + ), + ); +} + +function noop() {} + +export function makeFormInputModel({ createForm }: FormImportDeps): FormImportModel { + const state = writable(E.of(O.none)); + const setErrors = (errors: string[]) => state.set(E.left(errors)); + const resetState = () => state.set(E.of(O.none)); return { - errors, + state, + uiState(state) { + return matchState(state, { + empty: () => ({ canSubmit: false, errors: [], onSubmit: noop, buttonHint: "" }), + ok: (form) => ({ + canSubmit: true, + errors: [], + onSubmit: () => createForm(form), + buttonHint: "✅", + }), + error: (errors) => ({ canSubmit: false, errors, onSubmit: noop, buttonHint: "❌" }), + }); + }, validate: (value: string) => { if (value.trim() === "") { - errors.set([]); + resetState(); return; } pipe( @@ -26,17 +75,17 @@ export function makeFormInputModel(): FormImportModel { E.match( (error) => { if (error instanceof InvalidData) { - errors.set(error.toArrayOfStrings()); + setErrors(error.toArrayOfStrings()); return; } - errors.set([error.toString()]); + setErrors([error.toString()]); }, (form) => { if (form instanceof MigrationError) { - errors.set(form.toArrayOfStrings()); + setErrors(form.toArrayOfStrings()); return; } - errors.set([]); + state.set(E.of(O.some(form))); console.log(form); }, ), diff --git a/src/views/FormImportView.ts b/src/views/FormImportView.ts index 890cc398..bf08b6ef 100644 --- a/src/views/FormImportView.ts +++ b/src/views/FormImportView.ts @@ -1,11 +1,17 @@ import { App, Modal } from "obsidian"; import FormImport from "./FormImport.svelte"; -import { makeFormInputModel } from "./FormImport"; - +import { FormImportDeps, makeFormInputModel } from "./FormImport"; +/** + * This class is just the minimum glue code to bind our core logic + * with the svelte UI and obsidian API modal. + */ export class FormImportModal extends Modal { _component!: FormImport; - constructor(app: App) { + constructor( + app: App, + private deps: FormImportDeps, + ) { super(app); } @@ -17,7 +23,7 @@ export class FormImportModal extends Modal { const { contentEl } = this; this._component = new FormImport({ target: contentEl, - props: { model: makeFormInputModel() }, + props: { model: makeFormInputModel(this.deps) }, }); } }