Skip to content

Commit

Permalink
fix: IconCardGroup generic typing (#976)
Browse files Browse the repository at this point in the history
This is a fix to be able to use the IconCardGroup with enum values
  • Loading branch information
0ces authored Nov 30, 2023
1 parent d3424d2 commit 69ca386
Show file tree
Hide file tree
Showing 4 changed files with 108 additions and 46 deletions.
35 changes: 23 additions & 12 deletions src/forms/BoundIconCardGroupField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,49 @@ import { ObjectConfig, ObjectState, createObjectState, required } from "@homebou
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" },
enum Category {
Math,
History,
Finance,
Engineering,
Management,
Media,
}

const categories: IconCardGroupItemOption<Category>[] = [
{ icon: "abacus", label: "Math", value: Category.Math },
{ icon: "archive", label: "History", value: Category.History },
{ icon: "dollar", label: "Finance", value: Category.Finance },
{ icon: "hardHat", label: "Engineering", value: Category.Engineering },
{ icon: "kanban", label: "Management", value: Category.Management },
{ icon: "camera", label: "Media", value: Category.Media },
];

type NewAuthor = Omit<AuthorInput, "favoriteGenres"> & { favoriteGenres?: Category[] | null };

describe("BoundIconCardGroupField", () => {
it("shows the label", async () => {
const author = createObjectState(formConfig, { favoriteGenres: ["math"] });
const author = createObjectState(formConfig, { favoriteGenres: [Category.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(
const author: ObjectState<NewAuthor> = 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);
click(r.favoriteGenres_Math);
// Then the callback should be triggered with the current value
expect(autoSave).toBeCalledWith(["math"]);
expect(autoSave).toBeCalledWith([Category.Math]);
});
});

const formConfig: ObjectConfig<AuthorInput> = {
const formConfig: ObjectConfig<NewAuthor> = {
favoriteGenres: { type: "value", rules: [required], strictOrder: false },
};
12 changes: 8 additions & 4 deletions src/forms/BoundIconCardGroupField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@ import { Observer } from "mobx-react";
import { IconCardGroup, IconCardGroupProps } from "src/inputs/IconCardGroup";
import { useTestIds } from "src/utils";
import { defaultLabel } from "src/utils/defaultLabel";
import { Value } from "src/inputs";

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

/** Wraps `IconCardGroup` and binds it to a form field. */
export function BoundIconCardGroupField(props: BoundIconCardGroupFieldProps) {
export function BoundIconCardGroupField<V extends Value>(props: BoundIconCardGroupFieldProps<V>) {
const { field, onChange = (value) => field.set(value), label = defaultLabel(field.key), ...others } = props;
const testId = useTestIds(props, field.key);
return (
Expand Down
31 changes: 20 additions & 11 deletions src/inputs/IconCardGroup.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,37 @@
import { Meta } from "@storybook/react";
import { IconCardGroup, IconCardGroupItemOption, IconCardGroupProps } from "./IconCardGroup";
import { Chips } from "src/components";
import { useState } from "react";
import { Css } from "src/Css";

export default {
component: IconCardGroup,
} as Meta<IconCardGroupProps>;
} as Meta<IconCardGroupProps<Category>>;

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" },
enum Category {
Math,
History,
Finance,
Engineering,
Management,
Media,
Na,
}

const categories: IconCardGroupItemOption<Category>[] = [
{ icon: "abacus", label: "Math", value: Category.Math },
{ icon: "archive", label: "History", value: Category.History },
{ icon: "dollar", label: "Finance", value: Category.Finance },
{ icon: "hardHat", label: "Engineering", value: Category.Engineering },
{ icon: "kanban", label: "Management", value: Category.Management },
{ icon: "camera", label: "Media", value: Category.Media },
{ icon: "remove", label: "N/A", value: Category.Na, exclusive: true },
];

export function Default() {
const [values, setValues] = useState<string[]>(["math"]);
const [values, setValues] = useState<Category[]>([Category.Math]);

return (
<div>
<Chips values={values} />
<div css={Css.df.wPx(500).$}>
<IconCardGroup
label="Icon Card Group"
Expand Down
76 changes: 57 additions & 19 deletions src/inputs/IconCardGroup.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { ReactNode } from "react";
import { useCheckboxGroup } from "react-aria";
import { useCheckboxGroupState } from "react-stately";
import { ReactNode, useCallback, useMemo, useState } from "react";
import { Css } from "src/Css";
import { IconProps } from "src/components/Icon";
import { Label } from "src/components/Label";
Expand All @@ -9,29 +7,33 @@ import { useTestIds } from "src/utils";
import { IconCard } from "src/inputs/IconCard";
import { HelperText } from "src/components/HelperText";
import { ErrorMessage } from "./ErrorMessage";
import { Value } from "src/inputs";
import { mergeProps, useField } from "react-aria";

export interface IconCardGroupItemOption {
export interface IconCardGroupItemOption<V extends Value> {
icon: IconProps["icon"];
label: string;
disabled?: boolean;
/** The value of the IconCardGroup item, stored in value array in state. */
value: string;
value: V;
/** Exclusive: if true, this option will override all other options when selected. */
exclusive?: boolean;
}

export interface IconCardGroupProps extends Pick<PresentationFieldProps, "labelStyle"> {
export interface IconCardGroupProps<V extends Value> extends Pick<PresentationFieldProps, "labelStyle"> {
label: string;
/** Called when a card is selected */
onChange: (values: string[]) => void;
onChange: (values: V[]) => void;
/** Options for the cards contained within the IconCardGroup. */
options: IconCardGroupItemOption[];
options: IconCardGroupItemOption<V>[];
/** The values currently selected. */
values: string[];
values: V[];
errorMsg?: string;
helperText?: string | ReactNode;
disabled?: boolean;
}

export function IconCardGroup(props: IconCardGroupProps) {
export function IconCardGroup<V extends Value>(props: IconCardGroupProps<V>) {
const { fieldProps } = usePresentationContext();
const {
options,
Expand All @@ -41,14 +43,51 @@ export function IconCardGroup(props: IconCardGroupProps) {
errorMsg,
helperText,
disabled: isDisabled = false,
onChange,
} = props;

const state = useCheckboxGroupState({ ...props, isDisabled, value: values });
const { groupProps, labelProps } = useCheckboxGroup(props, state);
const [selected, setSelected] = useState<V[]>(values);

const exclusiveOptions = useMemo(() => options.filter((o) => o.exclusive), [options]);

const toggleValue = useCallback(
(value: V) => {
if (isDisabled) return;

const option = options.find((o) => o.value === value);
if (!option) return;

let newSelected: V[] = [];
if (selected.includes(value)) {
newSelected = selected.filter((v) => v !== value);
} else {
if (option.exclusive) {
newSelected = [value];
} else {
newSelected = [...selected, value];

// Filter out any exclusive options as a non-exclusive option was selected.
newSelected = newSelected.filter((v) => !exclusiveOptions.some((o) => o.value === v));
}
}
setSelected(newSelected);
onChange(newSelected);
},
[exclusiveOptions, isDisabled, onChange, options, selected],
);

const tid = useTestIds(props);

const { labelProps, fieldProps: fieldPropsAria } = useField(props);

const groupProps = mergeProps(tid, {
role: "group",
"aria-disabled": isDisabled || undefined,
...fieldPropsAria,
});

return (
<div {...groupProps} {...tid}>
<div {...groupProps}>
{labelStyle !== "hidden" && (
<div css={Css.if(labelStyle === "left").w50.$}>
<Label label={label} {...labelProps} {...tid.label} />
Expand All @@ -57,17 +96,16 @@ export function IconCardGroup(props: IconCardGroupProps) {
<div css={Css.df.gap2.add({ flexWrap: "wrap" }).$}>
{options.map((option) => {
const { icon, label, disabled } = option;
const isSelected = state.isSelected(option.value);
const isDisabled = disabled || state.isDisabled;
const isSelected = selected.includes(option.value);
return (
<IconCard
key={option.value}
key={option.label}
icon={icon}
label={label}
selected={isSelected}
disabled={isDisabled}
onChange={() => state.toggleValue(option.value)}
{...tid[option.value]}
disabled={disabled}
onChange={() => toggleValue(option.value)}
{...tid[option.label]}
/>
);
})}
Expand Down

0 comments on commit 69ca386

Please sign in to comment.