diff --git a/.prettierrc b/.prettierrc index a0e6323b..664d4497 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,9 +1,8 @@ { - "plugins": [ - "prettier-plugin-svelte" - ], + "plugins": ["prettier-plugin-svelte"], "arrowParens": "always", "editorconfig": true, "svelteAllowShorthand": true, - "trailingComma": "all" -} \ No newline at end of file + "trailingComma": "all", + "printWidth": 120 +} diff --git a/src/FormModal.ts b/src/FormModal.ts index 89830ac3..4cdcd562 100644 --- a/src/FormModal.ts +++ b/src/FormModal.ts @@ -1,20 +1,16 @@ import { App, Modal, Platform, Setting } from "obsidian"; +import * as R from "fp-ts/Record"; import MultiSelect from "./views/components/MultiSelect.svelte"; -import FormResult, { - type ModalFormData, -} from "./core/FormResult"; -import { formDataFromFormDefaults } from './core/formDataFromFormDefaults'; +import FormResult, { type ModalFormData } from "./core/FormResult"; +import { formDataFromFormDefaults } from "./core/formDataFromFormDefaults"; import { exhaustiveGuard } from "./safety"; import { get_tfiles_from_folder } from "./utils/files"; import type { FormDefinition, FormOptions } from "./core/formDefinition"; import { FileSuggest } from "./suggesters/suggestFile"; import { DataviewSuggest } from "./suggesters/suggestFromDataview"; import { SvelteComponent } from "svelte"; -import { - executeSandboxedDvQuery, - sandboxedDvQuery, -} from "./suggesters/SafeDataviewQuery"; -import { A, E, pipe, throttle } from "@std"; +import { executeSandboxedDvQuery, sandboxedDvQuery } from "./suggesters/SafeDataviewQuery"; +import { A, E, parseFunctionBody, pipe, throttle } from "@std"; import { log_error, log_notice } from "./utils/Log"; import { FieldValue, FormEngine, makeFormEngine } from "./store/formStore"; import { Writable } from "svelte/store"; @@ -22,9 +18,13 @@ import { FolderSuggest } from "./suggesters/suggestFolder"; export type SubmitFn = (formResult: FormResult) => void; +const notify = throttle((msg: string) => log_notice("⚠️ The form has errors ⚠️", msg, "notice-warning"), 2000); +const notifyError = (title: string) => + throttle((msg: string) => log_notice(`🚨 ${title} 🚨`, msg, "notice-error"), 2000); + export class FormModal extends Modal { svelteComponents: SvelteComponent[] = []; - initialFormValues: ModalFormData + initialFormValues: ModalFormData; subscriptions: (() => void)[] = []; formEngine: FormEngine; constructor( @@ -34,31 +34,19 @@ export class FormModal extends Modal { options?: FormOptions, ) { super(app); - this.initialFormValues = formDataFromFormDefaults(modalDefinition.fields, options?.values ?? {}) + this.initialFormValues = formDataFromFormDefaults(modalDefinition.fields, options?.values ?? {}); this.formEngine = makeFormEngine((result) => { this.onSubmit(new FormResult(result, "ok")); this.close(); }, this.initialFormValues); - this.formEngine.subscribe(console.log) + // this.formEngine.subscribe(console.log); } - // onOpen2() { - // const { contentEl } = this; - // const component = new FormModalComponent({ - // target: contentEl, - // props: { - // onSubmit: this.onSubmit, - // formDefinition: this.modalDefinition, - // }, - // }); - // this.svelteComponents.push(component); - // } onOpen() { const { contentEl } = this; // This class is very important for scoped styles - contentEl.addClass('modal-form'); - if (this.modalDefinition.customClassname) - contentEl.addClass(this.modalDefinition.customClassname); + contentEl.addClass("modal-form"); + if (this.modalDefinition.customClassname) contentEl.addClass(this.modalDefinition.customClassname); contentEl.createEl("h1", { text: this.modalDefinition.title }); this.modalDefinition.fields.forEach((definition) => { const fieldBase = new Setting(contentEl) @@ -71,14 +59,11 @@ export class FormModal extends Modal { const type = fieldInput.type; const initialValue = this.initialFormValues[definition.name]; const fieldStore = this.formEngine.addField(definition); - const subToErrors = ( - input: HTMLInputElement | HTMLTextAreaElement, - ) => { - const notify = throttle((msg: string) => log_notice('⚠️ The form has errors ⚠️', msg, 'notice-warning'), 2000) + const subToErrors = (input: HTMLInputElement | HTMLTextAreaElement) => { this.subscriptions.push( fieldStore.errors.subscribe((errs) => { - console.log('errors', errs) - errs.forEach(notify) + errs.length > 0 ? console.log("errors", errs) : void 0; + errs.forEach(notify); input.setCustomValidity(errs.join("\n")); }), ); @@ -93,8 +78,7 @@ export class FormModal extends Modal { textEl.setValue(initialValue); } textEl.inputEl.rows = 6; - if (Platform.isIosApp) - textEl.inputEl.style.width = "100%"; + if (Platform.isIosApp) textEl.inputEl.style.width = "100%"; else if (Platform.isDesktopApp) { textEl.inputEl.rows = 10; } @@ -109,8 +93,7 @@ export class FormModal extends Modal { text.inputEl.type = type; subToErrors(text.inputEl); text.onChange(fieldStore.value.set); - initialValue !== undefined && - text.setValue(String(initialValue)); + initialValue !== undefined && text.setValue(String(initialValue)); }); case "number": return fieldBase.addText((text) => { @@ -121,14 +104,12 @@ export class FormModal extends Modal { fieldStore.value.set(Number(val) + ""); } }); - initialValue !== undefined && - text.setValue(String(initialValue)); + initialValue !== undefined && text.setValue(String(initialValue)); }); case "datetime": return fieldBase.addText((text) => { text.inputEl.type = "datetime-local"; - initialValue !== undefined && - text.setValue(String(initialValue)); + initialValue !== undefined && text.setValue(String(initialValue)); subToErrors(text.inputEl); text.onChange(fieldStore.value.set); }); @@ -157,10 +138,7 @@ export class FormModal extends Modal { }); case "folder": return fieldBase.addText((element) => { - new FolderSuggest( - element.inputEl, - this.app, - ); + new FolderSuggest(element.inputEl, this.app); subToErrors(element.inputEl); element.onChange(fieldStore.value.set); }); @@ -181,22 +159,16 @@ export class FormModal extends Modal { source == "fixed" ? fieldInput.multi_select_options : source == "notes" - ? pipe( - get_tfiles_from_folder( - fieldInput.folder, - this.app, - ), + ? pipe( + get_tfiles_from_folder(fieldInput.folder, this.app), E.map(A.map((file) => file.basename)), E.getOrElse((err) => { log_error(err); return [] as string[]; }), ) - : executeSandboxedDvQuery( - sandboxedDvQuery(fieldInput.query), - this.app, - ); - fieldStore.value.set(initialValue ?? []) + : executeSandboxedDvQuery(sandboxedDvQuery(fieldInput.query), this.app); + fieldStore.value.set(initialValue ?? []); this.svelteComponents.push( new MultiSelect({ target: fieldBase.controlEl, @@ -212,10 +184,8 @@ export class FormModal extends Modal { return; } case "tag": { - const options = Object.keys( - this.app.metadataCache.getTags(), - ).map((tag) => tag.slice(1)); // remove the # - fieldStore.value.set(initialValue ?? []) + const options = Object.keys(this.app.metadataCache.getTags()).map((tag) => tag.slice(1)); // remove the # + fieldStore.value.set(initialValue ?? []); this.svelteComponents.push( new MultiSelect({ target: fieldBase.controlEl, @@ -238,69 +208,81 @@ export class FormModal extends Modal { subToErrors(element.inputEl); }); } - case "select": - { - const source = fieldInput.source; - switch (source) { - case "fixed": - return fieldBase.addDropdown((element) => { - fieldInput.options.forEach((option) => { - element.addOption( - option.value, - option.label, - ); - }); - fieldStore.value.set(element.getValue()); - element.onChange(fieldStore.value.set); + case "select": { + const source = fieldInput.source; + switch (source) { + case "fixed": + return fieldBase.addDropdown((element) => { + fieldInput.options.forEach((option) => { + element.addOption(option.value, option.label); }); + fieldStore.value.set(element.getValue()); + element.onChange(fieldStore.value.set); + }); - case "notes": - return fieldBase.addDropdown((element) => { - const files = get_tfiles_from_folder( - fieldInput.folder, - this.app, - ); - pipe( - files, - E.map((files) => - files.reduce( - ( - acc: Record, - option, - ) => { - acc[option.basename] = - option.basename; - return acc; - }, - {}, - ), - ), - E.mapLeft((err) => { - log_error(err); - return err; - }), - E.map((options) => { - element.addOptions(options); - }), - ); - fieldStore.value.set(element.getValue()); - element.onChange(fieldStore.value.set); - }); - default: - exhaustiveGuard(source); - } + case "notes": + return fieldBase.addDropdown((element) => { + const files = get_tfiles_from_folder(fieldInput.folder, this.app); + pipe( + files, + E.map((files) => + files.reduce((acc: Record, option) => { + acc[option.basename] = option.basename; + return acc; + }, {}), + ), + E.mapLeft((err) => { + log_error(err); + return err; + }), + E.map((options) => { + element.addOptions(options); + }), + ); + fieldStore.value.set(element.getValue()); + element.onChange(fieldStore.value.set); + }); + default: + exhaustiveGuard(source); } break; + } + case "document_block": { + const functionBody = fieldInput.body; + const functionParsed = parseFunctionBody<[Record], string>( + functionBody, + "form", + ); + const domNode = fieldBase.infoEl.createDiv(); + const sub = this.formEngine.subscribe((form) => { + pipe( + functionParsed, + E.chainW((fn) => + pipe( + form.fields, + R.filterMap((field) => field.value), + fn, + ), + ), + E.match( + (error) => { + console.error(error); + notifyError("Error in document block")(String(error)); + }, + (newText) => domNode.setText(newText), + ), + ); + }); + return this.subscriptions.push(sub); + } + default: return exhaustiveGuard(type); } }); new Setting(contentEl).addButton((btn) => - btn - .setButtonText("Submit") - .setCta() - .onClick(this.formEngine.triggerSubmit), + btn.setButtonText("Submit").setCta().onClick(this.formEngine.triggerSubmit), ); const submitEnterCallback = (evt: KeyboardEvent) => { @@ -318,6 +300,6 @@ export class FormModal extends Modal { this.svelteComponents.forEach((component) => component.$destroy()); this.subscriptions.forEach((subscription) => subscription()); contentEl.empty(); - this.initialFormValues = {} + this.initialFormValues = {}; } } diff --git a/src/core/InputDefinitionSchema.ts b/src/core/InputDefinitionSchema.ts new file mode 100644 index 00000000..d38b8ca1 --- /dev/null +++ b/src/core/InputDefinitionSchema.ts @@ -0,0 +1,154 @@ +import { trySchemas, ParsingFn, parseC } from "@std"; +import { AllFieldTypes } from "./formDefinition"; +import { + object, + number, + literal, + array, + string, + union, + optional, + minLength, + toTrimmed, + BaseSchema, + enumType, + Output, +} from "valibot"; + +/** + * Here are the definition for the input types. + * This types are the ones that represent the just the input. + * This is how they are stored in the settings/form definition. + * For schemas of the FieldDefinition, see formDefinitionSchema.ts + **/ + +export 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", + "email", + "tel", +]); +//=========== Schema definitions +export const SelectFromNotesSchema = object({ + type: literal("select"), + source: literal("notes"), + folder: nonEmptyString("folder name"), +}); +export const InputTagSchema = object({ + type: literal("tag"), + exclude: optional(string()), // This should be a regex string +}); +export const InputSliderSchema = object({ + type: literal("slider"), + min: number(), + max: number(), +}); +export const InputNoteFromFolderSchema = object({ + type: literal("note"), + folder: nonEmptyString("folder name"), +}); +export const InputFolderSchema = object({ + type: literal("folder"), + // TODO: allow exclude option +}); +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()), +}); +const MultiSelectQuerySchema = object({ + type: literal("multiselect"), + source: literal("dataview"), + query: nonEmptyString("dataview query"), +}); +export const MultiselectSchema = union([ + MultiSelectNotesSchema, + MultiSelectFixedSchema, + MultiSelectQuerySchema, +]); + +// This is a special type of input that lets you render a string +// based on the values of other fields. +const DocumentBlock = object({ + type: literal("document_block"), + body: string(), +}); + +// Codec for all the input types +export const InputTypeSchema = union([ + InputBasicSchema, + InputNoteFromFolderSchema, + InputFolderSchema, + InputSliderSchema, + InputTagSchema, + SelectFromNotesSchema, + InputDataviewSourceSchema, + InputSelectFixedSchema, + MultiselectSchema, + DocumentBlock, +]); + +export const InputTypeToParserMap: Record< + AllFieldTypes, + ParsingFn +> = { + number: parseC(InputBasicSchema), + text: parseC(InputBasicSchema), + email: parseC(InputBasicSchema), + tel: parseC(InputBasicSchema), + date: parseC(InputBasicSchema), + time: parseC(InputBasicSchema), + datetime: parseC(InputBasicSchema), + textarea: parseC(InputBasicSchema), + toggle: parseC(InputBasicSchema), + note: parseC(InputNoteFromFolderSchema), + folder: parseC(InputFolderSchema), + slider: parseC(InputSliderSchema), + tag: parseC(InputTagSchema), + select: trySchemas([SelectFromNotesSchema, InputSelectFixedSchema]), + dataview: parseC(InputDataviewSourceSchema), + multiselect: parseC(MultiselectSchema), + document_block: parseC(DocumentBlock), +}; + +//=========== Types derived from schemas +export type selectFromNotes = Output; +export type inputSlider = Output; +export type inputNoteFromFolder = Output; +export type inputDataviewSource = Output; +export type inputSelectFixed = Output; +export type basicInput = Output; +export type multiselect = Output; +export type inputType = Output; diff --git a/src/core/findInputDefinitionSchema.ts b/src/core/findInputDefinitionSchema.ts index 181828f9..f7b973df 100644 --- a/src/core/findInputDefinitionSchema.ts +++ b/src/core/findInputDefinitionSchema.ts @@ -1,49 +1,66 @@ import { A, NonEmptyArray, ParsingFn, parse, pipe } from "@std"; import * as E from "fp-ts/Either"; import { ValiError, BaseSchema } from "valibot"; -import { FieldMinimal, FieldMinimalSchema, InputTypeToParserMap } from "./formDefinitionSchema"; +import { FieldMinimal, FieldMinimalSchema } from "./formDefinitionSchema"; import { AllFieldTypes } from "./formDefinition"; +import { InputTypeToParserMap } from "./InputDefinitionSchema"; function stringifyIssues(error: ValiError): NonEmptyArray { - return error.issues.map((issue) => `${issue.path?.map((i) => i.key)}: ${issue.message} got ${issue.input}`) as NonEmptyArray; + return error.issues.map( + (issue) => + `${issue.path?.map((i) => i.key)}: ${issue.message} got ${ + issue.input + }`, + ) as NonEmptyArray; } export class InvalidInputTypeError { static readonly _tag = "InvalidInputTypeError" as const; - readonly path: string = 'input.type'; - constructor(readonly field: FieldMinimal, readonly inputType: unknown) { } + readonly path: string = "input.type"; + constructor( + readonly field: FieldMinimal, + readonly inputType: unknown, + ) {} toString(): string { return `InvalidInputTypeError: ${this.getFieldErrors()[0]}`; } getFieldErrors(): NonEmptyArray { - return [`"input.type" is invalid, got: ${JSON.stringify(this.inputType)}`] + return [ + `"input.type" is invalid, got: ${JSON.stringify(this.inputType)}`, + ]; } } export class InvalidInputError { static readonly _tag = "InvalidInputError" as const; readonly path: string; - constructor(readonly field: FieldMinimal, readonly error: ValiError) { - this.path = error.issues[0].path?.map((i) => i.key).join('.') ?? ''; + constructor( + readonly field: FieldMinimal, + readonly error: ValiError, + ) { + this.path = error.issues[0].path?.map((i) => i.key).join(".") ?? ""; } toString(): string { - return `InvalidInputError: ${stringifyIssues(this.error).join(', ')}`; + return `InvalidInputError: ${stringifyIssues(this.error).join(", ")}`; } getFieldErrors(): NonEmptyArray { - return stringifyIssues(this.error) + return stringifyIssues(this.error); } } export class InvalidFieldError { static readonly _tag = "InvalidFieldError" as const; readonly path: string; - constructor(public field: unknown, readonly error: ValiError) { - this.path = error.issues[0].path?.map((i) => i.key).join('.') ?? ''; + constructor( + public field: unknown, + readonly error: ValiError, + ) { + this.path = error.issues[0].path?.map((i) => i.key).join(".") ?? ""; } toString(): string { - return `InvalidFieldError: ${stringifyIssues(this.error).join(', ')}`; + return `InvalidFieldError: ${stringifyIssues(this.error).join(", ")}`; } getFieldErrors(): string[] { - return stringifyIssues(this.error) + return stringifyIssues(this.error); } static of(field: unknown) { return (error: ValiError) => new InvalidFieldError(field, error); @@ -51,7 +68,7 @@ export class InvalidFieldError { } function isValidInputType(input: unknown): input is AllFieldTypes { - return 'string' === typeof input && input in InputTypeToParserMap; + return "string" === typeof input && input in InputTypeToParserMap; } /** * Finds the corresponding schema to the provided unparsed field. @@ -63,15 +80,21 @@ function isValidInputType(input: unknown): input is AllFieldTypes { * @param fieldDefinition a field definition to find the input schema for * @returns a tuple of the basic field definition and the input schema */ -export function findInputDefinitionSchema(fieldDefinition: unknown): E.Either]> { +export function findInputDefinitionSchema( + fieldDefinition: unknown, +): E.Either< + InvalidFieldError | InvalidInputTypeError, + [FieldMinimal, ParsingFn] +> { return pipe( parse(FieldMinimalSchema, fieldDefinition), E.mapLeft(InvalidFieldError.of(fieldDefinition)), E.chainW((field) => { const type = field.input.type; - if (isValidInputType(type)) return E.right([field, InputTypeToParserMap[type]]); + if (isValidInputType(type)) + return E.right([field, InputTypeToParserMap[type]]); else return E.left(new InvalidInputTypeError(field, type)); - }) + }), ); } /** @@ -87,16 +110,18 @@ export function findFieldErrors(fields: unknown[]) { A.map((fieldUnparsed) => { return pipe( findInputDefinitionSchema(fieldUnparsed), - E.chainW(([field, parser]) => pipe( - parser(field.input), - E.bimap( - (error) => new InvalidInputError(field, error), - () => field - )), - )) + E.chainW(([field, parser]) => + pipe( + parser(field.input), + E.bimap( + (error) => new InvalidInputError(field, error), + () => field, + ), + ), + ), + ); }), // A.partition(E.isLeft), // Separated.right, ); - } diff --git a/src/core/formDefinition.test.ts b/src/core/formDefinition.test.ts index 514a3def..6526ac47 100644 --- a/src/core/formDefinition.test.ts +++ b/src/core/formDefinition.test.ts @@ -1,3 +1,4 @@ +import { MultiselectSchema } from "./InputDefinitionSchema"; import { isDataViewSource, isInputNoteFromFolder, @@ -5,7 +6,6 @@ import { isInputSlider, isSelectFromNotes, } from "./formDefinition"; -import { MultiselectSchema } from "./formDefinitionSchema"; import { parse } from "valibot"; describe("isDataViewSource", () => { diff --git a/src/core/formDefinition.ts b/src/core/formDefinition.ts index ba5167a3..4fad3a66 100644 --- a/src/core/formDefinition.ts +++ b/src/core/formDefinition.ts @@ -1,13 +1,5 @@ import { type Output, is, safeParse } from "valibot"; import { - SelectFromNotesSchema, - InputSliderSchema, - InputNoteFromFolderSchema, - InputDataviewSourceSchema, - InputSelectFixedSchema, - InputBasicSchema, - MultiselectSchema, - InputTypeSchema, FieldDefinitionSchema, FormDefinitionLatestSchema, FieldListSchema, @@ -16,17 +8,26 @@ import { } from "./formDefinitionSchema"; import { A, O, pipe } from "@std"; import { Simplify } from "type-fest"; -//=========== Types derived from schemas -type selectFromNotes = Output; -type inputSlider = Output; -type inputNoteFromFolder = Output; -type inputDataviewSource = Output; -type inputSelectFixed = Output; -type basicInput = Output; -type multiselect = Output; -type inputType = Output; - -export const FieldTypeReadable: Record = { +import { + InputBasicSchema, + InputDataviewSourceSchema, + InputNoteFromFolderSchema, + InputSelectFixedSchema, + InputSliderSchema, + InputTypeSchema, + MultiselectSchema, + SelectFromNotesSchema, + basicInput, + inputDataviewSource, + inputNoteFromFolder, + inputSelectFixed, + inputSlider, + inputType, + multiselect, + selectFromNotes, +} from "./InputDefinitionSchema"; + +export const InputTypeReadable: Record = { text: "Text", number: "Number", tag: "Tags", @@ -43,6 +44,7 @@ export const FieldTypeReadable: Record = { select: "Select", dataview: "Dataview", multiselect: "Multiselect", + document_block: "Document block", } as const; export function isDataViewSource(input: unknown): input is inputDataviewSource { diff --git a/src/core/formDefinitionSchema.ts b/src/core/formDefinitionSchema.ts index f03087c0..68ca74ad 100644 --- a/src/core/formDefinitionSchema.ts +++ b/src/core/formDefinitionSchema.ts @@ -1,141 +1,29 @@ import * as E from "fp-ts/Either"; -import { pipe, parse, trySchemas, ParsingFn, parseC } from "@std"; +import { pipe, parse } from "@std"; import { object, - number, literal, type Output, is, array, string, - union, optional, - minLength, - toTrimmed, merge, unknown, ValiError, - BaseSchema, - enumType, passthrough, boolean, } from "valibot"; -import { AllFieldTypes, FormDefinition } from "./formDefinition"; +import { FormDefinition } from "./formDefinition"; import { findFieldErrors } from "./findInputDefinitionSchema"; import { ParsedTemplateSchema } from "./template/templateSchema"; +import { InputTypeSchema, nonEmptyString } from "./InputDefinitionSchema"; /** * 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", - "email", - "tel", -]); -//=========== Schema definitions -export const SelectFromNotesSchema = object({ - type: literal("select"), - source: literal("notes"), - folder: nonEmptyString("folder name"), -}); -export const InputTagSchema = object({ - type: literal("tag"), - exclude: optional(string()), // This should be a regex string -}); -export const InputSliderSchema = object({ - type: literal("slider"), - min: number(), - max: number(), -}); -export const InputNoteFromFolderSchema = object({ - type: literal("note"), - folder: nonEmptyString("folder name"), -}); -export const InputFolderSchema = object({ - type: literal("folder"), - // TODO: allow exclude option -}); -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()), -}); -const MultiSelectQuerySchema = object({ - type: literal("multiselect"), - source: literal("dataview"), - query: nonEmptyString("dataview query"), -}); -export const MultiselectSchema = union([ - MultiSelectNotesSchema, - MultiSelectFixedSchema, - MultiSelectQuerySchema, -]); -export const InputTypeSchema = union([ - InputBasicSchema, - InputNoteFromFolderSchema, - InputFolderSchema, - InputSliderSchema, - InputTagSchema, - SelectFromNotesSchema, - InputDataviewSourceSchema, - InputSelectFixedSchema, - MultiselectSchema, -]); -export const InputTypeToParserMap: Record< - AllFieldTypes, - ParsingFn -> = { - number: parseC(InputBasicSchema), - text: parseC(InputBasicSchema), - email: parseC(InputBasicSchema), - tel: parseC(InputBasicSchema), - date: parseC(InputBasicSchema), - time: parseC(InputBasicSchema), - datetime: parseC(InputBasicSchema), - textarea: parseC(InputBasicSchema), - toggle: parseC(InputBasicSchema), - note: parseC(InputNoteFromFolderSchema), - folder: parseC(InputFolderSchema), - slider: parseC(InputSliderSchema), - tag: parseC(InputTagSchema), - select: trySchemas([SelectFromNotesSchema, InputSelectFixedSchema]), - dataview: parseC(InputDataviewSourceSchema), - multiselect: parseC(MultiselectSchema), -}; export const FieldDefinitionSchema = object({ name: nonEmptyString("field name"), diff --git a/src/exampleModalDefinition.ts b/src/exampleModalDefinition.ts index a3e2a510..91784776 100644 --- a/src/exampleModalDefinition.ts +++ b/src/exampleModalDefinition.ts @@ -6,17 +6,18 @@ export const exampleModalDefinition: FormDefinition = { version: "1", fields: [ { - name: "Name", + name: "name", + label: "Name", description: "It is named how?", isRequired: true, - input: { type: "text", }, + input: { type: "text" }, }, { name: "age", label: "Age", description: "How old", isRequired: true, - input: { type: "number", }, + input: { type: "number" }, }, { name: "dateOfBirth", @@ -41,7 +42,8 @@ export const exampleModalDefinition: FormDefinition = { label: "Favorite book", description: "Pick one", input: { type: "note", folder: "Books" }, - }, { + }, + { name: "folder", label: "The destination folder", description: "It offers auto-completion to existing folders", @@ -145,5 +147,13 @@ export const exampleModalDefinition: FormDefinition = { description: "Tags input example", input: { type: "tag" }, }, + { + name: "document", + description: "Document block example", + input: { + type: "document_block", + body: "return `Hello ${form.name}!\n Your best friend is ${form.best_fried}`", + }, + }, ], }; diff --git a/src/std/index.test.ts b/src/std/index.test.ts index f02f5390..6086fe66 100644 --- a/src/std/index.test.ts +++ b/src/std/index.test.ts @@ -1,4 +1,4 @@ -import { E, pipe, trySchemas } from "./index"; +import { E, parseFunctionBody, pipe, trySchemas } from "./index"; import { string, number, array, boolean, object } from "valibot"; describe("trySchemas", () => { @@ -29,7 +29,7 @@ describe("trySchemas", () => { hobbies: ["reading", "swimming"], isEmployed: true, }; - console.log(' ====== 1 =====') + console.log(" ====== 1 ====="); const result = trySchemas([schema1, schema2, schema3])(input); expect(result).toEqual({ _tag: "Right", @@ -49,7 +49,7 @@ describe("trySchemas", () => { age: 25, isStudent: true, }; - console.log(' ====== 2 =====') + console.log(" ====== 2 ====="); const result = trySchemas([schema1, schema2, schema3])(input); expect(result).toEqual({ _tag: "Right", @@ -69,12 +69,12 @@ describe("trySchemas", () => { hobbies: ["reading", "swimming"], isEmployed: true, }; - console.log(' ====== 3 =====') + console.log(" ====== 3 ====="); const result = trySchemas([schema1, schema2, schema3])(input); if (E.isLeft(result)) { - expect(result.left.message).toEqual('Invalid type'); + expect(result.left.message).toEqual("Invalid type"); } else { - fail('expected a Left') + fail("expected a Left"); } }); @@ -85,7 +85,7 @@ describe("trySchemas", () => { hobbies: ["reading", "swimming"], isEmployed: true, }; - console.log(' ====== 4 =====') + console.log(" ====== 4 ====="); const result = trySchemas([schema1])(input); expect(result).toEqual(E.right(input)); }); @@ -101,8 +101,41 @@ describe("trySchemas", () => { pipe( result, E.bimap( - (x) => expect(x.message).toEqual('Invalid type'), - () => fail('expected a Left') - )) + (x) => expect(x.message).toEqual("Invalid type"), + () => fail("expected a Left"), + ), + ); + }); +}); + +describe("parseFunctionBody", () => { + it("should parse a function body", () => { + const input = "return x + 1;"; + const result = parseFunctionBody(input); + pipe( + result, + E.match( + (err) => fail("Expected a right"), + (result) => expect(result).toBeInstanceOf(Function), + ), + ); + }); + it("should fail to parse a function body when it is incorrect", () => { + const input = "{ return x + 1; "; + const result = parseFunctionBody(input); + expect(result).toEqual(E.left(new SyntaxError("Unexpected token ')'"))); + }); + it("should parse a function body with arguments and be able to execute it", () => { + const input = "return x + 1;"; + const result = parseFunctionBody<[number], number>(input, "x"); + pipe( + result, + E.match( + () => fail("Expected a right"), + (result) => { + expect(result(1)).toEqual(E.right(2)); + }, + ), + ); }); }); diff --git a/src/std/index.ts b/src/std/index.ts index a95aa964..62f4508c 100644 --- a/src/std/index.ts +++ b/src/std/index.ts @@ -1,14 +1,48 @@ import { pipe as p, flow as f } from "fp-ts/function"; -import { partitionMap, findFirst, findFirstMap, partition, map as mapArr, filter, compact, filterMap } from "fp-ts/Array"; -import { map as mapO, getOrElse as getOrElseOpt, some, none, fromNullable as fromNullableOpt, fold as ofold } from 'fp-ts/Option' -import { isLeft, isRight, tryCatchK, map, getOrElse, fromNullable, right, left, mapLeft, Either, bimap, tryCatch, flatMap } from "fp-ts/Either"; +import { + partitionMap, + findFirst, + findFirstMap, + partition, + map as mapArr, + filter, + compact, + filterMap, +} from "fp-ts/Array"; +import { + map as mapO, + getOrElse as getOrElseOpt, + some, + none, + fromNullable as fromNullableOpt, + fold as ofold, +} from "fp-ts/Option"; +import { + isLeft, + isRight, + tryCatchK, + map, + getOrElse, + fromNullable, + right, + left, + mapLeft, + Either, + bimap, + tryCatch, + flatMap, + ap, + flap, + chainW, + match, +} from "fp-ts/Either"; 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"; -export type { NonEmptyArray } from 'fp-ts/NonEmptyArray' -export type { Either, Left, Right } from 'fp-ts/Either' +export type { NonEmptyArray } from "fp-ts/NonEmptyArray"; +export type { Either, Left, Right } from "fp-ts/Either"; export const flow = f; -export const pipe = p +export const pipe = p; export const A = { partitionMap, partition, @@ -17,14 +51,14 @@ export const A = { findFirstMap, map: mapArr, filter, - filterMap -} + filterMap, +}; /** * Non empty array */ export const NEA = { - concatAll: concatAllNea -} + concatAll: concatAllNea, +}; export const E = { isLeft, @@ -39,32 +73,37 @@ export const E = { bimap, flatMap, fromNullable, -} + match, + ap, + flap, + chainW, + fold: match, +}; export const O = { map: mapO, getOrElse: getOrElseOpt, - some, none, + some, + none, fold: ofold, fromNullable: fromNullableOpt, -} +}; +export const parse = tryCatchK(parseV, (e: unknown) => e as ValiError); -export const parse = tryCatchK(parseV, (e: unknown) => e as ValiError) - -type ParseOpts = Parameters[2] +type ParseOpts = Parameters[2]; /** - * + * * curried version of parse. * It takes first the schema and the options * and produces a function that takes the input * and returns the result of parsing the input with the schema and options. */ export function parseC(schema: S, options?: ParseOpts) { - return (input: unknown) => parse(schema, input, options) + return (input: unknown) => parse(schema, input, options); } -export type ParsingFn = (input: unknown) => Either> +export type ParsingFn = (input: unknown) => Either>; /** * Concatenates two parsing functions that return Either into one. * If the first function returns a Right, the second function is not called. @@ -72,35 +111,38 @@ export type ParsingFn = (input: unknown) => Either implements Semigroup> { concat(f: ParsingFn, g: ParsingFn): (i: unknown) => Either { return (i) => { - const fRes = f(i) - if (isRight(fRes)) return fRes - return g(i) - } + const fRes = f(i); + if (isRight(fRes)) return fRes; + return g(i); + }; } } -export const EFunSemigroup = new _EFunSemigroup() +export const EFunSemigroup = new _EFunSemigroup(); /** - * Takes an array of schemas and returns a function + * Takes an array of schemas and returns a function * that tries to parse the input with each schema. */ export function trySchemas(schemas: NonEmptyArray, options?: ParseOpts) { - const [first, ...rest] = schemas + const [first, ...rest] = schemas; return pipe( rest, A.map((schema) => parseC(schema, options)), concatAll(EFunSemigroup)(parseC(first, options)), - ) + ); } -export function throttle(cb: (...args: [...T]) => V, timeout?: number): (...args: [...T]) => V | undefined; +export function throttle( + cb: (...args: [...T]) => V, + timeout?: number, +): (...args: [...T]) => V | undefined; export function throttle(fn: (...args: unknown[]) => unknown, ms = 100) { let lastCall = 0; return function (...args: unknown[]) { const now = Date.now(); if (now - lastCall < ms) { - lastCall = now;//reset the last call time,so it needs cooldown time + lastCall = now; //reset the last call time,so it needs cooldown time return; } lastCall = now; @@ -110,7 +152,31 @@ export function throttle(fn: (...args: unknown[]) => unknown, ms = 100) { export function tap(msg: string) { return (x: T) => { - console.log(msg, x) - return x + console.log(msg, x); + return x; + }; +} + +function ensureError(e: unknown): Error { + return e instanceof Error ? e : new Error(String(e)); +} + +/** + * Creates a function from a string that is supposed to be a function body. + * It ensures the "use strict" directive is present and returns the function. + * Because the parsing can fail, it returns an Either. + * The reason why the type arguments are reversed is because + * we often know what the function input types should be, but + * we can't trust the function body to return the correct type, so by default1t it will be unknown + * The returned function is also wrapped to never throw and return an Either instead + */ +export function parseFunctionBody(body: string, ...args: string[]) { + const fnBody = `"use strict"; +${body}`; + try { + const fn = new Function(...args, fnBody) as (...args: Args) => T; + return right(tryCatchK(fn, ensureError)); + } catch (e) { + return left(ensureError(e)); } } diff --git a/src/views/FormBuilder.svelte b/src/views/FormBuilder.svelte index d7ae4467..97623634 100644 --- a/src/views/FormBuilder.svelte +++ b/src/views/FormBuilder.svelte @@ -3,8 +3,8 @@ type EditableFormDefinition, type FormDefinition, isValidFormDefinition, - FieldTypeReadable, validateFields, + InputTypeReadable, } from "src/core/formDefinition"; import { setIcon } from "obsidian"; import InputBuilderDataview from "./components/inputBuilderDataview.svelte"; @@ -22,6 +22,7 @@ ParsedTemplate, parsedTemplateToString, } from "src/core/template/templateParser"; + import InputBuilderDocumentBlock from "./components/InputBuilderDocumentBlock.svelte"; export let definition: EditableFormDefinition = { title: "", @@ -309,7 +310,7 @@ bind:value={field.input.type} id={`type_${index}`} > - {#each Object.entries(FieldTypeReadable) as type} + {#each Object.entries(InputTypeReadable) as type} @@ -370,6 +371,11 @@ {index} bind:value={field.input.query} /> + {:else if field.input.type === "document_block"} + {/if}
diff --git a/src/views/components/InputBuilderDocumentBlock.svelte b/src/views/components/InputBuilderDocumentBlock.svelte new file mode 100644 index 00000000..3fedd403 --- /dev/null +++ b/src/views/components/InputBuilderDocumentBlock.svelte @@ -0,0 +1,39 @@ + + + + + This is a document block input. It is not meant to be used as a normal + input, instead it is to render some instructions to the user. It is + expected to be a function body that returns a string. Within the + function body, you can access the form data using the form + variable. For example: +
{placeholder}
+