Skip to content

Commit

Permalink
feat: add rudimentrary impl of zod to atom group
Browse files Browse the repository at this point in the history
rudimentrary implementation, probably doesn't handle types probably
  • Loading branch information
barelyhuman committed May 28, 2024
1 parent ce67123 commit 5088546
Show file tree
Hide file tree
Showing 5 changed files with 172 additions and 1 deletion.
61 changes: 61 additions & 0 deletions __tests__/04_schema_generation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import 'regenerator-runtime/runtime';

import {
act,
cleanup,
fireEvent,
render,
waitFor,
} from '@testing-library/react';
import React from 'react';
import { z } from 'zod';
import { atomFromZodSchema } from '../src/utils/zod';
import { FormControlPrimitiveValues } from './components/FormControl';

afterEach(() => {
cleanup();
});

describe('atomFromZodSchema', () => {
it('will create a form atom', async () => {
const schema = z.object({
email: z.string().email().default('[email protected]'),
age: z.number(),
agreed: z.boolean(),
});

const formAtom = atomFromZodSchema(schema);

const { getByText, getByLabelText } = render(
<div>
<FormControlPrimitiveValues atomDef={formAtom} />
</div>,
);

await act(async () => {
await waitFor(() => {
const emailInput = getByLabelText('email-input');
getByText('email: [email protected]');

const ageInput = getByLabelText('age-input');
getByText('age: 0');

const agreedInput = getByLabelText('agree-input');
getByText('agreed: No');

fireEvent.change(emailInput, {
target: { value: '[email protected]' },
});
getByText('email: [email protected]');

fireEvent.change(ageInput, {
target: { value: '2' },
});
getByText('age: 2');

fireEvent.click(agreedInput);
getByText('agreed: Yes');
});
});
});
});
28 changes: 28 additions & 0 deletions __tests__/components/FormControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,34 @@ type Props = {
atomDef: Atom<any>;
};

export const FormControlPrimitiveValues = ({ atomDef }: Props) => {
const { values, handleOnChange } = useAtomValue(atomDef);

return (
<>
<input
aria-label="email-input"
value={values.email}
onChange={(e) => handleOnChange('email')(e.target.value)}
/>
<p>email: {values.email}</p>
<input
aria-label="age-input"
value={values.age}
onChange={(e) => handleOnChange('age')(e.target.value)}
/>
<p>age: {values.age}</p>
<input
aria-label="agree-input"
type="checkbox"
checked={values.agreed}
onChange={(e) => handleOnChange('agreed')(e.target.value)}
/>
<p>agreed: {values.agreed ? 'Yes' : 'No'}</p>
</>
);
};

export const FormControlValues = ({ atomDef }: Props) => {
const { values, handleOnChange } = useAtomValue(atomDef);

Expand Down
14 changes: 13 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@
"module": "./dist/react/index.modern.js",
"import": "./dist/react/index.modern.mjs",
"default": "./dist/react/index.umd.js"
},
"./utils/zod": {
"types": "./dist/utils/src/utils/zod.d.ts",
"module": "./dist/utils/zod.modern.js",
"import": "./dist/utils/zod.modern.mjs",
"default": "./dist/utils/zod.umd.js"
}
},
"sideEffects": false,
Expand Down Expand Up @@ -108,6 +114,12 @@
"zod": "^3.22.4"
},
"peerDependencies": {
"jotai": ">=2"
"jotai": ">=2",
"zod": ">=3"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
}
4 changes: 4 additions & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ module.exports = function config() {
// react
builder.buildUMD('./src/react/index.ts', 'jotai-form-react', 'dist/react'),
builder.buildESM('./src/react/index.ts', 'dist/react'),

// utils - zod
builder.buildUMD('./src/utils/zod.ts', 'jotai-form-zod', 'dist/utils/zod'),
builder.buildESM('./src/utils/zod.ts', 'dist/utils/zod'),
);
};

Expand Down
66 changes: 66 additions & 0 deletions src/utils/zod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { ZodDefault, ZodObject, ZodType } from 'zod';
import { Options, atomWithFormControls } from '../atomWithFormControls';
import { atomWithValidate } from '../atomWithValidate';
import { AtomWithValidation } from '../validateAtoms';

const INSTANCE_DEFAULT_MAP = {
string: 'defaultemail',
number: 0,
boolean: false,
};

const INTERNAL_INSTANCE_PRIMITIVE_MAP = {
ZodString: 'string',
ZodNumber: 'number',
ZodBoolean: 'boolean',
};

type INTERNAL_PRIMITIVE_MAP_KEY = keyof typeof INTERNAL_INSTANCE_PRIMITIVE_MAP;
type INTERNAL_DEFAULT_MAP_KEY = keyof typeof INSTANCE_DEFAULT_MAP;

export function atomFromZodSchema<
T,
AtomGroup extends Record<string, AtomWithValidation<any>>,
Keys extends Extract<keyof AtomGroup, string>,
Vals extends AtomGroup[Keys],
>(schema: ZodType<T>, options?: Options<Keys, Vals>) {
const result = {} as Record<string, AtomWithValidation<any>>;

// eslint-disable-next-line no-underscore-dangle
if (schema instanceof ZodObject) {
// eslint-disable-next-line no-underscore-dangle
Object.entries(schema._def.shape()).forEach(([key, value]) => {
if (value instanceof ZodDefault) {
// eslint-disable-next-line no-underscore-dangle
const validationAtom = atomWithValidate(value._def.defaultValue(), {
validate: (d) => value.parse(d),
});
result[key] = validationAtom;
} else if (value instanceof ZodType) {
const typeName = value.constructor.name;
const toPrimitiveKey = Object.keys(
INTERNAL_INSTANCE_PRIMITIVE_MAP,
).find((d) => {
return typeName === d;
}) as INTERNAL_PRIMITIVE_MAP_KEY | undefined;
if (toPrimitiveKey) {
const primitiveValue = INTERNAL_INSTANCE_PRIMITIVE_MAP[
toPrimitiveKey
] as INTERNAL_DEFAULT_MAP_KEY;
const defaultValue = INSTANCE_DEFAULT_MAP[primitiveValue];
// eslint-disable-next-line no-underscore-dangle
const validationAtom = atomWithValidate(defaultValue, {
validate: (d) => value.parse(d),
});
result[key] = validationAtom;
}
}
});
}
return atomWithFormControls<AtomGroup, Keys, Vals>(result as AtomGroup, {
validate: (values) => {
schema.parse(values);
},
...options,
});
}

0 comments on commit 5088546

Please sign in to comment.