Skip to content

Commit

Permalink
feat(ListField): improved builder
Browse files Browse the repository at this point in the history
fixes #40
  • Loading branch information
MiroslavPetrik committed Nov 30, 2023
1 parent 7693643 commit 35d19aa
Show file tree
Hide file tree
Showing 6 changed files with 170 additions and 31 deletions.
37 changes: 21 additions & 16 deletions src/components/list-field/Docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,27 +22,32 @@ import { ListField } from "@form-atoms/field";
<Canvas sourceState="none" of={ListFieldStories.Primary} />

```tsx
import { formAtom, fieldAtom } from "form-atoms";
import { ListField } from "@form-atoms/field";
import { formAtom } from "form-atoms";
import { ListField, textField, listFieldBuilder } from "@form-atoms/field";

// listFieldBulder creates a builder which helps initialize form fields for you
// the callback item shape is based on the callback form fields returned from it!
const envVarsBuilder = listFieldBuilder((item: { variable; value }) => ({
variable: textField({ name: "variable", value: variable }),
value: textField({ name: "value", value: value }),
// Uncommenting the following line will make item.owner property available!
// owner: textField({ name: "owner" }),
}));

const envForm = formAtom({
envVars: [
{
name: fieldAtom({ value: "API_KEY" }),
value: fieldAtom({ value: "ff52d09a" }),
},
],
// call the builder with your data, when initializing the form
envVars: envVarsBuilder([
{ variable: "GITHUB_TOKEN", value: "ff52d09a" },
{ variable: "NPM_TOKEN", value: "deepsecret" },
]),
});

const Example = () => (
<ListField
form={envForm}
path={["envVars"]}
keyFrom="name"
builder={() => ({
name: fieldAtom({ value: "" }),
value: fieldAtom({ value: "" }),
})}
keyFrom="variable"
builder={envVarsBuilder}
AddItemButton={({ add }) => (
<button type="button" className="outline" onClick={add}>
Add environment variable
Expand All @@ -63,12 +68,12 @@ const Example = () => (
}}
>
<InputField
atom={fields.name}
render={(props) => <input {...props} placeholder="Name" />}
atom={fields.variable}
render={(props) => <input {...props} placeholder="Variable Name" />}
/>
<InputField
atom={fields.value}
render={(props) => <input {...props} placeholder="Value" />}
render={(props) => <input {...props} placeholder="Variable Value" />}
/>
<RemoveItemButton />
</div>
Expand Down
36 changes: 21 additions & 15 deletions src/components/list-field/ListField.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
ListField,
RemoveItemButtonProps,
} from "./ListField";
import { listFieldBuilder } from "./listFieldBuilder";
import { textField } from "../../fields";
import { checkboxField } from "../../fields/checkbox-field";
import { formStory, meta } from "../../scenarios/StoryForm";
import { FieldLabel } from "../field-label";
Expand All @@ -15,33 +17,33 @@ export default {
title: "components/ListField",
};

const envVarsBuilder = listFieldBuilder(({ variable, value }) => ({
variable: textField({ name: "variable", value: variable }),
value: textField({ name: "value", value: value }),
}));

export const Primary = formStory({
parameters: {
docs: {
description: {
story:
"The array field enables you to capture list of items with the same attributes. It offers `add` and `remove` callbacks to append new item or drop existing one.",
"The array field enables you to capture list of items with the same attributes. It offers `add` and `remove` callbacks to append new item or drop an existing one.",
},
},
},
args: {
fields: {
envVars: [
{
name: fieldAtom({ value: "API_KEY" }),
value: fieldAtom({ value: "ff52d09a" }),
},
],
envVars: envVarsBuilder([
{ variable: "GITHUB_TOKEN", value: "ff52d09a" },
{ variable: "NPM_TOKEN", value: "deepsecret" },
]),
},
children: ({ form }) => (
<ListField
form={form}
path={["envVars"]}
keyFrom="name"
builder={() => ({
name: fieldAtom({ value: "" }),
value: fieldAtom({ value: "" }),
})}
keyFrom="variable"
builder={envVarsBuilder}
AddItemButton={({ add }) => (
<button type="button" className="outline" onClick={add}>
Add environment variable
Expand All @@ -58,12 +60,16 @@ export const Primary = formStory({
}}
>
<InputField
atom={fields.name}
render={(props) => <input {...props} placeholder="Name" />}
atom={fields.variable}
render={(props) => (
<input {...props} placeholder="Variable Name" />
)}
/>
<InputField
atom={fields.value}
render={(props) => <input {...props} placeholder="Value" />}
render={(props) => (
<input {...props} placeholder="Variable Value" />
)}
/>
<RemoveItemButton />
</div>
Expand Down
1 change: 1 addition & 0 deletions src/components/list-field/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./ListField";
export * from "./useListFieldActions";
export * from "./listFieldBuilder";
19 changes: 19 additions & 0 deletions src/components/list-field/listFieldBuilder.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { expectTypeOf, test } from "vitest";

import { listFieldBuilder } from "./listFieldBuilder";
import { textField } from "../../fields";

test("listFieldBuilder - cannot build with random data", () => {
const addressBuilder = listFieldBuilder(({ street }) => ({
street: textField({ name: "street", value: street }),
}));

expectTypeOf(addressBuilder).toBeCallableWith([{ street: "foo" }]);

// Doesnt work for no-argument (due to function overload)
// https://github.com/mmkal/expect-type/issues/30
// expectTypeOf(addressBuilder).toBeCallableWith();

// TODO: expect-type issue
// expectTypeOf(addressBuilder).not.toBeCallableWith([{ notStreet: "foo" }]);
});
65 changes: 65 additions & 0 deletions src/components/list-field/listFieldBuilder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { renderHook } from "@testing-library/react";
import { formAtom, useFormValues } from "form-atoms";
import { describe, expect, it } from "vitest";

import { listFieldBuilder } from "./listFieldBuilder";
import { textField } from "../../fields";

describe("listFieldBuilder()", () => {
describe("building plain atoms", () => {
const builder = listFieldBuilder((value) =>
textField({ name: "street", value }),
);

it("initializes empty atom when called without argument", async () => {
const field = builder();

const form = formAtom({ test: field });
const { result } = renderHook(() => useFormValues(form));

expect(result.current).toEqual({ test: "" });
});

it("initialized multiple items", async () => {
const streets = builder(["foo", "bar"]);

const form = formAtom({ streets });
const { result } = renderHook(() => useFormValues(form));

expect(result.current).toEqual({ streets: ["foo", "bar"] });
});
});

describe("building form fields object", () => {
const addressBuilder = listFieldBuilder(({ street, city }) => ({
street: textField({ name: "street", value: street }),
city: textField({ name: "city", value: city }),
}));

it("initializes empty form fields when called without argument", async () => {
const fields = addressBuilder();

const form = formAtom(fields);
const { result } = renderHook(() => useFormValues(form));

expect(result.current).toEqual({ street: "", city: "" });
});

it("initialized multiple items", async () => {
const addresses = addressBuilder([
{ city: "Kosice", street: "Hlavna" },
{ city: "Bratislava", street: "Hrad" },
]);

const form = formAtom({ addresses });
const { result } = renderHook(() => useFormValues(form));

expect(result.current).toEqual({
addresses: [
{ city: "Kosice", street: "Hlavna" },
{ city: "Bratislava", street: "Hrad" },
],
});
});
});
});
43 changes: 43 additions & 0 deletions src/components/list-field/listFieldBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { FieldAtom, FormFieldValues, FormFields } from "form-atoms";
import { ExtractAtomValue } from "jotai";

type FieldAtomValue<T extends FieldAtom<any>> = ExtractAtomValue<
ExtractAtomValue<T>["value"]
>;

type ListFieldItems = FieldAtom<any> | FormFields;

type ListFieldValue<T extends ListFieldItems> = T extends FieldAtom<any>
? FieldAtomValue<T>
: T extends FormFields
? FormFieldValues<T>
: never;

// actual type must be one of overloads, as this one is ignored
export function listFieldBuilder<
Fields extends ListFieldItems,
Value = ListFieldValue<Fields>,
>(builder: (value: Value) => Fields) {
let emptyValue: undefined | Value = undefined;
try {
// test if builder is 'atomBuilder', e.g. returns plain atom
// @ts-expect-error this is a test call
builder(undefined);
} catch {
// builder is 'fieldsBuilder', e.g. it returns Record<string, fieldAtom>
emptyValue = {} as Value;
}

function buildFields(): Fields;
function buildFields(data: ListFieldValue<Fields>[]): Fields[];
function buildFields(data?: ListFieldValue<Fields>[]) {
if (data) {
return data.map(builder);
} else {
// @ts-expect-error empty call
return builder(emptyValue);
}
}

return buildFields;
}

0 comments on commit 35d19aa

Please sign in to comment.