Skip to content

Commit

Permalink
feat(filter-bar): allow consumer to open a filter from another filter…
Browse files Browse the repository at this point in the history
…'s action (#3884)

* fix(filter-bar): prevent infinite loop when opening a filter through selecting a value

* refactor(filter-bar): tighten state type for values/isUsable to no longer be null

* docs(filter-bar): add docs for open filter through another filter

* refactor(filter-bar): rename toggleOpenFilter (deprecated) to setFilterOpenState

* feat(filter-bar): add context util openFilter
  • Loading branch information
HeartSquared authored Aug 10, 2023
1 parent 049cc68 commit 039a457
Show file tree
Hide file tree
Showing 24 changed files with 428 additions and 160 deletions.
9 changes: 9 additions & 0 deletions .changeset/chilled-beans-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@kaizen/components": minor
---

#### FilterBar

- Prevent infinite loop when calling `toggleOpenFilter` from selecting a value.
- Deprecate `toggleOpenFilter`, and replace with `setFilterOpenState` for clearer function intent.
- Add context util `openFilter` for consumers to be able to open a filter through an event from another filter.
305 changes: 190 additions & 115 deletions packages/components/src/FilterBar/FilterBar.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,12 +125,15 @@ const FilterBarWrapper = <T extends FiltersValues>({
)

return (
<FilterBar<T>
filters={filters}
values={activeValues}
onValuesChange={setActiveValues}
{...customProps}
/>
<>
<FilterBar<T>
filters={filters}
values={activeValues}
onValuesChange={setActiveValues}
{...customProps}
/>
<div data-testid="testid__values">{JSON.stringify(activeValues)}</div>
</>
)
}

Expand Down Expand Up @@ -270,6 +273,20 @@ describe("<FilterBar />", () => {
).not.toBeInTheDocument()
expect(getByRole("button", { name: "Add Filters" })).toBeDisabled()
})

it("clears the value if the filter is not usable", () => {
const { getByTestId } = render(
<FilterBarWrapper
filters={filtersDependent}
defaultValues={{
topping: "pearls",
}}
/>
)
expect(getByTestId("testid__values").textContent).toEqual(
JSON.stringify({})
)
})
})

