From ecb56e9300fb406e651d801ff5ee7e16eda0b667 Mon Sep 17 00:00:00 2001 From: Micah Engle-Eshleman Date: Sat, 12 Oct 2024 23:47:33 -0700 Subject: [PATCH] Make useCondition() more type-safe - v0.1.2 (#6) Fixes https://github.com/micahjon/rhf-conditional-logic/issues/4 Make `useCondition()` more type-safe by only allowing field paths that are associated with keys of the conditional logic config instead of every possible field path associated with the form schema. If a user specifies an invalid field path (not associated with a condition), we'll still return `true` (in the returned array) but will also log a warning in the console. --- CHANGELOG.md | 7 ++ README.md | 11 ++- package-lock.json | 4 +- package.json | 2 +- src/index.ts | 23 +++--- src/types.ts | 13 ++++ src/utils/conditional-logic.ts | 62 +++++++++------- src/utils/field-name-paths.ts | 17 +++-- tests-e2e/mock-form/schema.ts | 6 +- tests-unit/get-conditional-logic.spec.ts | 90 ++++++++++++++++++++++++ 10 files changed, 177 insertions(+), 58 deletions(-) create mode 100644 tests-unit/get-conditional-logic.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bfefd1..8169d59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog / Releases +## [0.1.2] - 2024-10-12 + +- Improve type-safety of `useCondition(paths, conditions, getValues)` hook by narrowing `paths` to only field paths associated with `conditions`, not any field path associated with the form. + This improves editor autocompletion and type safety. +- If a path is ever provided to `useCondition()` that is is not able to find a condition for, log a warning in the console. + This should always be accompanied by a type error (above), but is a good safeguard so that `useCondition()` never fails silently. + ## [0.1.1] - 2024-07-14 _No changes in functionality._ diff --git a/README.md b/README.md index ce9f401..32bb0ad 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,10 @@ A tiny library that makes it easy to define conditional logic in one place, expo const conditions = { // Show "Other Caterer" field if "Other" option is selected otherCaterer: getValues => getValues('caterer') === 'Other', - }; + } satisfies FieldConditions; ``` - Use `useConditionalForm()` (a drop-in replacement for `useForm()`) to prune hidden field values before validation: - ```ts const { register } = useConditionalForm({ @@ -46,14 +45,14 @@ A tiny library that makes it easy to define conditional logic in one place, expo const conditions = { // Show wine pairing options for each guest over 21 ['guests.#.wine']: getValues => getValues('guests.#.age') >= 21, - } + }; ``` ## Getting Started ```bash npm i rhf-conditional-logic -```` +``` Totally up to you, but I find it cleaner to stick schemas in one file and components in another, e.g. @@ -78,13 +77,13 @@ export type FormSchema = z.infer; // All conditional logic goes in a single declarative object // { path.to.field: (getValues) => boolean } -export const conditions: FieldConditions = { +export const conditions = { // Show "Other Caterer" if "Other" option is selected otherCaterer: getValues => getValues('caterer') === 'Other', // Show "Wine" options for guests over 21 // Note: "#" wildcard stands-in for "current" array index ['guests.#.wine']: getValues => getValues('guests.#.age') >= 21, -}; +} satisfies FieldConditions; ``` ```tsx diff --git a/package-lock.json b/package-lock.json index 7a25989..b9d3d5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rhf-conditional-logic", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rhf-conditional-logic", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "ts-extras": "^0.11.0" }, diff --git a/package.json b/package.json index 72f1a4b..a64d8e6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "rhf-conditional-logic", "description": "Conditional Logic for React Hook Forms. Fully typed and compatible with resolvers (e.g. Zod)", - "version": "0.1.1", + "version": "0.1.2", "main": "./dist/rhf-conditional-logic.cjs", "module": "./dist/rhf-conditional-logic.mjs", "exports": { diff --git a/src/index.ts b/src/index.ts index 5b4009f..8c08052 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,5 @@ import { Control, - FieldPath, FieldValues, UseFormGetValues, UseFormProps, @@ -9,7 +8,7 @@ import { useWatch, } from 'react-hook-form'; import { objectKeys } from 'ts-extras'; -import { FieldConditions, FieldPathPlusHash } from './types'; +import { FieldConditionPath, FieldConditions } from './types'; import { getConditionalLogic, getConditionalLogicWithDependencies, @@ -31,10 +30,11 @@ import { getByPath } from './utils/get-by-path'; */ export function useCondition< TFieldValues extends FieldValues, - TFieldNames extends FieldPath[], + TFieldConditions extends FieldConditions, + TConditionPaths extends FieldConditionPath, >( - fieldNamePaths: readonly [...TFieldNames], - conditions: FieldConditions, + fieldNamePaths: readonly [...TConditionPaths[]], + conditions: TFieldConditions, getValues: UseFormGetValues, control: Control ) { @@ -57,15 +57,10 @@ export function useCondition< */ export function pruneHiddenFields< TFieldValues extends FieldValues, - TFieldNames extends FieldPath[], ->( - getValues: UseFormGetValues, - conditions: FieldConditions -) { + TFieldConditions extends FieldConditions, +>(getValues: UseFormGetValues, conditions: TFieldConditions) { // Run all conditional logic and get results - const fieldPathsWithHashes = objectKeys( - conditions - ) as FieldPathPlusHash[]; + const fieldPathsWithHashes = objectKeys(conditions); let values = getValues(); const fieldPaths = fieldPathsWithHashes @@ -103,7 +98,7 @@ export function pruneHiddenFields< } return pathsToTransform; }) - .flat() as TFieldNames; + .flat() as unknown[] as FieldConditionPath[]; const conditionResults = getConditionalLogic(fieldPaths, conditions, getValues); diff --git a/src/types.ts b/src/types.ts index 6c21935..a97791d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,6 +37,19 @@ export type FieldConditions = Partial< > >; +/** + * All the form field paths that have conditional logic associated with them + * Converts "parent.#.child" to "parent.${number}.child" + */ +export type FieldConditionPath = { + [K in keyof T]: K extends string ? ReplaceHashesWithNumbers : never; +}[keyof T]; + +type ReplaceHashesWithNumbers = + T extends `${infer Start}.#.${infer Rest}` + ? `${Start}.${number}.${ReplaceHashesWithNumbers}` + : T; + /** * GetValues is derived from UseFormGetValues * diff --git a/src/utils/conditional-logic.ts b/src/utils/conditional-logic.ts index 04ede51..6113f43 100644 --- a/src/utils/conditional-logic.ts +++ b/src/utils/conditional-logic.ts @@ -1,6 +1,11 @@ import { FieldPath, FieldValues, UseFormGetValues } from 'react-hook-form'; import { objectKeys } from 'ts-extras'; -import { FieldConditions, FieldPathPlusHash, GetValues } from '../types'; +import { + FieldConditionPath, + FieldConditions, + FieldPathPlusHash, + GetValues, +} from '../types'; import { hashIndexRegex, integerIndexRegex } from './regex'; import { getConditionKeyWithHashThatMatchesPath, @@ -10,10 +15,11 @@ import { // Utility to compute conditional logic for one or more fields and track dependencies export function getConditionalLogicWithDependencies< TFieldValues extends FieldValues, - TFieldNames extends FieldPath[], + TFieldConditions extends FieldConditions, + TConditionPath extends FieldConditionPath, >( - formFieldPaths: readonly [...TFieldNames], - conditions: FieldConditions, + formFieldPaths: readonly [...TConditionPath[]], + conditions: TFieldConditions, getValues: UseFormGetValues ) { // Whenever a user looks up a value in a conditional logic function, we track @@ -54,48 +60,52 @@ export function getConditionalLogic< TFieldValues extends FieldValues, TFieldNames extends FieldPath[], TFieldNamesParam extends readonly [...TFieldNames], + TFieldConditions extends FieldConditions, + TConditionPath extends FieldConditionPath, >( - formFieldPaths: TFieldNamesParam, - conditions: FieldConditions, + formFieldPaths: readonly [...TConditionPath[]], + conditions: TFieldConditions, getValues: GetValues ) { // All condition keys that are generic (have # to match any index) const conditionKeysWithHashes = objectKeys(conditions).filter(key => hashIndexRegex.test(key) - ) as FieldPathPlusHash[]; + ) as (keyof TFieldConditions & string)[]; return formFieldPaths.map((path): boolean => { - let isVisible = true; - if (path in conditions) { // Found condition matching this field exactly - isVisible = conditions[path as keyof typeof conditions]!(getValues); - } else if (conditionKeysWithHashes.length && integerIndexRegex.test(path)) { + return conditions[path as keyof typeof conditions]!(getValues); + } + if (conditionKeysWithHashes.length && integerIndexRegex.test(path)) { const conditionKey = getConditionKeyWithHashThatMatchesPath( path, conditionKeysWithHashes ); if (conditionKey) { // Found matching condition key with hashes - // When calling getValues(), swap out any indices corresponding to the - // hashes with the indices of the passed field path - const modifiedGetValues = < - TFieldName extends FieldPathPlusHash, - TFieldNames extends FieldPathPlusHash[], - >( - fieldOrFields: TFieldName | readonly [...TFieldNames] + // Before calling getValues(), swap out any indices corresponding to the + // hashes with the indices of the passed field path. This allows child + // fields to do conditional logic based on their parent's values. + const modifiedGetValues = >( + withHashes: TFieldName | TFieldName[] ) => { - const transformedFieldOrFields = Array.isArray(fieldOrFields) - ? fieldOrFields.map(field => swapOutHashesInFieldPath(field, path)) - : swapOutHashesInFieldPath(fieldOrFields as TFieldName, path); - - // @ts-expect-error Not sure why this is so hard to get Types working for :( - return getValues(transformedFieldOrFields); + return Array.isArray(withHashes) + ? getValues( + withHashes.map(field => + swapOutHashesInFieldPath(field, path) + ) as TFieldName[] + ) + : getValues(swapOutHashesInFieldPath(withHashes, path) as TFieldName); }; - isVisible = conditions[conditionKey]!(modifiedGetValues); + // @ts-expect-error Oof, not sure why this isn't getting typed + return conditions[conditionKey](modifiedGetValues); } } - return isVisible; + // Unable to get conditional logic for this path. Don't hide the field in the UI, + // but show the developer a warning. They should already have a type error. + console.warn(`Missing RHF conditional logic for "${path}"`); + return true; }) as { [Index in keyof TFieldNamesParam]: boolean }; } diff --git a/src/utils/field-name-paths.ts b/src/utils/field-name-paths.ts index fc536c2..3178d94 100644 --- a/src/utils/field-name-paths.ts +++ b/src/utils/field-name-paths.ts @@ -48,14 +48,19 @@ export function getConditionKeyWithHashThatMatchesPath< return undefined; } +// Given a field path with hashes to lookup (e.g. "houses.#.color") that a conditional logic +// path depends on (e.g. "houses.1.cats.2.isNice"), swap out hashes in the requested path +// to match the current conditional logic path, in this case "houses.1.color" +// This enables conditional logic based on parent values in field arrays. +// +// e.g. show "isNice" field only for cats in blue houses +// const conditions = { "houses.#.cats.#.isNice": getValues => getValues("houses.#.color") == "blue" } +// export function swapOutHashesInFieldPath< TFieldValues extends FieldValues, - TFieldNameRequested extends FieldPathPlusHash, - TFieldNameConditional extends FieldPath, ->( - requestedFieldPath: TFieldNameRequested, - conditionalFieldPath: TFieldNameConditional -) { + TFieldNameWithHash extends FieldPathPlusHash, + TConditionPath extends FieldPath, +>(requestedFieldPath: TFieldNameWithHash, conditionalFieldPath: TConditionPath) { // No hashes to replace with indices if (!hashIndexRegex.test(requestedFieldPath)) { return requestedFieldPath as FieldPath; diff --git a/tests-e2e/mock-form/schema.ts b/tests-e2e/mock-form/schema.ts index bf9a21b..b6986b1 100644 --- a/tests-e2e/mock-form/schema.ts +++ b/tests-e2e/mock-form/schema.ts @@ -39,7 +39,7 @@ export const getDefaultValues = (): BlankFormSchema => ({ }); // Define conditional logic -export const conditions: FieldConditions = { +export const conditions = { otherCaterer: getValues => getValues('caterer') === 'Other', - ['guests.#.wine']: getValues => getValues('guests.#.age') === '21+', -}; + 'guests.#.wine': getValues => getValues('guests.#.age') === '21+', +} satisfies FieldConditions; diff --git a/tests-unit/get-conditional-logic.spec.ts b/tests-unit/get-conditional-logic.spec.ts new file mode 100644 index 0000000..2720c85 --- /dev/null +++ b/tests-unit/get-conditional-logic.spec.ts @@ -0,0 +1,90 @@ +import { get } from 'lodash-es'; +import { afterAll, describe, expect, it, test, vi } from 'vitest'; +import { getConditionalLogic as gcl } from '../src/utils/conditional-logic'; + +const formValues = { + contactName: 'Micah', + contactEmail: '', + caterer: null, + guests: [ + { + name: 'Ben', + age: 24, + hasGloves: true, + bottles: [ + { + grape: 'Tempranillo', + sips: 3, + isSmudgedByThisGuest: undefined, + isSmudgedByFirstGuest: undefined, + }, + ], + }, + { + name: 'Kate', + age: 28, + hasGloves: false, + bottles: [ + { + grape: 'Tempranillo', + sips: 2, + isSmudgedByThisGuest: undefined, + isSmudgedByFirstGuest: undefined, + }, + ], + }, + { + name: 'Joseph', + age: 16, + hasGloves: false, + }, + ], +}; +const conditions: Record unknown) => boolean> = { + contactEmail: gv => (gv('contactName') as string).length > 0, + 'guests.#.bottles.#.isSmudgedByThisGuest': gv => + (gv('guests.#.hasGloves') as boolean | undefined) !== true, + 'guests.#.bottles.#.isSmudgedByFirstGuest': gv => + (gv('guests.0.hasGloves') as boolean | undefined) !== true, +}; +const getValues = ((paths: string | string[]) => + Array.isArray(paths) + ? paths.map(path => get(formValues, path)) + : get(formValues, paths)) as any; // eslint-disable-line @typescript-eslint/no-explicit-any + +test('Basic case', () => { + expect(gcl(['contactEmail'], conditions, getValues)).toStrictEqual([true]); +}); + +test('Referencing parent in same lineage', () => { + expect( + gcl(['guests.0.bottles.0.isSmudgedByThisGuest'], conditions, getValues) + ).toStrictEqual([false]); + expect( + gcl(['guests.1.bottles.0.isSmudgedByThisGuest'], conditions, getValues) + ).toStrictEqual([true]); +}); + +test('Referencing parent in different lineage', () => { + expect( + gcl(['guests.0.bottles.0.isSmudgedByFirstGuest'], conditions, getValues) + ).toStrictEqual([false]); + expect( + gcl(['guests.1.bottles.0.isSmudgedByFirstGuest'], conditions, getValues) + ).toStrictEqual([false]); +}); + +describe('mocking console.warn...', () => { + const consoleWarnMock = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + afterAll(() => { + consoleWarnMock.mockReset(); + }); + + it('Log warning when looking up invalid condition', () => { + expect(gcl(['not_a_valid_condition'], conditions, getValues)).toStrictEqual([true]); + expect(consoleWarnMock).toHaveBeenCalledOnce(); + expect(consoleWarnMock).toHaveBeenLastCalledWith( + 'Missing RHF conditional logic for "not_a_valid_condition"' + ); + }); +});