From 2d0f9e0cc4df787800bcf82eee626227031ee45e Mon Sep 17 00:00:00 2001 From: Edwar Plata Date: Tue, 3 Oct 2023 12:31:34 -0500 Subject: [PATCH] feat: bound fields --- src/forms/BoundIconCardField.test.tsx | 58 ++++++++++++++++++++++ src/forms/BoundIconCardField.tsx | 39 +++++++++++++++ src/forms/BoundIconCardGroupField.test.tsx | 41 +++++++++++++++ src/forms/BoundIconCardGroupField.tsx | 36 ++++++++++++++ src/inputs/IconCard.tsx | 2 +- 5 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 src/forms/BoundIconCardField.test.tsx create mode 100644 src/forms/BoundIconCardField.tsx create mode 100644 src/forms/BoundIconCardGroupField.test.tsx create mode 100644 src/forms/BoundIconCardGroupField.tsx diff --git a/src/forms/BoundIconCardField.test.tsx b/src/forms/BoundIconCardField.test.tsx new file mode 100644 index 000000000..732ef7a20 --- /dev/null +++ b/src/forms/BoundIconCardField.test.tsx @@ -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 = { + isAvailable: { type: "value", rules: [required] }, +}; + +const formConfigReadOnly: ObjectConfig = { + 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(); + // 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(); + // 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 = createObjectState( + formConfig, + { isAvailable: true }, + { maybeAutoSave: () => autoSave(formState.isAvailable.value) }, + ); + const r = await render(); + + // 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 = createObjectState(formConfigReadOnly, { isAvailable: true }); + const r = await render(); + + // Then the checkbox should be disabled + expect(r.isAvailable).toBeDisabled(); + }); +}); diff --git a/src/forms/BoundIconCardField.tsx b/src/forms/BoundIconCardField.tsx new file mode 100644 index 000000000..4ba82045e --- /dev/null +++ b/src/forms/BoundIconCardField.tsx @@ -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 & { + field: FieldState; + 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 ( + + {() => ( + { + // We are triggering blur manually for checkbox fields due to its transactional nature + onChange(selected); + field.maybeAutoSave(); + }} + disabled={field.readOnly} + {...testId} + {...others} + /> + )} + + ); +} diff --git a/src/forms/BoundIconCardGroupField.test.tsx b/src/forms/BoundIconCardGroupField.test.tsx new file mode 100644 index 000000000..48503dbe1 --- /dev/null +++ b/src/forms/BoundIconCardGroupField.test.tsx @@ -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(); + 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 = createObjectState( + formConfig, + {}, + { maybeAutoSave: () => autoSave(author.favoriteGenres.value) }, + ); + const r = await render(); + + // 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 = { + favoriteGenres: { type: "value", rules: [required], strictOrder: false }, +}; diff --git a/src/forms/BoundIconCardGroupField.tsx b/src/forms/BoundIconCardGroupField.tsx new file mode 100644 index 000000000..928462abe --- /dev/null +++ b/src/forms/BoundIconCardGroupField.tsx @@ -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 & { + field: FieldState; + /** 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 ( + + {() => ( + { + // We are triggering blur manually for checkbox fields due to its transactional nature + onChange(values); + field.maybeAutoSave(); + }} + disabled={field.readOnly} + {...testId} + {...others} + /> + )} + + ); +} diff --git a/src/inputs/IconCard.tsx b/src/inputs/IconCard.tsx index c0fdcc2a5..e62e807ea 100644 --- a/src/inputs/IconCard.tsx +++ b/src/inputs/IconCard.tsx @@ -53,7 +53,7 @@ export function IconCard(props: IconCardProps) { title: resolveTooltip(isDisabled, tooltip), placement: "top", children: ( -