Skip to content

Commit

Permalink
feat: bound fields
Browse files Browse the repository at this point in the history
  • Loading branch information
0ces committed Oct 3, 2023
1 parent 01ff949 commit 2d0f9e0
Show file tree
Hide file tree
Showing 5 changed files with 175 additions and 1 deletion.
58 changes: 58 additions & 0 deletions src/forms/BoundIconCardField.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { ObjectConfig, ObjectState, createObjectState, required } from "@homebound/form-state";
import { BoundIconCardField } from "./BoundIconCardField";
import { click, render } from "src/utils/rtl";
import { AuthorInput } from "./formStateDomain";

const formConfig: ObjectConfig<AuthorInput> = {
isAvailable: { type: "value", rules: [required] },
};

const formConfigReadOnly: ObjectConfig<AuthorInput> = {
isAvailable: { type: "value", rules: [required], readOnly: true },
};

describe("BoundIconCardField", () => {
it("should be selected", async () => {
// Given a formState with true boolean field
const formState = createObjectState(formConfig, { isAvailable: true });
// When rendered
const r = await render(<BoundIconCardField icon="check" field={formState.isAvailable} />);
// Then the BoundCheckboxField should be checked
expect(r.isAvailable_value).toBeChecked();
});

it("should uncheck when clicked", async () => {
// Given a rendered checked BoundCheckboxField
const formState = createObjectState(formConfig, { isAvailable: true });
const r = await render(<BoundIconCardField icon="check" field={formState.isAvailable} />);
// When interacting with a BoundCheckboxField
click(r.isAvailable);
// Then expect the checkbox to be unchecked and the formState to reflect that state
expect(r.isAvailable).not.toBeChecked();
expect(formState.isAvailable.value).toBeFalsy();
});

it("triggers 'maybeAutoSave' on change", async () => {
const autoSave = jest.fn();
// Given a BoundCheckboxField with auto save
const formState: ObjectState<AuthorInput> = createObjectState(
formConfig,
{ isAvailable: true },
{ maybeAutoSave: () => autoSave(formState.isAvailable.value) },
);
const r = await render(<BoundIconCardField icon="check" field={formState.isAvailable} />);

// When toggling the checkbox off
click(r.isAvailable);
// Then the callback should be triggered with the current value
expect(autoSave).toBeCalledWith(false);
});
it("disables when field is readonly", async () => {
// Given a readOnly BoundCheckboxField
const formState: ObjectState<AuthorInput> = createObjectState(formConfigReadOnly, { isAvailable: true });
const r = await render(<BoundIconCardField icon="check" field={formState.isAvailable} />);

// Then the checkbox should be disabled
expect(r.isAvailable).toBeDisabled();
});
});
39 changes: 39 additions & 0 deletions src/forms/BoundIconCardField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { FieldState } from "@homebound/form-state";
import { IconCard, IconCardProps } from "src/inputs";
import { defaultLabel } from "src/utils/defaultLabel";
import { useTestIds } from "src/utils";
import { Observer } from "mobx-react";
import { IconProps } from "src/components";

export type BoundIconCardFieldProps = Omit<IconCardProps, "label" | "selected" | "onChange"> & {
field: FieldState<boolean | null | undefined>;
icon: IconProps["icon"];
/** Make optional so that callers can override if they want to. */
onChange?: (values: boolean) => void;
label?: string;
};

/** Wraps `IconCard` and binds it to a form field. */
export function BoundIconCardField(props: BoundIconCardFieldProps) {
const { icon, field, onChange = (value) => field.set(value), label = defaultLabel(field.key), ...others } = props;
const testId = useTestIds(props, field.key);
return (
<Observer>
{() => (
<IconCard
icon={icon}
label={label}
selected={field.value ?? false}
onChange={(selected) => {
// We are triggering blur manually for checkbox fields due to its transactional nature
onChange(selected);
field.maybeAutoSave();
}}
disabled={field.readOnly}
{...testId}
{...others}
/>
)}
</Observer>
);
}
41 changes: 41 additions & 0 deletions src/forms/BoundIconCardGroupField.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { IconCardGroupItemOption } from "src/inputs/IconCardGroup";
import { AuthorInput } from "./formStateDomain";
import { ObjectConfig, ObjectState, createObjectState, required } from "@homebound/form-state";
import { BoundIconCardGroupField } from "./BoundIconCardGroupField";
import { click, render } from "src/utils/rtl";

