Skip to content

Commit

Permalink
Make useCondition() more type-safe - v0.1.2 (#6)
Browse files Browse the repository at this point in the history
Fixes #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.
  • Loading branch information
micahjon authored Oct 13, 2024
1 parent e76c165 commit ecb56e9
Show file tree
Hide file tree
Showing 10 changed files with 177 additions and 58 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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._
Expand Down
11 changes: 5 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<FormSchema>;
```

- Use `useConditionalForm()` (a drop-in replacement for `useForm()`) to prune hidden field values before validation:


```ts
const { register } = useConditionalForm<FormSchema>({
Expand All @@ -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.

Expand All @@ -78,13 +77,13 @@ export type FormSchema = z.infer<typeof formSchema>;

// All conditional logic goes in a single declarative object
// { path.to.field: (getValues) => boolean }
export const conditions: FieldConditions<FormSchema> = {
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<FormSchema>;
```

```tsx
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
23 changes: 9 additions & 14 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {
Control,
FieldPath,
FieldValues,
UseFormGetValues,
UseFormProps,
Expand All @@ -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,
Expand All @@ -31,10 +30,11 @@ import { getByPath } from './utils/get-by-path';
*/
export function useCondition<
TFieldValues extends FieldValues,
TFieldNames extends FieldPath<TFieldValues>[],
TFieldConditions extends FieldConditions<TFieldValues>,
TConditionPaths extends FieldConditionPath<TFieldConditions>,
>(
fieldNamePaths: readonly [...TFieldNames],
conditions: FieldConditions<TFieldValues>,
fieldNamePaths: readonly [...TConditionPaths[]],
conditions: TFieldConditions,
getValues: UseFormGetValues<TFieldValues>,
control: Control<TFieldValues>
) {
Expand All @@ -57,15 +57,10 @@ export function useCondition<
*/
export function pruneHiddenFields<
TFieldValues extends FieldValues,
TFieldNames extends FieldPath<TFieldValues>[],
>(
getValues: UseFormGetValues<TFieldValues>,
conditions: FieldConditions<TFieldValues>
) {
TFieldConditions extends FieldConditions<TFieldValues>,
>(getValues: UseFormGetValues<TFieldValues>, conditions: TFieldConditions) {
// Run all conditional logic and get results
const fieldPathsWithHashes = objectKeys(
conditions
) as FieldPathPlusHash<TFieldValues>[];
const fieldPathsWithHashes = objectKeys(conditions);
let values = getValues();

const fieldPaths = fieldPathsWithHashes
Expand Down Expand Up @@ -103,7 +98,7 @@ export function pruneHiddenFields<
}
return pathsToTransform;
})
.flat() as TFieldNames;
.flat() as unknown[] as FieldConditionPath<TFieldConditions>[];

const conditionResults = getConditionalLogic(fieldPaths, conditions, getValues);

Expand Down
13 changes: 13 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@ export type FieldConditions<TFieldValues extends FieldValues> = Partial<
>
>;

/**
* All the form field paths that have conditional logic associated with them
* Converts "parent.#.child" to "parent.${number}.child"
*/
export type FieldConditionPath<T> = {
[K in keyof T]: K extends string ? ReplaceHashesWithNumbers<K> : never;
}[keyof T];

type ReplaceHashesWithNumbers<T extends string> =
T extends `${infer Start}.#.${infer Rest}`
? `${Start}.${number}.${ReplaceHashesWithNumbers<Rest>}`
: T;

/**
* GetValues is derived from UseFormGetValues
*
Expand Down
62 changes: 36 additions & 26 deletions src/utils/conditional-logic.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<TFieldValues>[],
TFieldConditions extends FieldConditions<TFieldValues>,
TConditionPath extends FieldConditionPath<TFieldConditions>,
>(
formFieldPaths: readonly [...TFieldNames],
conditions: FieldConditions<TFieldValues>,
formFieldPaths: readonly [...TConditionPath[]],
conditions: TFieldConditions,
getValues: UseFormGetValues<TFieldValues>
) {
// Whenever a user looks up a value in a conditional logic function, we track
Expand Down Expand Up @@ -54,48 +60,52 @@ export function getConditionalLogic<
TFieldValues extends FieldValues,
TFieldNames extends FieldPath<TFieldValues>[],
TFieldNamesParam extends readonly [...TFieldNames],
TFieldConditions extends FieldConditions<TFieldValues>,
TConditionPath extends FieldConditionPath<TFieldConditions>,
>(
formFieldPaths: TFieldNamesParam,
conditions: FieldConditions<TFieldValues>,
formFieldPaths: readonly [...TConditionPath[]],
conditions: TFieldConditions,
getValues: GetValues<TFieldValues>
) {
// All condition keys that are generic (have # to match any index)
const conditionKeysWithHashes = objectKeys(conditions).filter(key =>
hashIndexRegex.test(key)
) as FieldPathPlusHash<TFieldValues>[];
) 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<TFieldValues>,
TFieldNames extends FieldPathPlusHash<TFieldValues>[],
>(
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 = <TFieldName extends FieldPathPlusHash<TFieldValues>>(
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 };
}
17 changes: 11 additions & 6 deletions src/utils/field-name-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TFieldValues>,
TFieldNameConditional extends FieldPath<TFieldValues>,
>(
requestedFieldPath: TFieldNameRequested,
conditionalFieldPath: TFieldNameConditional
) {
TFieldNameWithHash extends FieldPathPlusHash<TFieldValues>,
TConditionPath extends FieldPath<TFieldValues>,
>(requestedFieldPath: TFieldNameWithHash, conditionalFieldPath: TConditionPath) {
// No hashes to replace with indices
if (!hashIndexRegex.test(requestedFieldPath)) {
return requestedFieldPath as FieldPath<TFieldValues>;
Expand Down
6 changes: 3 additions & 3 deletions tests-e2e/mock-form/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export const getDefaultValues = (): BlankFormSchema => ({
});

// Define conditional logic
export const conditions: FieldConditions<BlankFormSchema> = {
export const conditions = {
otherCaterer: getValues => getValues('caterer') === 'Other',
['guests.#.wine']: getValues => getValues('guests.#.age') === '21+',
};
'guests.#.wine': getValues => getValues('guests.#.age') === '21+',
} satisfies FieldConditions<BlankFormSchema>;
90 changes: 90 additions & 0 deletions tests-unit/get-conditional-logic.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, (getValues: (key: string) => 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"'
);
});
});

0 comments on commit ecb56e9

Please sign in to comment.