Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow spreading in reflect #12

Merged
merged 3 commits into from
Mar 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,23 @@ const arrayItemLens = lens.focus('array.0');
Transforms the lens structure with type inference.
It is useful when you want to create a new lens from existing one with different shape to pass it to a shared component.

The first argument is a dictionary of lenses. The second argument is the original lens.

```tsx
const contactLens = lens.reflect(({ profile }) => ({
name: profile.focus('contact.firstName'),
phoneNumber: profile.focus('contact.phone'),
}));

<SharedComponent lens={contactLens} />;

function SharedComponent({ lens }: { lens: Lens<{ name: string; phoneNumber: string }> }) {
// ...
}
```

```tsx
const contactLens = lens.reflect((l) => ({
const contactLens = lens.reflect((_, l) => ({
name: l.focus('profile.contact.firstName'),
phoneNumber: l.focus('profile.contact.phone'),
}));
Expand All @@ -175,7 +190,7 @@ Also, you can restructure array lens:

```tsx
function ArrayComponent({ lens }: { lens: Lens<{ value: string }[]> }) {
return <AnotherComponent lens={lens.reflect((l) => [{ data: l.focus('value') }])} />;
return <AnotherComponent lens={lens.reflect((_, l) => [{ data: l.focus('value') }])} />;
}

