diff --git a/package-lock.json b/package-lock.json index da5ec238..764a8f68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-modal-form", - "version": "1.40.4", + "version": "1.46.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-modal-form", - "version": "1.40.4", + "version": "1.46.0", "license": "MIT", "dependencies": { "fp-ts": "^2.16.1", @@ -2662,12 +2662,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -3641,9 +3641,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" diff --git a/src/FormModal.svelte b/src/FormModal.svelte new file mode 100644 index 00000000..2baf57f7 --- /dev/null +++ b/src/FormModal.svelte @@ -0,0 +1,65 @@ + + +{#each fields as definition} + {@const { value, errors } = formEngine.addField(definition)} + {#if definition.input.type === "select"} + + {:else if definition.input.type === "toggle"} + + {:else if definition.input.type === "folder"} + + {:else if definition.input.type === "dataview"} + + {:else if definition.input.type === "note"} + + {:else if definition.input.type === "textarea"} + + {:else if definition.input.type === "document_block"} + + + + + {:else} + + {#if definition.input.type === "multiselect"} + + {:else if definition.input.type === "slider"} + + {:else if definition.input.type === "tag"} + + {:else} + + {/if} + + {/if} +{/each} diff --git a/src/FormModal.ts b/src/FormModal.ts index 5ee2b1ed..43cbc593 100644 --- a/src/FormModal.ts +++ b/src/FormModal.ts @@ -1,35 +1,24 @@ -import { E, absurd, parseFunctionBody, pipe, throttle } from "@std"; -import * as R from "fp-ts/Record"; -import * as TE from "fp-ts/TaskEither"; -import { App, Modal, Platform, Setting, sanitizeHTMLToDom } from "obsidian"; +import { throttle } from "@std"; +import { App, Modal, Setting } from "obsidian"; import { SvelteComponent } from "svelte"; -import { Writable } from "svelte/store"; +import FormModalComponent from "./FormModal.svelte"; import FormResult, { type ModalFormData } from "./core/FormResult"; import { formDataFromFormDefaults } from "./core/formDataFromFormDefaults"; import type { FormDefinition, FormOptions } from "./core/formDefinition"; -import { FieldValue, FormEngine, makeFormEngine } from "./store/formStore"; -import { FileSuggest } from "./suggesters/suggestFile"; -import { FolderSuggest } from "./suggesters/suggestFolder"; -import { DataviewSuggest } from "./suggesters/suggestFromDataview"; -import { log_error, log_notice } from "./utils/Log"; -import { get_tfiles_from_folder } from "./utils/files"; -import MultiSelect from "./views/components/MultiSelect.svelte"; -import { MultiSelectModel, MultiSelectTags } from "./views/components/MultiSelectModel"; +import { FormEngine, makeFormEngine } from "./store/formStore"; +import { log_notice } from "./utils/Log"; export type SubmitFn = (formResult: FormResult) => void; const notify = throttle( - (msg: string) => log_notice("⚠️ The form has errors ⚠️", msg, "notice-warning"), + (msg: string[]) => log_notice("⚠️ The form has errors ⚠️", msg.join("\n"), "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; subscriptions: (() => void)[] = []; - formEngine: FormEngine; + formEngine: FormEngine; constructor( app: App, private modalDefinition: FormDefinition, @@ -62,236 +51,17 @@ export class FormModal extends Modal { if (this.modalDefinition.customClassname) contentEl.addClass(this.modalDefinition.customClassname); contentEl.createEl("h1", { text: this.modalDefinition.title }); - this.modalDefinition.fields.forEach((definition) => { - const name = definition.label || definition.name; - const required = definition.isRequired ?? false; - const fieldBase = new Setting(contentEl) - .setName(`${name} ${required ? "*" : ""}`.trim()) - .setDesc(definition.description); - // This intermediary constants are necessary so typescript can narrow down the proper types. - // without them, you will have to use the whole access path (definition.input.folder), - // and it is no specific enough when you use it in a switch statement. - const fieldInput = definition.input; - const type = fieldInput.type; - const initialValue = this.initialFormValues[definition.name]; - const fieldStore = this.formEngine.addField(definition); - const subToErrors = (input: HTMLInputElement | HTMLTextAreaElement) => { - this.subscriptions.push( - fieldStore.errors.subscribe((errs) => { - errs.length > 0 ? console.log("errors", errs) : void 0; - errs.forEach(notify); - input.setCustomValidity(errs.join("\n")); - }), - ); - }; - switch (type) { - case "textarea": { - fieldBase.setClass("modal-form-textarea"); - return fieldBase.addTextArea((textEl) => { - textEl.onChange(fieldStore.value.set); - subToErrors(textEl.inputEl); - if (typeof initialValue === "string") { - textEl.setValue(initialValue); - } - textEl.inputEl.rows = 6; - if (Platform.isIosApp) textEl.inputEl.style.width = "100%"; - else if (Platform.isDesktopApp) { - textEl.inputEl.rows = 10; - } - }); - } - case "email": - case "tel": - case "date": - case "time": - case "text": - return fieldBase.addText((text) => { - text.inputEl.type = type; - subToErrors(text.inputEl); - text.onChange(fieldStore.value.set); - initialValue !== undefined && text.setValue(String(initialValue)); - }); - case "number": - return fieldBase.addText((text) => { - text.inputEl.type = "number"; - text.inputEl.step = "any"; - subToErrors(text.inputEl); - text.onChange((val) => { - if (val !== "") { - fieldStore.value.set(Number(val) + ""); - } - }); - initialValue !== undefined && text.setValue(String(initialValue)); - }); - case "datetime": - return fieldBase.addText((text) => { - text.inputEl.type = "datetime-local"; - initialValue !== undefined && text.setValue(String(initialValue)); - subToErrors(text.inputEl); - text.onChange(fieldStore.value.set); - }); - case "toggle": - return fieldBase.addToggle((toggle) => { - toggle.setValue(!!initialValue); - return toggle.onChange(fieldStore.value.set); - }); - case "note": - return fieldBase.addText((element) => { - new FileSuggest( - this.app, - element.inputEl, - { - renderSuggestion(file) { - return file.basename; - }, - selectSuggestion(file) { - return file.basename; - }, - }, - fieldInput.folder, - ); - subToErrors(element.inputEl); - element.onChange(fieldStore.value.set); - }); - case "folder": - return fieldBase.addText((element) => { - new FolderSuggest(element.inputEl, this.app); - subToErrors(element.inputEl); - element.onChange(fieldStore.value.set); - }); - case "slider": - return fieldBase.addSlider((slider) => { - slider.setLimits(fieldInput.min, fieldInput.max, 1); - slider.setDynamicTooltip(); - if (typeof initialValue === "number") { - slider.setValue(initialValue); - } else { - slider.setValue(fieldInput.min); - } - slider.onChange(fieldStore.value.set); - }); - case "multiselect": { - fieldStore.value.set(initialValue ?? []); - this.svelteComponents.push( - new MultiSelect({ - target: fieldBase.controlEl, - props: { - model: MultiSelectModel( - fieldInput, - this.app, - fieldStore.value as Writable, - ), - values: fieldStore.value as Writable, - errors: fieldStore.errors, - setting: fieldBase, - }, - }), - ); - return; - } - case "tag": { - fieldStore.value.set(initialValue ?? []); - this.svelteComponents.push( - new MultiSelect({ - target: fieldBase.controlEl, - props: { - values: fieldStore.value as Writable, - setting: fieldBase, - errors: fieldStore.errors, - model: Promise.resolve( - MultiSelectTags( - fieldInput, - this.app, - fieldStore.value as Writable, - ), - ), - }, - }), - ); - return; - } - case "dataview": { - const query = fieldInput.query; - return fieldBase.addText((element) => { - new DataviewSuggest(element.inputEl, query, this.app); - element.onChange(fieldStore.value.set); - 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); - }); - initialValue !== undefined && element.setValue(String(initialValue)); - 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: - absurd(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, - TE.fromEither, - TE.chainW((fn) => - pipe( - form.fields, - R.filterMap((field) => field.value), - fn, - ), - ), - TE.match( - (error) => { - console.error(error); - notifyError("Error in document block")(String(error)); - }, - (newText) => domNode.setText(sanitizeHTMLToDom(newText)), - ), - )(); - }); - return this.subscriptions.push(sub); - } - - default: - return absurd(type); - } - }); + this.svelteComponents.push( + new FormModalComponent({ + target: contentEl, + props: { + formEngine: this.formEngine, + fields: this.modalDefinition.fields, + app: this.app, + reportFormErrors: notify, + }, + }), + ); const buttons = new Setting(contentEl).addButton((btn) => btn.setButtonText("Cancel").onClick(this.formEngine.triggerCancel), diff --git a/src/core/InputDefinitionSchema.ts b/src/core/InputDefinitionSchema.ts index e9714464..841e26ad 100644 --- a/src/core/InputDefinitionSchema.ts +++ b/src/core/InputDefinitionSchema.ts @@ -1,20 +1,22 @@ -import { trySchemas, ParsingFn, parseC } from "@std"; -import { AllFieldTypes, AllSources } from "./formDefinition"; +import { ParsingFn, parseC, trySchemas } from "@std"; +import { absurd } from "fp-ts/function"; import { - object, - number, - literal, - array, - string, - union, - optional, - minLength, - toTrimmed, BaseSchema, - enumType, Output, + array, boolean, + enumType, + is, + literal, + minLength, + number, + object, + optional, + string, + toTrimmed, + union, } from "valibot"; +import { AllFieldTypes, AllSources } from "./formDefinition"; /** * Here are the definition for the input types. @@ -41,6 +43,10 @@ const InputBasicTypeSchema = enumType([ "email", "tel", ]); + +export const isBasicInputType = (type: string) => is(InputBasicTypeSchema, type); +export type BasicInputType = Output; + //=========== Schema definitions export const SelectFromNotesSchema = object({ type: literal("select"), @@ -162,7 +168,37 @@ export type inputSlider = Output; export type inputNoteFromFolder = Output; export type inputDataviewSource = Output; export type inputSelectFixed = Output; +export type Select = selectFromNotes | inputSelectFixed; export type basicInput = Output; export type multiselect = Output; export type inputTag = Output; export type inputType = Output; + +export type DocumentBlock = Output; + +export function requiresListOfStrings(input: inputType): boolean { + const type = input.type; + switch (type) { + case "multiselect": + case "tag": + return true; + case "select": + case "dataview": + case "note": + case "folder": + case "slider": + case "document_block": + case "number": + case "text": + case "date": + case "time": + case "datetime": + case "textarea": + case "toggle": + case "email": + case "tel": + return false; + default: + return absurd(type); + } +} diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 00000000..18bae0a7 --- /dev/null +++ b/src/core/index.ts @@ -0,0 +1 @@ +export * as input from "./InputDefinitionSchema"; diff --git a/src/store/formStore.ts b/src/store/formStore.ts index a573d6ea..a44c790d 100644 --- a/src/store/formStore.ts +++ b/src/store/formStore.ts @@ -1,13 +1,16 @@ +import * as R from "fp-ts/Record"; // This is the store that represents a runtime form. It is a writable store that contains the current state of the form // and the errors that are present in the form. It is used by the Form component to render the form and to update the -import { NonEmptyArray, pipe } from "@std"; +import { NonEmptyArray, flow, pipe } from "@std"; import * as A from "fp-ts/Array"; import * as E from "fp-ts/Either"; import * as O from "fp-ts/Option"; import { Option } from "fp-ts/Option"; import { fromEntries, toEntries } from "fp-ts/Record"; import { absurd } from "fp-ts/function"; +import { input } from "src/core"; +import { FieldDefinition } from "src/core/formDefinition"; import { Readable, Writable, derived, get, writable } from "svelte/store"; type Rule = { tag: "required"; message: string }; //| { tag: 'minLength', length: number, message: string } | { tag: 'maxLength', length: number, message: string } | { tag: 'pattern', pattern: RegExp, message: string }; @@ -37,7 +40,7 @@ type FormStore = { // TODO: instead of making the whole engine generic, make just the addField method generic extending the type of the field value // Then, the whole formEngine can be typed as FormEngine -export interface FormEngine { +export interface FormEngine { /** * Adds a field to the form engine. * It returns an object with a writable store that represents the value of the field, @@ -45,14 +48,14 @@ export interface FormEngine { * Use them to bind the field to the form and be notified of errors. * @param field a field definition to start tracking */ - addField: (field: { name: string; label?: string; isRequired?: boolean }) => { - value: Writable; + addField(field: { name: string; label?: string; isRequired?: boolean }): { + value: Writable; errors: Readable; }; /** * Subscribes to the form store. This method is required to conform to the svelte store interface. */ - subscribe: Readable>["subscribe"]; + subscribe: Readable>["subscribe"]; /** * Readable store that represents the validity of the form. * If any of the fields in the form have errors, this will be false. @@ -69,6 +72,7 @@ export interface FormEngine { * or even persist the form state to allow the user to resume later. */ triggerCancel: () => void; + errors: Readable; } function nonEmptyValue(s: FieldValue): Option { switch (typeof s) { @@ -144,15 +148,15 @@ type makeFormEngineArgs = { defaultValues?: Record; }; -export function makeFormEngine({ +export function makeFormEngine({ onSubmit, onCancel, defaultValues = {}, -}: makeFormEngineArgs): FormEngine { - const formStore: Writable> = writable({ fields: {}, status: "draft" }); - // Creates helper functions to modify the store immutably - function setFormField(name: string) { - // Set the initial value of the field +}: makeFormEngineArgs): FormEngine { + const formStore: Writable> = writable({ fields: {}, status: "draft" }); + /** Creates helper functions to modify the store immutably*/ + function setFormField({ name, input }: FieldDefinition) { + /** Set the initial value of the field*/ function initField(errors = [], rules?: Rule) { formStore.update((form) => { return { @@ -164,7 +168,7 @@ export function makeFormEngine({ }; }); } - function setValue(value: T) { + function setValue(value: T) { formStore.update((form) => { const field = form.fields[name]; if (!field) { @@ -183,7 +187,7 @@ export function makeFormEngine({ return { initField, setValue }; } - function setErrors(failedFields: Field[]) { + function setErrors(failedFields: Field[]) { formStore.update((form) => { return pipe( failedFields, @@ -197,9 +201,19 @@ export function makeFormEngine({ }); } + const errors = derived(formStore, ({ fields }) => + pipe( + fields, + R.toEntries, + A.filterMap(([_, f]) => (f.errors.length > 0 ? O.some(f.errors) : O.none)), + A.flatten, + ), + ); + // TODO: dependent fields, handle more than just strings return { subscribe: formStore.subscribe, + errors, isValid: derived(formStore, ({ fields }) => pipe( fields, @@ -222,11 +236,11 @@ export function makeFormEngine({ formStore.update((form) => ({ ...form, status: "cancelled" })); onCancel?.(); }, - addField: (field) => { - const { initField: setField, setValue } = setFormField(field.name); + addField(field: FieldDefinition) { + const { initField: setField, setValue } = setFormField(field); setField([], field.isRequired ? requiredRule(field.label || field.name) : undefined); const fieldStore = derived(formStore, ({ fields }) => fields[field.name]); - const fieldValueStore: Writable = { + const fieldValueStore: Writable = { subscribe(cb) { return fieldStore.subscribe((x) => pipe( @@ -250,7 +264,9 @@ export function makeFormEngine({ const newValue = pipe( // fuck prettier current.value, - O.map(updater), + input.requiresListOfStrings(field.input) + ? O.match(() => O.of(updater([])), flow(updater, O.of)) + : O.map(updater), ); return { ...form, diff --git a/src/views/FormBuilder.svelte b/src/views/FormBuilder.svelte index 7bbe638f..51ea7a84 100644 --- a/src/views/FormBuilder.svelte +++ b/src/views/FormBuilder.svelte @@ -1,24 +1,24 @@ + +
diff --git a/src/views/components/Form/InputDataview.svelte b/src/views/components/Form/InputDataview.svelte new file mode 100644 index 00000000..35fdfe71 --- /dev/null +++ b/src/views/components/Form/InputDataview.svelte @@ -0,0 +1,21 @@ + + + + + diff --git a/src/views/components/Form/InputField.svelte b/src/views/components/Form/InputField.svelte new file mode 100644 index 00000000..6d5835d8 --- /dev/null +++ b/src/views/components/Form/InputField.svelte @@ -0,0 +1,31 @@ + + +{#if inputType === "number"} + +{:else if inputType === "text"} + +{:else if inputType === "email"} + +{:else if inputType === "tel"} + +{:else if inputType === "date"} + +{:else if inputType === "time"} + +{:else if inputType === "datetime"} + +{/if} + + diff --git a/src/views/components/Form/InputFolder.svelte b/src/views/components/Form/InputFolder.svelte new file mode 100644 index 00000000..42735179 --- /dev/null +++ b/src/views/components/Form/InputFolder.svelte @@ -0,0 +1,34 @@ + + +
+ dummy to prevent the setting from being the first child +
diff --git a/src/views/components/Form/InputNote.svelte b/src/views/components/Form/InputNote.svelte new file mode 100644 index 00000000..6a4bb42d --- /dev/null +++ b/src/views/components/Form/InputNote.svelte @@ -0,0 +1,33 @@ + + + + + diff --git a/src/views/components/Form/InputTag.svelte b/src/views/components/Form/InputTag.svelte new file mode 100644 index 00000000..046d50d6 --- /dev/null +++ b/src/views/components/Form/InputTag.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/views/components/Form/InputTextArea.svelte b/src/views/components/Form/InputTextArea.svelte new file mode 100644 index 00000000..dafe88e9 --- /dev/null +++ b/src/views/components/Form/InputTextArea.svelte @@ -0,0 +1,25 @@ + + + +