diff --git a/.gitignore b/.gitignore index 58babce5..9e66c925 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ meta.json site/ docs/index.md docs/changelog.md +coverage diff --git a/docs/blog/posts/NextRelease.md b/docs/blog/posts/NextRelease.md new file mode 100644 index 00000000..a8d07676 --- /dev/null +++ b/docs/blog/posts/NextRelease.md @@ -0,0 +1,39 @@ +--- +title: Release notes for 1.4x.0 +date: 2024-06-29 +tags: release-notes +--- + +This is a very exciting release for me, because it includes one of the features that I have been wanting to implement for a long time, and also one of the most requested features. But it also includes some minor improvements, so let's take those first: + +- The placeholder of the label is set by default to the name of the field. This will make it easier for people to understand that the default label value is the name of the field. + +Now the big feature: + +## **Dependent fields** + +As with every new feature, I like to start small, so this first version is very simple. +It just settles the basic foundation and works only with the most basic field types. +This will allow me to gather feedback and improve it in the next releases after making sure that the basic functionality is working as expected. + +In this first approach there are not many safeguards either, so you can end up in forms that don't render anything, for example because of with fields that are excluding each other. I don't think this is going to be a big problem in practice, but I will be monitoring the feedback to see if it is necessary to add some kind of validation, or at least some kind of warning. +The reason I am not adding it any limitations in this first version is because flexibility: forms can be called with parameters to omit fields, default values, etc. and I don't want to limit that flexibility. + +Here are some screenshots of the feature in action. + +Form builder: + +![boolean comparison]() +![string comparison]() + +Form in preview mode with the condition met +![condition met]() +with the condition not met +![condition not met]() + +This first iteration is purely visual: just because a field is hidden it does not mean that, if it has a value, is not going to be included in the result. If you fill a field, and then do something that makes it hidden, the value will still be included in the result. I think in practice most people just needs a way to start with several fields hidden, and then show them based on the value of other fields, so I think this is a good first approach. + +The wording of the feature is not final, I'm not very satisfied with the current wording, so I'm open to suggestions. +I hope you like it, that it does not introduce too many inconveniences and that it is useful to you. + +Please let me know your thoughts and suggestions. diff --git a/docs/blog/posts/condition-met.png b/docs/blog/posts/condition-met.png new file mode 100644 index 00000000..e3a71575 Binary files /dev/null and b/docs/blog/posts/condition-met.png differ diff --git a/docs/blog/posts/condition-not-met.png b/docs/blog/posts/condition-not-met.png new file mode 100644 index 00000000..5977f49b Binary files /dev/null and b/docs/blog/posts/condition-not-met.png differ diff --git a/docs/blog/posts/conditional-boolean.png b/docs/blog/posts/conditional-boolean.png new file mode 100644 index 00000000..3c4f6f06 Binary files /dev/null and b/docs/blog/posts/conditional-boolean.png differ diff --git a/docs/blog/posts/conditional-string.png b/docs/blog/posts/conditional-string.png new file mode 100644 index 00000000..f4ee6639 Binary files /dev/null and b/docs/blog/posts/conditional-string.png differ diff --git a/jest.config.ts b/jest.config.ts index 1385b771..dcfee5ea 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,21 +1,22 @@ -import { type JestConfigWithTsJest } from 'ts-jest' +import { type JestConfigWithTsJest } from "ts-jest"; const jestConfig: JestConfigWithTsJest = { // preset: 'ts-jest/presets/default-esm', // or other ESM presets - preset: 'ts-jest', // or other ESM presets - moduleDirectories: ['node_modules', ''], - roots: ['src'], + preset: "ts-jest", // or other ESM presets + moduleDirectories: ["node_modules", ""], + roots: ["src"], moduleNameMapper: { - '^@std$': '/src/std/$1', + "^@std$": "/src/std/$1", + "^@core$": "/src/core/$1", }, - transformIgnorePatterns: ['node_modules/(?!(svelte)/)'], + transformIgnorePatterns: ["node_modules/(?!(svelte)/)"], transform: { - '^.+\\.(t|j)sx?$': [ - 'ts-jest', + "^.+\\.(t|j)sx?$": [ + "ts-jest", { useESM: true, }, ], }, -} -export default jestConfig +}; +export default jestConfig; diff --git a/src/FormModal.svelte b/src/FormModal.svelte index 2baf57f7..3703fe1e 100644 --- a/src/FormModal.svelte +++ b/src/FormModal.svelte @@ -2,18 +2,7 @@ import { App } from "obsidian"; import { FormDefinition } from "src/core/formDefinition"; import { makeFormEngine } from "src/store/formStore"; - import InputField from "src/views/components/Form/InputField.svelte"; - import ObsidianInputWrapper from "src/views/components/Form/ObsidianInputWrapper.svelte"; - import DocumentBlock from "./views/components/Form/DocumentBlock.svelte"; - import InputDataview from "./views/components/Form/InputDataview.svelte"; - import InputFolder from "./views/components/Form/InputFolder.svelte"; - import InputNote from "./views/components/Form/InputNote.svelte"; - import InputTag from "./views/components/Form/InputTag.svelte"; - import InputTextArea from "./views/components/Form/InputTextArea.svelte"; - import MultiSelectField from "./views/components/Form/MultiSelectField.svelte"; - import ObsidianSelect from "./views/components/Form/ObsidianSelect.svelte"; - import ObsidianToggle from "./views/components/Form/ObsidianToggle.svelte"; - import InputSlider from "./views/components/Form/inputSlider.svelte"; + import RenderField from "./views/components/Form/RenderField.svelte"; export let app: App; export let reportFormErrors: (errors: string[]) => void; export let formEngine: ReturnType; @@ -23,43 +12,5 @@ {#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/core/findInputDefinitionSchema.ts b/src/core/findInputDefinitionSchema.ts index db8ccb68..4a114703 100644 --- a/src/core/findInputDefinitionSchema.ts +++ b/src/core/findInputDefinitionSchema.ts @@ -1,9 +1,9 @@ import { A, NonEmptyArray, ParsingFn, parse, pipe } from "@std"; import * as E from "fp-ts/Either"; -import { ValiError, BaseSchema } from "valibot"; -import { FieldMinimal, FieldMinimalSchema } from "./formDefinitionSchema"; +import { BaseSchema, ValiError } from "valibot"; import { AllFieldTypes } from "./formDefinition"; -import { InputTypeToParserMap } from "./InputDefinitionSchema"; +import { FieldMinimal, FieldMinimalSchema } from "./formDefinitionSchema"; +import { InputTypeToParserMap } from "./input/InputDefinitionSchema"; export function stringifyIssues(error: ValiError): NonEmptyArray { return error.issues.map( diff --git a/src/core/formDefinition.test.ts b/src/core/formDefinition.test.ts index 4351b33c..618820c8 100644 --- a/src/core/formDefinition.test.ts +++ b/src/core/formDefinition.test.ts @@ -1,4 +1,4 @@ -import { MultiselectSchema } from "./InputDefinitionSchema"; +import { parse } from "valibot"; import { isDataViewSource, isInputNoteFromFolder, @@ -6,7 +6,7 @@ import { isInputSlider, isSelectFromNotes, } from "./formDefinition"; -import { parse } from "valibot"; +import { MultiselectSchema } from "./input/InputDefinitionSchema"; describe("isDataViewSource", () => { it("should return true for valid inputDataviewSource objects", () => { diff --git a/src/core/formDefinition.ts b/src/core/formDefinition.ts index 22723f52..e2cd521d 100644 --- a/src/core/formDefinition.ts +++ b/src/core/formDefinition.ts @@ -1,13 +1,14 @@ -import { type Output, is, safeParse } from "valibot"; +import { input } from "@core"; +import { A, O, pipe } from "@std"; +import { Simplify } from "type-fest"; +import { is, safeParse, type Output } from "valibot"; import { FieldDefinitionSchema, - FormDefinitionLatestSchema, FieldListSchema, FormDefinitionBasicSchema, + FormDefinitionLatestSchema, MigrationError, } from "./formDefinitionSchema"; -import { A, O, pipe } from "@std"; -import { Simplify } from "type-fest"; import { InputBasicSchema, InputDataviewSourceSchema, @@ -25,7 +26,7 @@ import { inputType, multiselect, selectFromNotes, -} from "./InputDefinitionSchema"; +} from "./input/InputDefinitionSchema"; export const InputTypeReadable: Record = { text: "Text", @@ -104,17 +105,20 @@ export type EditableInput = { allowUnknownValues?: boolean; }; +export type EditableField = { + name: string; + label?: string; + description: string; + input: EditableInput; + folder?: string; + options?: { value: string; label: string }[]; + condition?: input.Condition; +}; + export type EditableFormDefinition = FormDefinition & { title: string; name: string; - fields: { - name: string; - label?: string; - description: string; - input: EditableInput; - folder?: string; - options?: { value: string; label: string }[]; - }[]; + fields: EditableField[]; }; export function isValidBasicInput(input: unknown): input is basicInput { diff --git a/src/core/formDefinitionSchema.ts b/src/core/formDefinitionSchema.ts index 72c1a542..147616dd 100644 --- a/src/core/formDefinitionSchema.ts +++ b/src/core/formDefinitionSchema.ts @@ -1,23 +1,24 @@ +import { input } from "@core"; +import { parse, pipe } from "@std"; import * as E from "fp-ts/Either"; -import { pipe, parse } from "@std"; import { - object, - literal, - type Output, - is, + ValiError, array, - string, - optional, + boolean, + is, + literal, merge, - unknown, - ValiError, + object, + optional, passthrough, - boolean, + string, + unknown, + type Output, } from "valibot"; -import { FormDefinition } from "./formDefinition"; import { findFieldErrors, stringifyIssues } from "./findInputDefinitionSchema"; +import { FormDefinition } from "./formDefinition"; +import { InputTypeSchema, nonEmptyString } from "./input/InputDefinitionSchema"; import { ParsedTemplateSchema } from "./template/templateSchema"; -import { InputTypeSchema, nonEmptyString } from "./InputDefinitionSchema"; /** * Here are the core logic around the main domain of the plugin, @@ -30,6 +31,7 @@ export const FieldDefinitionSchema = object({ label: optional(string()), description: string(), isRequired: optional(boolean()), + condition: optional(input.ConditionSchema), input: InputTypeSchema, }); /** diff --git a/src/core/index.ts b/src/core/index.ts index 18bae0a7..17c74051 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1 +1 @@ -export * as input from "./InputDefinitionSchema"; +export * as input from "./input"; diff --git a/src/core/InputDefinitionSchema.ts b/src/core/input/InputDefinitionSchema.ts similarity index 98% rename from src/core/InputDefinitionSchema.ts rename to src/core/input/InputDefinitionSchema.ts index 841e26ad..fe80b070 100644 --- a/src/core/InputDefinitionSchema.ts +++ b/src/core/input/InputDefinitionSchema.ts @@ -16,7 +16,7 @@ import { toTrimmed, union, } from "valibot"; -import { AllFieldTypes, AllSources } from "./formDefinition"; +import { AllFieldTypes, AllSources } from "../formDefinition"; /** * Here are the definition for the input types. @@ -142,6 +142,8 @@ export const InputTypeSchema = union([ DocumentBlock, ]); +export type Input = Output; + export const InputTypeToParserMap: Record> = { number: parseC(InputBasicSchema), text: parseC(InputBasicSchema), diff --git a/src/core/input/dependentFields.test.ts b/src/core/input/dependentFields.test.ts new file mode 100644 index 00000000..b18675a2 --- /dev/null +++ b/src/core/input/dependentFields.test.ts @@ -0,0 +1,124 @@ +import { input } from "@core"; +import * as v from "valibot"; +import { FieldDefinition } from "../formDefinition"; +import * as deps from "./dependentFields"; +import { ConditionSchema } from "./dependentFields"; + +describe("dependentFields", () => { + it("should return a list of conditions available for the input type: 'text'", () => { + const field: FieldDefinition["input"] = { + type: "text", + }; + const types = input.availableConditionsForInput(field); + types.forEach((type) => { + const value = { type, dependencyName: "test-field", value: "test" }; + const parsed = v.safeParse(ConditionSchema, value); + expect(parsed.success).toBe(true); + }); + expect.assertions(types.length); + }); + it("should return true if the value is set", () => { + const condition: input.Condition = { + type: "isSet", + dependencyName: "test-field", + }; + expect(deps.valueMeetsCondition(condition, "test")).toBe(true); + expect(deps.valueMeetsCondition(condition, 0)).toBe(true); + expect(deps.valueMeetsCondition(condition, true)).toBe(true); + expect(deps.valueMeetsCondition(condition, false)).toBe(true); + }); + it("should return false if the value is not set", () => { + const condition: input.Condition = { + type: "isSet", + dependencyName: "test-field", + }; + expect(deps.valueMeetsCondition(condition, "")).toBe(false); + }); + it("should handle the boolean condition", () => { + const condition: input.Condition = { + type: "boolean", + value: true, + dependencyName: "test-field", + }; + expect(deps.valueMeetsCondition(condition, true)).toBe(true); + expect(deps.valueMeetsCondition(condition, false)).toBe(false); + }); + it('should handle the isSet conditions for "null" and "undefined"', () => { + const condition: input.Condition = { + type: "isSet", + dependencyName: "test-field", + }; + expect(deps.valueMeetsCondition(condition, null)).toBe(false); + expect(deps.valueMeetsCondition(condition, undefined)).toBe(false); + expect(deps.valueMeetsCondition(condition, "")).toBe(false); + }); + it("should properly handle all string conditions that are true", () => { + const conditions: [input.Condition, string][] = [ + [{ type: "startsWith", dependencyName: "test-field", value: "test" }, "test starts"], + [{ type: "endsWith", dependencyName: "test-field", value: "test" }, "ends with test"], + [{ type: "isExactly", dependencyName: "test-field", value: "test" }, "test"], + [ + { type: "contains", dependencyName: "test-field", value: "test" }, + "contains test somewhere", + ], + ]; + conditions.forEach(([condition, value]) => { + expect(deps.valueMeetsCondition(condition, value)).toBe(true); + }); + expect.assertions(conditions.length); + }); + it("should properly handle all string conditions that are false", () => { + const conditions: [input.Condition, unknown][] = [ + [{ type: "startsWith", dependencyName: "test-field", value: "test" }, "not test"], + [{ type: "startsWith", dependencyName: "test-field", value: "test" }, null], + [ + { type: "endsWith", dependencyName: "test-field", value: "test" }, + "not test at the end", + ], + [ + { type: "isExactly", dependencyName: "test-field", value: "test" }, + "not exactly test", + ], + [ + { type: "contains", dependencyName: "test-field", value: "test" }, + "does not contain tst", + ], + ]; + conditions.forEach(([condition, value]) => { + expect(deps.valueMeetsCondition(condition, value)).toBe(false); + }); + expect.assertions(conditions.length); + }); + it("should properly handle all number conditions that are true", () => { + const conditions: [input.Condition, unknown][] = [ + [{ type: "above", dependencyName: "test", value: 5 }, 6], + [{ type: "aboveOrEqual", dependencyName: "test", value: 5 }, 5], + [{ type: "aboveOrEqual", dependencyName: "test", value: 5 }, 8], + [{ type: "below", dependencyName: "test", value: 5 }, 4], + [{ type: "below", dependencyName: "test", value: 5 }, -4], + [{ type: "belowOrEqual", dependencyName: "test", value: 5 }, 5], + [{ type: "belowOrEqual", dependencyName: "test", value: 5 }, 2], + [{ type: "exactly", dependencyName: "test", value: 5 }, 5], + ]; + conditions.forEach(([condition, value]) => { + expect(deps.valueMeetsCondition(condition, value)).toBe(true); + }); + expect.assertions(conditions.length); + }); + it("should properly handle all number conditions that are false", () => { + const conditions: [input.Condition, unknown][] = [ + [{ type: "above", dependencyName: "test", value: 5 }, 4], + [{ type: "aboveOrEqual", dependencyName: "test", value: 5 }, 4], + [{ type: "below", dependencyName: "test", value: 5 }, 6], + [{ type: "belowOrEqual", dependencyName: "test", value: 5 }, 6], + [{ type: "exactly", dependencyName: "test", value: 5 }, 4], + [{ type: "exactly", dependencyName: "test", value: 5 }, null], + [{ type: "exactly", dependencyName: "test", value: 5 }, undefined], + [{ type: "exactly", dependencyName: "test", value: 5 }, {}], + ]; + conditions.forEach(([condition, value]) => { + expect(deps.valueMeetsCondition(condition, value)).toBe(false); + }); + expect.assertions(conditions.length); + }); +}); diff --git a/src/core/input/dependentFields.ts b/src/core/input/dependentFields.ts new file mode 100644 index 00000000..62bc6156 --- /dev/null +++ b/src/core/input/dependentFields.ts @@ -0,0 +1,131 @@ +import { absurd } from "fp-ts/function"; +import * as v from "valibot"; +import { FieldDefinition } from "../formDefinition"; +const isSet = v.object({ dependencyName: v.string(), type: v.literal("isSet") }); +const booleanValue = v.object({ + dependencyName: v.string(), + type: v.literal("boolean"), + value: v.boolean(), +}); +const startsWith = v.object({ + dependencyName: v.string(), + type: v.enumType(["startsWith", "endsWith", "isExactly", "contains"]), + value: v.string(), +}); +const above = v.object({ + dependencyName: v.string(), + type: v.enumType(["above", "aboveOrEqual", "below", "belowOrEqual", "exactly"]), + value: v.number(), +}); +export const ConditionSchema = v.union([isSet, booleanValue, startsWith, above]); + +export type Condition = v.Output; +export type ConditionType = Condition["type"]; + +export function availableConditionsForInput(input: FieldDefinition["input"]): ConditionType[] { + switch (input.type) { + case "text": + case "textarea": + case "email": + case "folder": + case "note": + case "tel": + return ["startsWith", "endsWith", "isExactly", "contains"]; + case "slider": + case "number": + return ["above", "aboveOrEqual", "below", "belowOrEqual", "exactly"]; + case "toggle": + return ["boolean"]; + case "date": + case "time": + case "datetime": + return ["isSet"]; + case "select": + case "multiselect": + case "tag": + case "dataview": + case "document_block": + return []; + default: + return absurd(input); + } +} + +function processIsSet(_condition: Extract, value: unknown) { + if (value === null || value === undefined) { + return false; + } + if (typeof value === "string") { + return value !== ""; + } + return true; +} + +function processStringCondition( + condition: Extract, + value: unknown, +): boolean { + if (typeof value !== "string") { + return false; + } + switch (condition.type) { + case "startsWith": + return value.startsWith(condition.value); + case "contains": + return value.includes(condition.value); + case "endsWith": + return value.endsWith(condition.value); + case "isExactly": + return value === condition.value; + default: + return absurd(condition.type); + } +} + +function processNumberCondition( + condition: Extract< + Condition, + { type: "above" | "below" | "aboveOrEqual" | "belowOrEqual" | "exactly" } + >, + value: unknown, +): boolean { + if (typeof value !== "number") { + return false; + } + switch (condition.type) { + case "above": + return value > condition.value; + case "below": + return value < condition.value; + case "aboveOrEqual": + return value >= condition.value; + case "belowOrEqual": + return value <= condition.value; + case "exactly": + return value === condition.value; + default: + return absurd(condition.type); + } +} + +export function valueMeetsCondition(condition: Condition, value: unknown): boolean { + switch (condition.type) { + case "isSet": + return processIsSet(condition, value); + case "startsWith": + case "contains": + case "endsWith": + case "isExactly": + return processStringCondition(condition, value); + case "above": + case "below": + case "aboveOrEqual": + case "belowOrEqual": + case "exactly": + return processNumberCondition(condition, value); + case "boolean": + return value === condition.value; + default: + return absurd(condition); + } +} diff --git a/src/core/input/index.ts b/src/core/input/index.ts new file mode 100644 index 00000000..1bac8008 --- /dev/null +++ b/src/core/input/index.ts @@ -0,0 +1,2 @@ +export * from "./InputDefinitionSchema"; +export * from "./dependentFields"; diff --git a/src/store/formStore.ts b/src/store/formStore.ts index a44c790d..9229a8ee 100644 --- a/src/store/formStore.ts +++ b/src/store/formStore.ts @@ -2,6 +2,7 @@ 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 { input } from "@core"; import { NonEmptyArray, flow, pipe } from "@std"; import * as A from "fp-ts/Array"; import * as E from "fp-ts/Either"; @@ -9,8 +10,8 @@ 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 { valueMeetsCondition } from "src/core/input"; 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 }; @@ -51,6 +52,7 @@ export interface FormEngine { addField(field: { name: string; label?: string; isRequired?: boolean }): { value: Writable; errors: Readable; + isVisible: Readable>; }; /** * Subscribes to the form store. This method is required to conform to the svelte store interface. @@ -156,7 +158,10 @@ export function makeFormEngine({ 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*/ + /** + * Initializes a field in the form store with the provided errors, rules + * and default values (read from the defaultValues object passed to the form engine) + */ function initField(errors = [], rules?: Rule) { formStore.update((form) => { return { @@ -282,8 +287,26 @@ export function makeFormEngine({ }); }, }; + const isVisible = derived(formStore, ($form): E.Either => { + console.log( + "condition", + field.name, + field.condition && $form.fields[field.condition.dependencyName], + ); + if (field.isRequired) return E.of(true); + const condition = field.condition; + if (condition === undefined) return E.of(true); + return pipe( + $form.fields[condition.dependencyName], + E.fromNullable( + `Field '${condition.dependencyName}' which is a dependency of '${field.name}' does not exist`, + ), + E.map((f) => valueMeetsCondition(condition, O.toUndefined(f.value))), + ); + }); return { value: fieldValueStore, + isVisible, errors: derived(formStore, ({ fields }) => fields[field.name]?.errors ?? []), }; }, diff --git a/src/views/FormBuilder.svelte b/src/views/FormBuilder.svelte index 51ea7a84..5f690e97 100644 --- a/src/views/FormBuilder.svelte +++ b/src/views/FormBuilder.svelte @@ -1,4 +1,5 @@