function AnotherComponent({ lens }: { lens: Lens<{ data: string }[]> }) {
Expand All @@ -189,7 +204,7 @@ In addition you can use `reflect` to merge two lenses into one.

```tsx
function Component({ lensA, lensB }: { lensA: Lens<{ firstName: string }>; lensB: Lens<{ lastName: string }> }) {
const combined = lensA.reflect((l) => ({
const combined = lensA.reflect((_, l) => ({
firstName: l.focus('firstName'),
lastName: lensB.focus('lastName'),
}));
Expand All @@ -200,6 +215,22 @@ function Component({ lensA, lensB }: { lensA: Lens<{ firstName: string }>; lensB

Keep in mind that is such case the passed to `reflect` function is longer pure.

You can use spread in reflect if you want to leave other properties as is. In runtime the first argument is just a proxy that calls `focus` on the original lens.

```tsx
function Component({ lens }: { lens: Lens<{ firstName: string; lastName: string; age: number }> }) {
return (
<PersonForm
lens={lens.reflect(({ firstName, lastName, ...rest }) => ({
...rest,
name: firstName,
surname: lastName,
}))}
/>
);
}
```

##### `map` (Array Lenses)

Maps over array fields with `useFieldArray` integration
Expand Down
4 changes: 3 additions & 1 deletion examples/stories/Complex.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ export function Complex({ onSubmit = action('submit') }: ComplexProps) {
<StringInput lens={lens.focus('studio.location')} label="Studio Location" />

<hr />
<PersonForm lens={lens.focus('studio.owner').reflect((l) => ({ name: l.focus('personName'), birthYear: l.focus('yearOfBirth') }))} />
<PersonForm
lens={lens.focus('studio.owner').reflect(({ personName, yearOfBirth }) => ({ name: personName, birthYear: yearOfBirth }))}
/>

<hr />
<MoviesForm lens={lens.focus('movies')} />
Expand Down
6 changes: 3 additions & 3 deletions examples/stories/Demo.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ export function Demo({ onSubmit = action('submit') }: DemoProps) {
return (
<form onSubmit={handleSubmit(onSubmit)}>
<PersonForm
lens={lens.reflect((l) => ({
name: l.focus('firstName'),
surname: l.focus('lastName'),
lens={lens.reflect(({ firstName, lastName }) => ({
name: firstName,
surname: lastName,
}))}
/>
<NumberInput lens={lens.focus('age')} />
Expand Down
6 changes: 3 additions & 3 deletions examples/stories/Quickstart.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ export function Quickstart() {
return (
<form onSubmit={handleSubmit(action('submit'))}>
<PersonForm
lens={lens.reflect((l) => ({
name: l.focus('firstName'),
surname: l.focus('lastName'),
lens={lens.reflect(({ firstName, lastName }) => ({
name: firstName,
surname: lastName,
}))}
/>
<ChildForm lens={lens.focus('children')} />
Expand Down
8 changes: 4 additions & 4 deletions examples/stories/restructure/Reflect.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ export function Reflect({ onSubmit = action('submit') }: ReflectProps) {
return (
<form onSubmit={handleSubmit(onSubmit)}>
<PersonForm
lens={lens.reflect((l) => ({
name: l.focus('firstName'),
surname: l.focus('lastName.value'),
lens={lens.reflect(({ firstName, lastName }) => ({
name: firstName,
surname: lastName.focus('value'),
}))}
/>

Expand Down Expand Up @@ -73,7 +73,7 @@ export function ArrayReflect({ onSubmit = action('submit') }: ArrayReflectProps)

return (
<form onSubmit={handleSubmit(onSubmit)}>
<Items lens={lens.focus('items').reflect((l) => [{ data: l.focus('value').focus('inside') }])} />
<Items lens={lens.focus('items').reflect((l) => [{ data: l.value.focus('inside') }])} />
<div>
<button type="submit">submit</button>
</div>
Expand Down
7 changes: 6 additions & 1 deletion examples/stories/restructure/ReflectCombined.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ export function ReflectCombined({ onSubmit = action('submit') }: ReflectCombined

return (
<form onSubmit={handleSubmit(onSubmit)}>
<PersonForm lens={lens.focus('firstName').reflect((firstNameLens) => ({ name: firstNameLens, surname: lens.focus('lastName') }))} />
<PersonForm
lens={lens.focus('firstName').reflect((_, firstName) => ({
name: firstName.reflect((_, l) => ({ nestedName: l })).focus('nestedName'),
surname: lens.focus('lastName'),
}))}
/>

<div>
<button id="submit">Submit</button>
Expand Down
49 changes: 49 additions & 0 deletions examples/stories/restructure/Spread.story.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useForm } from 'react-hook-form';
import { Lens, useLens } from '@hookform/lenses';
import { action } from '@storybook/addon-actions';
import { Meta } from '@storybook/react';

export default {
title: 'Restructure',
} satisfies Meta;

export function Spread() {
const { handleSubmit, control } = useForm<{
firstName: string;
lastName: string;
age: number;
}>({});

const lens = useLens({ control });

return (
<form onSubmit={handleSubmit(action('submit'))}>
<PersonForm
lens={lens.reflect(({ firstName, lastName, ...rest }) => ({
...rest,
name: firstName,
surname: lastName,
}))}
/>
<input type="submit" />
</form>
);
}

function PersonForm({ lens }: { lens: Lens<{ name: string; surname: string; age: number }> }) {
return (
<div>
<StringInput lens={lens.focus('name')} />
<StringInput lens={lens.focus('surname')} />
<NumberInput lens={lens.focus('age')} />
</div>
);
}

function StringInput({ lens }: { lens: Lens<string> }) {
return <input {...lens.interop((ctrl, name) => ctrl.register(name))} />;
}

function NumberInput({ lens }: { lens: Lens<number> }) {
return <input type="number" {...lens.interop((ctrl, name) => ctrl.register(name, { valueAsNumber: true }))} />;
}
4 changes: 2 additions & 2 deletions examples/tests/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ test('lens can cache by function with useCallback', () => {
const form = useForm<{ a: string }>();
const lens = useLens({ control: form.control });

const reflectedLens = lens.reflect(useCallback((l) => ({ b: l.focus('a') }), [lens]));
const reflectedLens = lens.reflect(useCallback((l) => ({ b: l.a }), [lens]));
return { reflectedLens };
});

Expand All @@ -34,7 +34,7 @@ test('lens cannot be cache by function without useCallback', () => {
const form = useForm<{ a: string }>();
const lens = useLens({ control: form.control });

const reflectedLens = lens.reflect((l) => ({ b: l.focus('a') }));
const reflectedLens = lens.reflect((l) => ({ b: l.a }));
return { reflectedLens };
});

Expand Down
30 changes: 19 additions & 11 deletions examples/tests/reflect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,28 @@ test('reflect can create a new lens', () => {
return lens;
});

expectTypeOf(result.current.reflect((l) => ({ b: l.focus('a') }))).toEqualTypeOf<Lens<{ b: string }>>();
expectTypeOf(result.current.reflect((l) => ({ b: l.a }))).toEqualTypeOf<Lens<{ b: string }>>();
});

test('spread operator is not allowed for lenses', () => {
test('spread operator is allowed for lenses in reflect', () => {
const { result } = renderHook(() => {
const form = useForm<{ a: string }>();
const form = useForm<{ a: string; b: { c: number } }>();
const lens = useLens({ control: form.control });
return lens;
});

// @ts-expect-error spread operator is not allowed for lenses
assertType(result.current.reflect((l) => ({ ...l })));
expectTypeOf(
result.current.reflect((l) => {
expectTypeOf(l).toEqualTypeOf<{
a: Lens<string>;
b: Lens<{
c: number;
}>;
}>();

return { ...l };
}),
).toEqualTypeOf<Lens<{ a: string; b: { c: number } }>>();
});

test('reflect can create a new lens from a field array item', () => {
Expand All @@ -32,7 +42,7 @@ test('reflect can create a new lens from a field array item', () => {
return lens;
});

expectTypeOf(result.current.reflect((l) => ({ b: l.focus('a').focus('0') }))).toEqualTypeOf<Lens<{ b: string }>>();
expectTypeOf(result.current.reflect((l) => ({ b: l.a.focus('0') }))).toEqualTypeOf<Lens<{ b: string }>>();
});

test('non lens fields cannot returned from reflect', () => {
Expand All @@ -43,7 +53,7 @@ test('non lens fields cannot returned from reflect', () => {
});

// @ts-expect-error non lens fields cannot be returned from reflect
assertType(result.current.reflect((l) => ({ b: l.focus('a'), w: 'hello' })));
assertType(result.current.reflect((_, l) => ({ b: l.focus('a'), w: 'hello' })));
});

test('reflect can add props from another lens', () => {
Expand All @@ -59,9 +69,7 @@ test('reflect can add props from another lens', () => {
return lens;
});

expectTypeOf(form1.current.reflect((l) => ({ c: l.focus('a'), d: form2.current.focus('b') }))).toEqualTypeOf<
Lens<{ c: string; d: number }>
>();
expectTypeOf(form1.current.reflect((l) => ({ c: l.a, d: form2.current.focus('b') }))).toEqualTypeOf<Lens<{ c: string; d: number }>>();
});

test('reflect can work with array', () => {
Expand All @@ -70,7 +78,7 @@ test('reflect can work with array', () => {

const lens = useLens({ control: form.control })
.focus('items')
.reflect((l) => [{ another: l.focus('value') }]);
.reflect((l) => [{ another: l.value }]);

const arr = useFieldArray(lens.interop());

Expand Down
22 changes: 18 additions & 4 deletions src/LensCore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type Control, type FieldValues, get } from 'react-hook-form';

import type { LensesValues } from './types/helpers';
import type { Lens } from './types/lenses';
import type { LensesStorage } from './LensesStorage';

Expand All @@ -10,14 +11,14 @@ interface Settings {
}

export class LensCore {
public settings: Settings;
public control: Control;
public settings: Settings;
public cache?: LensesStorage | undefined;

private constructor(control: Control<any>, cache?: LensesStorage, settings: Settings = {}) {
this.control = control;
this.cache = cache;
this.settings = settings;
this.cache = cache;
}

public static create<TFieldValues extends FieldValues = FieldValues>(
Expand Down Expand Up @@ -83,14 +84,27 @@ export class LensCore {
return focusedLens;
}

public reflect(getter: (original: LensCore) => Record<string, LensCore> | [Record<string, LensCore>]): LensCore {
public reflect(getter: (value: LensesValues<any>, lens: LensCore) => Record<string, LensCore> | [Record<string, LensCore>]): LensCore {
const fromCache = this.cache?.get(this.settings.propPath ?? '', getter);

if (fromCache) {
return fromCache;
}

const focusContext = getter(this);
const proxy = new Proxy(
{},
{
get: (target, prop) => {
if (typeof prop === 'string') {
return this.focus(prop);
}

return target;
},
},
);

const focusContext = getter(proxy, this);

const newLens = new LensCore(this.control, this.cache, {
lensesMap: focusContext,
Expand Down
8 changes: 8 additions & 0 deletions src/types/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import type { FieldValues } from 'react-hook-form';

import type { Lens, NonObjectFieldShim } from './lenses';

export type LensesMap<T> = {
[key in keyof T]: Lens<T[key]>;
};

export type LensesValues<T> = T extends FieldValues
? {
[key in keyof T]: Lens<T[key]>;
}
: Lens<T>;

export type LensesDeepMap<T> = {
[key: string]: Lens<T> | LensesDeepMap<T>;
};
Expand Down
8 changes: 5 additions & 3 deletions src/types/lenses.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Control, FieldValues, Path, PathValue } from 'react-hook-form';

import type { LensesMap, UnwrapLens } from './helpers';
import type { LensesMap, LensesValues, UnwrapLens } from './helpers';

/**
* This is a trick to allow `control` to have typed `Control<T>` type.
Expand Down Expand Up @@ -167,7 +167,9 @@ export interface LensReflect<T> {
* }
* ```
*/
reflect: <T2>(getter: (original: Lens<T>) => LensesMap<T2>) => Lens<UnwrapLens<LensesMap<T2>>>;
reflect: <T2>(
getter: (value: T extends FieldValues ? LensesValues<T> : never, lens: Lens<T>) => LensesMap<T2>,
) => Lens<UnwrapLens<LensesMap<T2>>>;
}

export interface LensMap<T extends any[]> {
Expand Down Expand Up @@ -211,7 +213,7 @@ export interface LensMap<T extends any[]> {
}

export interface ArrayLens<T extends any[]> extends LensMap<T>, LensFocus<T> {
reflect: <T2>(getter: (original: Lens<T[number]>) => [LensesMap<T2>]) => Lens<UnwrapLens<LensesMap<T2>>[]>;
reflect: <T2>(getter: (value: LensesValues<T[number]>, lens: Lens<T[number]>) => [LensesMap<T2>]) => Lens<UnwrapLens<LensesMap<T2>>[]>;
}
export interface ObjectLens<T> extends LensFocus<T>, LensReflect<T> {}
// Will leave primitive lense interface for future use
Expand Down
Loading