describe("Condition met", () => {
Expand Down Expand Up @@ -756,134 +773,192 @@ describe("<FilterBar />", () => {
})

describe("Context use cases", () => {
type Items = Array<{ value: string; label: string }>

type AsyncValues = {
city: string[]
hero: string[]
}

const MockFilterAsyncComponent = ({
id,
fetcher,
}: {
id: string
fetcher: (args: Partial<AsyncValues>) => Promise<Items>
}): JSX.Element => {
const [items, setItems] = useState<Items>([])
const { getActiveFilterValues } = useFilterBarContext()
const activeFilterVals = getActiveFilterValues()

useEffect(() => {
fetcher(activeFilterVals).then(fetchedItems => {
if (JSON.stringify(fetchedItems) !== JSON.stringify(items)) {
setItems(fetchedItems)
}
})
}, [JSON.stringify(activeFilterVals)])

return (
<FilterBar.MultiSelect id={id} items={items}>
{() => (
<FilterMultiSelect.ListBox>
{({ allItems }) => (
<FilterMultiSelect.ListBoxSection
items={allItems}
sectionName="All Items"
>
{item => (
<FilterMultiSelect.Option key={item.key} item={item} />
)}
</FilterMultiSelect.ListBoxSection>
)}
</FilterMultiSelect.ListBox>
)}
</FilterBar.MultiSelect>
)
}

const fetchCityOptions = jest.fn((filterValues: Partial<AsyncValues>) => {
const isSupermanInFilterValue = filterValues.hero?.includes("superman")
const isBatmanInFilterValue = filterValues.hero?.includes("batman")
describe("getActiveFilterValues()", () => {
type Items = Array<{ value: string; label: string }>

if (isBatmanInFilterValue && !isSupermanInFilterValue) {
return Promise.resolve([{ value: "gotham", label: "Gotham" }])
type AsyncValues = {
city: string[]
hero: string[]
}

return Promise.resolve([
{ value: "gotham", label: "Gotham" },
{ value: "metro", label: "Metropolis" },
])
})

const fetchHeroOptions = jest.fn((filterValues: Partial<AsyncValues>) => {
const isGothamInFilterValue = filterValues.city?.includes("gotham")
const isMetroInFilterValue = filterValues.city?.includes("metro")
const MockFilterAsyncComponent = ({
id,
fetcher,
}: {
id: string
fetcher: (args: Partial<AsyncValues>) => Promise<Items>
}): JSX.Element => {
const [items, setItems] = useState<Items>([])
const { getActiveFilterValues } = useFilterBarContext()
const activeFilterVals = getActiveFilterValues()

useEffect(() => {
fetcher(activeFilterVals).then(fetchedItems => {
if (JSON.stringify(fetchedItems) !== JSON.stringify(items)) {
setItems(fetchedItems)
}
})
}, [JSON.stringify(activeFilterVals)])

if (isGothamInFilterValue && !isMetroInFilterValue) {
return Promise.resolve([{ value: "batman", label: "Batman" }])
return (
<FilterBar.MultiSelect id={id} items={items}>
{() => (
<FilterMultiSelect.ListBox>
{({ allItems }) => (
<FilterMultiSelect.ListBoxSection
items={allItems}
sectionName="All Items"
>
{item => (
<FilterMultiSelect.Option key={item.key} item={item} />
)}
</FilterMultiSelect.ListBoxSection>
)}
</FilterMultiSelect.ListBox>
)}
</FilterBar.MultiSelect>
)
}

return Promise.resolve([
{ value: "superman", label: "Superman" },
{ value: "batman", label: "Batman" },
])
})
const fetchCityOptions = jest.fn((filterValues: Partial<AsyncValues>) => {
const isSupermanInFilterValue = filterValues.hero?.includes("superman")
const isBatmanInFilterValue = filterValues.hero?.includes("batman")

const config = [
{
id: "city",
name: "City",
Component: (
<MockFilterAsyncComponent id="city" fetcher={fetchCityOptions} />
),
},
{
id: "hero",
name: "Hero",
Component: (
<MockFilterAsyncComponent id="Hero" fetcher={fetchHeroOptions} />
),
},
] satisfies Filters<AsyncValues>

it("can re-fetch options with all active filter values pulled off of the FilterBarContext", async () => {
const { getByRole, queryByRole } = render(
<FilterBarWrapper<AsyncValues> filters={config} defaultValues={{}} />
)
if (isBatmanInFilterValue && !isSupermanInFilterValue) {
return Promise.resolve([{ value: "gotham", label: "Gotham" }])
}

return Promise.resolve([
{ value: "gotham", label: "Gotham" },
{ value: "metro", label: "Metropolis" },
])
})

await user.click(getByRole("button", { name: "City" }))
const fetchHeroOptions = jest.fn((filterValues: Partial<AsyncValues>) => {
const isGothamInFilterValue = filterValues.city?.includes("gotham")
const isMetroInFilterValue = filterValues.city?.includes("metro")

await waitFor(() => {
expect(getByRole("option", { name: "Gotham" })).toBeVisible()
expect(getByRole("option", { name: "Metropolis" })).toBeVisible()
if (isGothamInFilterValue && !isMetroInFilterValue) {
return Promise.resolve([{ value: "batman", label: "Batman" }])
}

return Promise.resolve([
{ value: "superman", label: "Superman" },
{ value: "batman", label: "Batman" },
])
})

await user.click(getByRole("option", { name: "Gotham" }))
const config = [
{
id: "city",
name: "City",
Component: (
<MockFilterAsyncComponent id="city" fetcher={fetchCityOptions} />
),
},
{
id: "hero",
name: "Hero",
Component: (
<MockFilterAsyncComponent id="Hero" fetcher={fetchHeroOptions} />
),
},
] satisfies Filters<AsyncValues>

it("can re-fetch options with all active filter values pulled off of the FilterBarContext", async () => {
const { getByRole, queryByRole } = render(
<FilterBarWrapper<AsyncValues> filters={config} defaultValues={{}} />
)

// close city filter
await user.click(document.body)
await user.click(getByRole("button", { name: "City" }))

await user.click(getByRole("button", { name: "Hero" }))
await waitFor(() => {
expect(getByRole("option", { name: "Gotham" })).toBeVisible()
expect(getByRole("option", { name: "Metropolis" })).toBeVisible()
})

await waitFor(() => {
expect(getByRole("option", { name: "Batman" })).toBeVisible()
expect(
queryByRole("option", { name: "Superman" })
).not.toBeInTheDocument()
await user.click(getByRole("option", { name: "Gotham" }))

// close city filter
await user.click(document.body)

await user.click(getByRole("button", { name: "Hero" }))

await waitFor(() => {
expect(getByRole("option", { name: "Batman" })).toBeVisible()
expect(
queryByRole("option", { name: "Superman" })
).not.toBeInTheDocument()
})

await user.click(getByRole("option", { name: "Batman" }))

await user.click(document.body)

await user.click(getByRole("button", { name: "City : Gotham" }))

await waitFor(() => {
expect(getByRole("option", { name: "Gotham" })).toBeVisible()
expect(
queryByRole("option", { name: "Metropolis" })
).not.toBeInTheDocument()
})
})
})

await user.click(getByRole("option", { name: "Batman" }))
describe("openFilter()", () => {
type CycleFilterValues = {
cycle: string
customDate: Date
}

await user.click(document.body)
const CycleFilter = ({ id }: { id?: string }): JSX.Element => {
const { openFilter } = useFilterBarContext<string, CycleFilterValues>()

await user.click(getByRole("button", { name: "City : Gotham" }))
return (
<FilterBar.Select
id={id}
items={[{ value: "custom", label: "Custom Date" }]}
onSelectionChange={key => {
if (key === "custom") openFilter("customDate")
}}
/>
)
}

await waitFor(() => {
expect(getByRole("option", { name: "Gotham" })).toBeVisible()
expect(
queryByRole("option", { name: "Metropolis" })
).not.toBeInTheDocument()
const cycleFilters = [
{
id: "cycle",
name: "Cycle",
Component: <CycleFilter />,
},
{
id: "customDate",
name: "Custom Date",
Component: <FilterBar.DatePicker />,
},
] satisfies Filters<CycleFilterValues>

it("opens the Custom Date filter when Cycle's 'custom' value is selected", async () => {
const { getByRole } = render(
<FilterBarWrapper<CycleFilterValues> filters={cycleFilters} />
)

const customDateButton = getByRole("button", { name: "Custom Date" })
expect(customDateButton).toHaveAttribute("aria-expanded", "false")

await user.click(getByRole("button", { name: "Cycle" }))

const customDateOption = getByRole("option", { name: "Custom Date" })
await waitFor(() => {
expect(customDateOption).toBeVisible()
})

await user.click(customDateOption)

await waitFor(() => {
expect(customDateButton).toHaveAttribute("aria-expanded", "true")
})
})
})
})
Expand Down
Loading

0 comments on commit 039a457

Please sign in to comment.