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: Require init.input or init.query. #104

Merged
merged 4 commits into from
Dec 15, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
12 changes: 7 additions & 5 deletions src/FormStateApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ export function FormStateApp() {
config: formConfig,
// Simulate getting the initial form state back from a server call
init: {
firstName: "a1",
books: [...Array(2)].map((_, i) => ({
title: `b${i}`,
classification: { number: `10${i + 1}`, category: `Test Category ${i}` },
})),
input: {
firstName: "a1",
books: [...Array(2)].map((_, i) => ({
title: `b${i}`,
classification: { number: `10${i + 1}`, category: `Test Category ${i}` },
})),
},
},
addRules(state) {
state.lastName.rules.push(() => {
Expand Down
41 changes: 4 additions & 37 deletions src/useFormState.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,39 +106,6 @@ describe("useFormState", () => {
expect(r.changedValue.textContent).toEqual(JSON.stringify({ firstName: "default" }));
});

it("uses init if set as a value", async () => {
// Given a component
type FormValue = Pick<AuthorInput, "firstName">;
const config: ObjectConfig<FormValue> = { firstName: { type: "value" } };
function TestComponent() {
const [, setTick] = useState(0);
const form = useFormState({
config,
// That's using a raw init value
init: { firstName: "bob" },
});
return (
<div>
<button
data-testid="change"
onClick={() => {
// When that value changes
form.firstName.set("fred");
// And also we re-render the component
setTick(1);
}}
/>
<div data-testid="firstName">{form.firstName.value}</div>
</div>
);
}
const r = await render(<TestComponent />);
expect(r.firstName).toHaveTextContent("bob");
click(r.change);
// Then the change didn't get dropped due to init being unstable
expect(r.firstName).toHaveTextContent("fred");
});

it("doesn't required an init value", async () => {
function TestComponent() {
type FormValue = Pick<AuthorInput, "firstName">;
Expand Down Expand Up @@ -284,7 +251,7 @@ describe("useFormState", () => {
const [data, setData] = useState<FormValue>(data1);
const form = useFormState({
config,
init: { input: data, map: (d) => d },
init: { input: data },
// And the form is read only
readOnly: true,
});
Expand Down Expand Up @@ -458,7 +425,7 @@ describe("useFormState", () => {
const data = { firstName: "f1", lastName: "f1" };
const form = useFormState({
config,
init: data,
init: { input: data, map: (d) => d },
// And there is reactive business logic in the `autoSave` method
async autoSave(state) {
state.lastName.set("l2");
Expand Down Expand Up @@ -487,7 +454,7 @@ describe("useFormState", () => {
const data = { firstName: "f1", lastName: "f1" };
const form = useFormState({
config,
init: data,
init: { input: data, map: (d) => d },
autoSave: (form) => autoSave(form.changedValue),
});
return (
Expand Down Expand Up @@ -602,7 +569,7 @@ describe("useFormState", () => {
);
},
autoSave: (fs) => autoSaveStub(fs.changedValue),
init: { id: "a:1" },
init: { input: { id: "a:1" }, map: (d) => d },
});
return <TextField field={fs.firstName} />;
}
Expand Down
10 changes: 8 additions & 2 deletions src/useFormState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export type Query<I> = { data: I; loading: boolean; error?: any };

export type InputAndMap<T, I> = {
input: I;
map: (input: Exclude<I, null | undefined>) => T;
map?: (input: Exclude<I, null | undefined>) => T;
ifUndefined?: T;
};

Expand All @@ -19,6 +19,12 @@ export type QueryAndMap<T, I> = {
ifUndefined?: T;
};

/**
* The opts has for `useFormState`.
*
* @typeparam T the form type, which is usually as close as possible to your *GraphQL input*
* @typeparam I the *form input* type, which is usually the *GraphQL output* type, i.e. the type of the response from your GraphQL query
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TylerR909 per your request. Fwiw I acknowledge the "form input type is the graphql output type" and "graphql input type is the form type" is awkward, but it seems to fundamentally step from:

  • GraphQL output type ==> form "input" ==> form "output" ==> GraphQL input

Open to better ways of naming/articulating this.

*/
export type UseFormStateOpts<T, I> = {
/** The form configuration, should be a module-level const or useMemo'd. */
config: ObjectConfig<T>;
Expand All @@ -40,7 +46,7 @@ export type UseFormStateOpts<T, I> = {
* only call `init.map` if it's set, otherwise we'll use `init.ifDefined` or `{}`, saving you
* from having to null check within your `init.map` function.
*/
init?: T | InputAndMap<T, I> | QueryAndMap<T, I>;
init?: InputAndMap<T, I> | QueryAndMap<T, I>;

/**
* A hook to add custom, cross-field validation rules that can be difficult to setup directly in the config DSL.
Expand Down
8 changes: 5 additions & 3 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,19 @@ export function assertNever(x: never): never {
export function initValue<T>(config: ObjectConfig<T>, init: any): T {
let value: any;
if (isInput(init)) {
value = init.input ? init.map(init.input) : init.ifUndefined;
value = init.input ? (init.map ? init.map(init.input) : init.input) : init.ifUndefined;
} else if (isQuery(init)) {
value = init.query.data ? init.map(init.query.data) : init.ifUndefined;
} else if (init === undefined) {
// allow completely undefined init
} else {
value = init;
throw new Error("init must have an input or query key");
}
return pickFields(config, value ?? {}) as T;
}

export function isInput<T, I>(init: UseFormStateOpts<T, I>["init"]): init is InputAndMap<T, I> {
return !!init && typeof init === "object" && "input" in init && "map" in init;
return !!init && typeof init === "object" && "input" in init;
}

export function isQuery<T, I>(init: UseFormStateOpts<T, I>["init"]): init is QueryAndMap<T, I> {
Expand Down
Loading