Skip to content

Commit

Permalink
feat: typesafe input docs, tests, and types
Browse files Browse the repository at this point in the history
  • Loading branch information
airjp73 committed Jun 25, 2024
1 parent eba9ff4 commit b84ba0f
Show file tree
Hide file tree
Showing 9 changed files with 147 additions and 11 deletions.
18 changes: 12 additions & 6 deletions apps/docs-v2/app/routes/_docs.input-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,16 @@ that you would get out of `form.value("myField")`.

## Number inputs

### Relevant types

- `NumberInputValue`
- `number | null`

### Setting default values

<Row>
<Col>
- Number inputs can be set using either a `number` or a `string`,
and both approaches can be used for different fields in the same form.
- When you access the value of the input using `form.value("age")`, the type
of that value will be consistent with the type of the default value, even after
the user has changed the value.
- If there is no default value, the type of the value will be a `number`.
Number inputs can be set using either a `number`, a `string`, or `null`.
</Col>
<Col>
```tsx
Expand All @@ -75,6 +75,12 @@ that you would get out of `form.value("myField")`.
</Col>
</Row>

### Observing / setting values

- An empty number input will always have a value of `null`.
- If you set the default value using a `string`, the value will always be a `string` (or `null`).
- If you set the default value using a `number` or `null`, the value will always be a `number` (or `null`).

### Validating

Like most input types, the value inside the native `form` element is always a string.
Expand Down
1 change: 1 addition & 0 deletions apps/docs-v2/app/routes/_docs.recipes.typesafe-input.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Typesafe input component
58 changes: 53 additions & 5 deletions apps/docs-v2/app/routes/_docs.scoping.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ code in this demo.
<Row>
<Col>
When you call `useForm`, it returns a [`ReactFormApi`](/reference/form-api) object.
The type of this object takes a single generic paramter, which is the type of the default values.
The type of this object takes a single generic parameter, which is the type of the form's default values.
</Col>
<Col>
```tsx
// `form` has the type `ReactFormApi<{ foo: string }>`
const form = useForm<DefaultValuesType>({
const form = useForm({
defaultValues: { foo: "bar" },
// ...etc
});
Expand All @@ -35,13 +35,16 @@ code in this demo.
<Row>
<Col>
One of the methods on `ReactFormApi` is `scope`.
When you call `scope` and pass it the name of a field, it returns a `FormScope<FieldType>`
where `FieldType` is the type of the field.
When you call `scope` and pass it the name of a field, it returns a `FormScope`
that's been scoped to that field.
You can even continue chaining `scope` calls to get even deeper, which is useful for deeply nested or recursive data.

Once you have a `FormScope`, you can pass it around to any component that needs it.
But in order to actually use it, you need to pass it to a `useFormScope`, `useField`, or `useFieldArray` hook.
</Col>
<Col>
```tsx
const form = useForm<DefaultValuesType>({
const form = useForm({
defaultValues: {
foo: "foo",
bar: { baz: "baz" }
Expand All @@ -61,3 +64,48 @@ code in this demo.

</Col>
</Row>

## Typesafe text input

<Row>
<Col>
Scoping allows us to encapsulate input logic in a typesafe way.
For this example, let's create an text input component that:

- Is guranteed by the types to have a `string` value.
- Automatically shows the validation error message if there is one.
- Can be used without having to think about whether to use `getInputProps` or `getControlProps`.

We can do this by accepting a `FormScope&lt;string&gt;` as a prop
and passing it to `useField`.

</Col>
<Col>
```tsx
type MyInputProps = {
scope: FormScope<string>;
label: string;
};

const MyInput = ({ scope, label }: MyInputProps) => {
const field = useField(scope);
return (
<div>
<label>
{label}
<input {...field.getInputProps()} />
</label>
{/* There's more work to be done here for accesibility,
but we're keeping it simple for now. */}
{field.error() && <span>{field.error()}</span>}
</div>
);
}
```

</Col>
</Row>

This particular input component can only handle text inputs,
but it's also possible to create a typesafe input component that can handle any
`input` type. Checkout the [typesafe input component recipe](/recipes/typesafe-input) for more details.
6 changes: 6 additions & 0 deletions apps/docs-v2/app/ui/layout/Navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,12 @@ export const navigation: Array<NavGroup> = [
{ title: "Different input types", href: "/input-types" },
],
},
{
title: "Recipes",
links: [
{ title: "Typesafe input component", href: "/recipes/typesafe-input" },
],
},
];

export function Navigation({
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/input-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,16 @@ export type SingleFileInputValue = null | File;
* Empty file inputs are represented by an empty string.
*/
export type MultiFileInputValue = null | File[];

export type NumberInputValue = number | null;

export type NativeValueByType = {
text: string;
number: NumberInputValue;
checkbox: boolean | string | string[];
radio: string;
file: null | File | File[];
};

export type NativeInputValue<Type extends string> =
Type extends keyof NativeValueByType ? NativeValueByType[Type] : string;
20 changes: 20 additions & 0 deletions packages/core/src/type.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { NativeInputValue } from "./input-types";

it("should give specific types for inputs with special handling", () => {
expectTypeOf<NativeInputValue<"text">>().toEqualTypeOf<string>();
expectTypeOf<NativeInputValue<"number">>().toEqualTypeOf<number | null>();
expectTypeOf<NativeInputValue<"checkbox">>().toEqualTypeOf<
boolean | string | string[]
>();
expectTypeOf<NativeInputValue<"radio">>().toEqualTypeOf<string>();
expectTypeOf<NativeInputValue<"file">>().toEqualTypeOf<
File | File[] | null
>();
});

it("should just return string for every other type", () => {
expectTypeOf<NativeInputValue<"jim">>().toEqualTypeOf<string>();
expectTypeOf<NativeInputValue<"date">>().toEqualTypeOf<string>();
expectTypeOf<NativeInputValue<"tel">>().toEqualTypeOf<string>();
expectTypeOf<NativeInputValue<"password">>().toEqualTypeOf<string>();
});
3 changes: 3 additions & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export {
isValidationErrorResponse,
type SingleFileInputValue,
type MultiFileInputValue,
type NumberInputValue,
type NativeValueByType,
type NativeInputValue,
} from "@rvf/core";
export { type ReactFormApi, type FormFields } from "./base";
export { useForm, FormOpts } from "./useForm";
Expand Down
36 changes: 36 additions & 0 deletions packages/react/src/test/input-types.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react";
import { useForm } from "../useForm";
import { successValidator } from "./util/successValidator";
import userEvent from "@testing-library/user-event";
import { ZeroCurvatureEnding } from "three";

describe("number inputs", () => {
it("default values set as numbers", async () => {
Expand Down Expand Up @@ -29,6 +30,10 @@ describe("number inputs", () => {
await userEvent.type(screen.getByTestId("age"), "4");
expect(screen.getByTestId("age")).toHaveValue(254);
expect(screen.getByTestId("age-value").textContent).toEqual("254");

await userEvent.clear(screen.getByTestId("age"));
expect(screen.getByTestId("age")).toHaveValue(null);
expect(screen.getByTestId("age-value").textContent).toEqual("null");
});

it("no default value", async () => {
Expand Down Expand Up @@ -87,6 +92,37 @@ describe("number inputs", () => {
await userEvent.type(screen.getByTestId("age"), "4");
expect(screen.getByTestId("age")).toHaveValue(254);
expect(screen.getByTestId("age-value").textContent).toEqual('"254"');

await userEvent.clear(screen.getByTestId("age"));
expect(screen.getByTestId("age")).toHaveValue(null);
expect(screen.getByTestId("age-value").textContent).toEqual("null");
});

it("null default", async () => {
const TestComp = () => {
const form = useForm({
defaultValues: { age: null },
validator: successValidator,
});

return (
<form {...form.getFormProps()}>
<input
data-testid="age"
{...form.field("age").getInputProps({ type: "number" })}
/>
<pre data-testid="age-value">{JSON.stringify(form.value("age"))}</pre>
</form>
);
};
render(<TestComp />);

expect(screen.getByTestId("age")).toHaveValue(null);
expect(screen.getByTestId("age-value").textContent).toEqual("null");

await userEvent.type(screen.getByTestId("age"), "4");
expect(screen.getByTestId("age")).toHaveValue(4);
expect(screen.getByTestId("age-value").textContent).toEqual("4");
});
});

Expand Down
3 changes: 3 additions & 0 deletions packages/remix/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ export {
useNativeValidity,
type SingleFileInputValue,
type MultiFileInputValue,
type NumberInputValue,
type NativeValueByType,
type NativeInputValue,
} from "@rvf/react";
export { useForm, type RemixFormOpts as FormScopeRemixOpts } from "./useForm";
export { ValidatedForm, type ValidatedFormProps } from "./ValidatedForm";
Expand Down

0 comments on commit b84ba0f

Please sign in to comment.