const categories: IconCardGroupItemOption[] = [
{ icon: "abacus", label: "Math", value: "math" },
{ icon: "archive", label: "History", value: "history" },
{ icon: "dollar", label: "Finance", value: "finance" },
{ icon: "hardHat", label: "Engineering", value: "engineering" },
{ icon: "kanban", label: "Management", value: "management" },
{ icon: "camera", label: "Media", value: "media" },
];

describe("BoundIconCardGroupField", () => {
it("shows the label", async () => {
const author = createObjectState(formConfig, { favoriteGenres: ["math"] });
const r = await render(<BoundIconCardGroupField field={author.favoriteGenres} options={categories} />);
expect(r.favoriteGenres_label).toHaveTextContent("Favorite Genres");
});
it("triggers 'maybeAutoSave' on change", async () => {
const autoSave = jest.fn();
// Given a BoundIconCardGroupField with auto save
const author: ObjectState<AuthorInput> = createObjectState(
formConfig,
{},
{ maybeAutoSave: () => autoSave(author.favoriteGenres.value) },
);
const r = await render(<BoundIconCardGroupField field={author.favoriteGenres} options={categories} />);

// When toggling the checkbox off
click(r.favoriteGenres_math);
// Then the callback should be triggered with the current value
expect(autoSave).toBeCalledWith(["math"]);
});
});

const formConfig: ObjectConfig<AuthorInput> = {
favoriteGenres: { type: "value", rules: [required], strictOrder: false },
};
36 changes: 36 additions & 0 deletions src/forms/BoundIconCardGroupField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { FieldState } from "@homebound/form-state";
import { Observer } from "mobx-react";
import { IconCardGroup, IconCardGroupProps } from "src/inputs/IconCardGroup";
import { useTestIds } from "src/utils";
import { defaultLabel } from "src/utils/defaultLabel";

export type BoundIconCardGroupFieldProps = Omit<IconCardGroupProps, "label" | "values" | "onChange"> & {
field: FieldState<string[] | null | undefined>;
/** Make optional so that callers can override if they want to. */
onChange?: (values: string[]) => void;
label?: string;
};

/** Wraps `IconCardGroup` and binds it to a form field. */
export function BoundIconCardGroupField(props: BoundIconCardGroupFieldProps) {
const { field, onChange = (value) => field.set(value), label = defaultLabel(field.key), ...others } = props;
const testId = useTestIds(props, field.key);
return (
<Observer>
{() => (
<IconCardGroup
label={label}
values={field.value || []}
onChange={(values) => {
// We are triggering blur manually for checkbox fields due to its transactional nature
onChange(values);
field.maybeAutoSave();
}}
disabled={field.readOnly}
{...testId}
{...others}
/>
)}
</Observer>
);
}
2 changes: 1 addition & 1 deletion src/inputs/IconCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function IconCard(props: IconCardProps) {
title: resolveTooltip(isDisabled, tooltip),
placement: "top",
children: (
<button css={styles} {...hoverProps} onClick={isDisabled ? noop : toggleState.toggle} {...tid}>
<button css={styles} {...hoverProps} onClick={toggleState.toggle} disabled={isDisabled} {...tid}>
<VisuallyHidden>
<input ref={ref} {...inputProps} {...tid.value} />
</VisuallyHidden>
Expand Down

0 comments on commit 2d0f9e0

Please sign in to comment.