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

fix: IconCardGroup generic typing #976

Merged
merged 6 commits into from
Nov 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
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
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;
}
0ces marked this conversation as resolved.
Show resolved Hide resolved

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
Loading