diff --git a/src/FormModal.ts b/src/FormModal.ts index 80e22f06..c62873c5 100644 --- a/src/FormModal.ts +++ b/src/FormModal.ts @@ -292,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), ); @@ -303,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 84943702..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({ onSubmit: 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({ onSubmit: 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({ onSubmit: 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({ onSubmit: 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({ onSubmit: 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({ onSubmit: 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 9746b9d6..a573d6ea 100644 --- a/src/store/formStore.ts +++ b/src/store/formStore.ts @@ -30,7 +30,10 @@ type 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 @@ -60,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) { @@ -128,17 +137,19 @@ function parseForm( } export type OnFormSubmit = (values: Record) => void; + type makeFormEngineArgs = { onSubmit: OnFormSubmit; - onCancel?: () => void; + onCancel: () => void; defaultValues?: Record; }; export function makeFormEngine({ onSubmit, + onCancel, defaultValues = {}, }: makeFormEngineArgs): FormEngine { - const formStore: Writable> = writable({ fields: {} }); + 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 @@ -198,6 +209,7 @@ export function makeFormEngine({ ), ), triggerSubmit() { + formStore.update((form) => ({ ...form, status: "submitted" })); const formState = get(formStore); // prettier-ignore pipe( @@ -206,6 +218,10 @@ export function makeFormEngine({ 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);