diff --git a/src/FormModal.ts b/src/FormModal.ts index ef6c3b57..c62873c5 100644 --- a/src/FormModal.ts +++ b/src/FormModal.ts @@ -41,10 +41,17 @@ export class FormModal extends Modal { modalDefinition.fields, options?.values ?? {}, ); - this.formEngine = makeFormEngine((result) => { - this.onSubmit(FormResult.make(result, "ok")); - this.close(); - }, this.initialFormValues); + this.formEngine = makeFormEngine({ + onSubmit: (result) => { + this.onSubmit(FormResult.make(result, "ok")); + this.close(); + }, + onCancel: () => { + this.onSubmit(FormResult.make({}, "cancelled")); + this.close(); + }, + defaultValues: this.initialFormValues, + }); // this.formEngine.subscribe(console.log); } @@ -285,7 +292,11 @@ export class FormModal extends Modal { } }); - new Setting(contentEl).addButton((btn) => + const buttons = new Setting(contentEl).addButton((btn) => + btn.setButtonText("Cancel").onClick(this.formEngine.triggerCancel), + ); + + buttons.addButton((btn) => btn.setButtonText("Submit").setCta().onClick(this.formEngine.triggerSubmit), ); @@ -296,7 +307,16 @@ export class FormModal extends Modal { } }; + const cancelEscapeCallback = (evt: KeyboardEvent) => { + // We don't want to hande it if any modfier is pressed + if (!(evt.ctrlKey || evt.metaKey) && evt.key === "Escape") { + evt.preventDefault(); + this.formEngine.triggerCancel(); + } + }; + contentEl.addEventListener("keydown", submitEnterCallback); + contentEl.addEventListener("keydown", cancelEscapeCallback); } onClose() { diff --git a/src/core/FormResult.test.ts b/src/core/FormResult.test.ts index 9b2c47bb..ab25fdef 100644 --- a/src/core/FormResult.test.ts +++ b/src/core/FormResult.test.ts @@ -56,8 +56,7 @@ isEmployed:: true`; it("should return the data formatted as a string matching the provided template", () => { const result = FormResult.make(formData, "ok"); const template = "My name is {{name}}, and I am {{age}} years old."; - const expectedOutput = - "My name is John Doe, and I am 30 years old."; + const expectedOutput = "My name is John Doe, and I am 30 years old."; expect(result.asString(template)).toEqual(expectedOutput); }); }); @@ -66,9 +65,7 @@ isEmployed:: true`; const result = FormResult.make(formData, "ok"); const expectedOutput = `name:: John Doe age:: 30`; - expect( - result.asDataviewProperties({ pick: ["name", "age"] }), - ).toEqual(expectedOutput); + expect(result.asDataviewProperties({ pick: ["name", "age"] })).toEqual(expectedOutput); }); it("should return the data as a string of dataview properties with all keys except the specified ones using options.omit", () => { @@ -111,20 +108,18 @@ age:: 30`; const result = FormResult.make(formData, "ok"); const expectedOutput = `name: John Doe age: 30`; - expect( - result.asFrontmatterString({ pick: ["name", "age"] }).trim(), - ).toEqual(expectedOutput); + expect(result.asFrontmatterString({ pick: ["name", "age"] }).trim()).toEqual( + expectedOutput, + ); }); it("should return the data as a YAML frontmatter string with all keys except the specified ones using options.omit", () => { const result = FormResult.make(formData, "ok"); const expectedOutput = `name: John Doe age: 30`; - expect( - result - .asFrontmatterString({ omit: ["hobbies", "isEmployed"] }) - .trim(), - ).toEqual(expectedOutput); + expect(result.asFrontmatterString({ omit: ["hobbies", "isEmployed"] }).trim()).toEqual( + expectedOutput, + ); }); it("should return the data as a YAML frontmatter string with only the specified keys using options.pick and ignoring options.omit", () => { @@ -178,30 +173,26 @@ age: 30`; expect(result.get("foo")).toEqual(""); }); }); - describe('Shorthand proxied accessors', () => { - it('Should allow access to a value in the data directly using dot notation', - () => { - const result = FormResult.make(formData, "ok"); - // @ts-ignore - expect(result.name.toString()).toEqual("John Doe"); - }) - it('Should allow access to a value in the data directly and allow to use shorthand methods on the returned value', - () => { - const result = FormResult.make(formData, "ok"); - // @ts-ignore - expect(result.name.upper.toString()).toEqual("JOHN DOE"); - } - ) - it('proxied access to bullet list should return a bullet list', () => { + describe("Shorthand proxied accessors", () => { + it("Should allow access to a value in the data directly using dot notation", () => { + const result = FormResult.make(formData, "ok"); + // @ts-ignore + expect(result.name.toString()).toEqual("John Doe"); + }); + it("Should allow access to a value in the data directly and allow to use shorthand methods on the returned value", () => { + const result = FormResult.make(formData, "ok"); + // @ts-ignore + expect(result.name.upper.toString()).toEqual("JOHN DOE"); + }); + it("proxied access to bullet list should return a bullet list", () => { const result = FormResult.make(formData, "ok"); // @ts-ignore expect(result.hobbies.bullets).toEqual("- reading\n- swimming"); - }) - it('accessing a non existing key should return a safe ResultValue, letting chain without issues', () => { + }); + it("accessing a non existing key should return a safe ResultValue, letting chain without issues", () => { const result = FormResult.make(formData, "ok"); // @ts-ignore expect(result.foo.upper.lower.toString()).toEqual(""); - }) - }) - + }); + }); }); diff --git a/src/store/formStore.test.ts b/src/store/formStore.test.ts index 5be38ebe..304f535c 100644 --- a/src/store/formStore.test.ts +++ b/src/store/formStore.test.ts @@ -4,7 +4,7 @@ import { makeFormEngine } from "./formStore"; describe("Form Engine", () => { it("should update form fields correctly", () => { const onSubmitMock = jest.fn(); - const formEngine = makeFormEngine(onSubmitMock); + const formEngine = makeFormEngine({ onSubmit: onSubmitMock, onCancel: console.log }); // Add fields to the form const field1 = formEngine.addField({ name: "fieldName1" }); @@ -24,7 +24,7 @@ describe("Form Engine", () => { it("should handle field errors correctly", () => { const onSubmitMock = jest.fn(); - const formEngine = makeFormEngine(onSubmitMock); + const formEngine = makeFormEngine({ onSubmit: onSubmitMock, onCancel: console.log }); // Add a field to the form const field1 = formEngine.addField({ name: "fieldName1", @@ -43,7 +43,7 @@ describe("Form Engine", () => { }); it("field errors should prefer field label over field name", () => { const onSubmitMock = jest.fn(); - const formEngine = makeFormEngine(onSubmitMock); + const formEngine = makeFormEngine({ onSubmit: onSubmitMock, onCancel: console.log }); // Add a field to the form const field1 = formEngine.addField({ name: "fieldName1", @@ -63,7 +63,7 @@ describe("Form Engine", () => { }); it("Clears the errors when a value is set", () => { const onSubmitMock = jest.fn(); - const formEngine = makeFormEngine(onSubmitMock); + const formEngine = makeFormEngine({ onSubmit: onSubmitMock, onCancel: console.log }); // Add a field to the form const field1 = formEngine.addField({ name: "fieldName1", @@ -92,7 +92,11 @@ describe("Form Engine", () => { fieldName1: "default1", fieldName2: "default2", }; - const formEngine = makeFormEngine(onSubmitMock, defaultValues); + const formEngine = makeFormEngine({ + onSubmit: onSubmitMock, + defaultValues, + onCancel: console.log, + }); // Add fields to the form const field1 = formEngine.addField({ name: "fieldName1" }); @@ -114,7 +118,11 @@ describe("Form Engine", () => { const defaultValues = { fieldName1: "default1", }; - const formEngine = makeFormEngine(onSubmitMock, defaultValues); + const formEngine = makeFormEngine({ + onSubmit: onSubmitMock, + defaultValues, + onCancel: console.log, + }); // Add fields to the form const field1 = formEngine.addField({ name: "fieldName1" }); @@ -128,4 +136,18 @@ describe("Form Engine", () => { fieldName1: "default1", }); }); + it("should flag the form as cancelled and call the onCancel callback when the cancel button is clicked", () => { + const onCancelMock = jest.fn(); + const formEngine = makeFormEngine({ onSubmit: console.log, onCancel: onCancelMock }); + // Add a field to the form + const field1 = formEngine.addField({ name: "fieldName1" }); + // Update field value with an empty string + field1.value.set(""); + // Trigger form submission + formEngine.triggerCancel(); + // Assert that the onCancel callback is called + expect(onCancelMock).toHaveBeenCalled(); + // Assert that the form is not valid + // expect(get(formEngine.isValid)).toBe(false); // is it worth checking this? + }); }); diff --git a/src/store/formStore.ts b/src/store/formStore.ts index fa6cea93..a573d6ea 100644 --- a/src/store/formStore.ts +++ b/src/store/formStore.ts @@ -2,13 +2,13 @@ // 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 * as A from "fp-ts/Array"; import * as E from "fp-ts/Either"; -import { absurd } from "fp-ts/function"; import * as O from "fp-ts/Option"; -import * as A from "fp-ts/Array"; -import { Writable, derived, writable, Readable, get } from "svelte/store"; -import { fromEntries, toEntries } from "fp-ts/Record"; import { Option } from "fp-ts/Option"; +import { fromEntries, toEntries } from "fp-ts/Record"; +import { absurd } from "fp-ts/function"; +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 }; function requiredRule(fieldName: string, message?: string): Rule { @@ -27,13 +27,13 @@ type FieldFailed = { rules: Rule; errors: NonEmptyArray; }; -function FieldFailed( - field: Field, - failedRule: Rule, -): FieldFailed { +function FieldFailed(field: Field, failedRule: Rule): FieldFailed { return { ...field, rules: failedRule, errors: [failedRule.message] }; } -type FormStore = { fields: Record> }; +type FormStore = { + fields: Record>; + status: "submitted" | "cancelled" | "draft"; +}; // 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 @@ -45,11 +45,10 @@ 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; errors: Readable }; + 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. */ @@ -64,6 +63,12 @@ export interface FormEngine { * If the form is invalid, the errors are updated and the onSubmit function is not called. */ triggerSubmit: () => void; + /** + * Cancels the form and calls the onCancel function. + * In the future we may add a confirmation dialog before calling the onCancel function. + * or even persist the form state to allow the user to resume later. + */ + triggerCancel: () => void; } function nonEmptyValue(s: FieldValue): Option { switch (typeof s) { @@ -73,11 +78,7 @@ function nonEmptyValue(s: FieldValue): Option { case "boolean": return O.some(s); case "object": - return Array.isArray(s) - ? s.length > 0 - ? O.some(s) - : O.none - : O.none; + return Array.isArray(s) ? (s.length > 0 ? O.some(s) : O.none) : O.none; default: return absurd(s); } @@ -88,9 +89,7 @@ function nonEmptyValue(s: FieldValue): Option { * If the field meets the requirements, the field is returned as is in a right. * If the field does not meet the requirements, the field is returned with the errors in a left. */ -function parseField( - field: Field, -): E.Either, Field> { +function parseField(field: Field): E.Either, Field> { if (!field.rules) return E.right(field); const rule = field.rules; switch (rule.tag) { @@ -100,8 +99,7 @@ function parseField( O.chain(nonEmptyValue), O.match( () => E.left(FieldFailed(field, rule)), - (value) => - E.right(field) + (value) => E.right(field), ), ); default: @@ -140,11 +138,18 @@ function parseForm( export type OnFormSubmit = (values: Record) => void; -export function makeFormEngine( - onSubmit: OnFormSubmit, - defaultValues: Record = {}, -): FormEngine { - const formStore: Writable> = writable({ fields: {} }); +type makeFormEngineArgs = { + onSubmit: OnFormSubmit; + onCancel: () => void; + defaultValues?: Record; +}; + +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 @@ -204,24 +209,23 @@ export function makeFormEngine( ), ), triggerSubmit() { + formStore.update((form) => ({ ...form, status: "submitted" })); const formState = get(formStore); + // prettier-ignore pipe( - formState.fields, - parseForm, - E.match(setErrors, onSubmit)); + formState.fields, + parseForm, + E.match(setErrors, onSubmit) + ); + }, + triggerCancel() { + formStore.update((form) => ({ ...form, status: "cancelled" })); + onCancel?.(); }, addField: (field) => { const { initField: setField, setValue } = setFormField(field.name); - setField( - [], - field.isRequired - ? requiredRule(field.label || field.name) - : undefined, - ); - const fieldStore = derived( - formStore, - ({ fields }) => fields[field.name], - ); + setField([], field.isRequired ? requiredRule(field.label || field.name) : undefined); + const fieldStore = derived(formStore, ({ fields }) => fields[field.name]); const fieldValueStore: Writable = { subscribe(cb) { return fieldStore.subscribe((x) => @@ -240,14 +244,14 @@ export function makeFormEngine( formStore.update((form) => { const current = form.fields[field.name]; if (!current) { - console.error( - new Error(`Field ${field.name} does not exist`), - ); + console.error(new Error(`Field ${field.name} does not exist`)); return form; } const newValue = pipe( + // fuck prettier current.value, - O.map(updater)); + O.map(updater), + ); return { ...form, fields: { @@ -264,10 +268,7 @@ export function makeFormEngine( }; return { value: fieldValueStore, - errors: derived( - formStore, - ({ fields }) => fields[field.name]?.errors ?? [], - ), + errors: derived(formStore, ({ fields }) => fields[field.name]?.errors ?? []), }; }, };