diff --git a/src/components/filters/category-filter.js b/src/components/filters/category-filter.js index 4d65c8c30d9f..351710844d09 100644 --- a/src/components/filters/category-filter.js +++ b/src/components/filters/category-filter.js @@ -1,105 +1,11 @@ -import React, { useEffect } from "react" -import styled from "styled-components" -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" - -import Title from "./title" +import React from "react" import prettyCategory from "../util/pretty-category" -import { getQueryParams, useQueryParamString } from "react-use-query-param-string" - -const Element = styled.div` - padding-top: 36px; - display: flex; - flex-direction: column; - justify-content: flex-start; - align-items: flex-start; -` - -const Category = styled.li` - font-size: var(--font-size-16); - color: var(--main-text-color); - display: flex; - padding: 0; - gap: 8px; -` - -const Categories = styled.ul` - list-style: none; - padding: 0; - margin: 10px; - line-height: 23px; -` - -const TickyBox = styled(props => )` - font-size: 16px; - color: var(--main-text-color); -` - -const separator = "," +import TickyFilter from "./ticky-filter" -const toggleCategory = ( - category, - tickedCategories, - setTickedCategories, - filterer -) => { - if (tickedCategories.includes(category)) { - tickedCategories = tickedCategories.filter(item => item !== category) - } else { - tickedCategories = [...tickedCategories, category] // It's important to make a new array or nothing will be re-rendered - } - if (tickedCategories.length > 0) { - setTickedCategories(tickedCategories?.join(separator)) - } else { - // Clear this filter from the URL bar if there's nothing in it - setTickedCategories(undefined) - } - filterer && filterer(tickedCategories) -} - -const key = "categories" const CategoryFilter = ({ categories, filterer }) => { - const [stringedTickedCategories, setTickedCategories, initialized] = useQueryParamString(key, undefined, true) - const realStringedTickedCategories = initialized ? stringedTickedCategories : getQueryParams() ? getQueryParams()[key] : undefined - - const tickedCategories = stringedTickedCategories ? stringedTickedCategories.split(separator) : [] - - const onClick = category => () => - toggleCategory( - category, - tickedCategories, - setTickedCategories, - filterer - ) - - useEffect(() => { // Make sure that even if the url is pasted in a browser, the list updates with the right value - if (realStringedTickedCategories && realStringedTickedCategories.length > 0) { - filterer(realStringedTickedCategories.split(separator)) - } - }, [realStringedTickedCategories], filterer) - return ( - categories && - Category - - {categories && - categories.map(category => ( - - - {tickedCategories.includes(category) ? ( - - ) : ( - - )} - - {prettyCategory(category)} - - ))} - - + categories && ) } diff --git a/src/components/filters/ticky-filter.js b/src/components/filters/ticky-filter.js new file mode 100644 index 000000000000..5f1d99c04b49 --- /dev/null +++ b/src/components/filters/ticky-filter.js @@ -0,0 +1,109 @@ +import React, { useEffect } from "react" +import styled from "styled-components" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" + +import Title from "./title" +import { getQueryParams, useQueryParamString } from "react-use-query-param-string" + +const Element = styled.div` + padding-top: 36px; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +` + +const Entry = styled.li` + font-size: var(--font-size-16); + color: var(--main-text-color); + display: flex; + padding: 0; + gap: 8px; +` + +const Entries = styled.ul` + list-style: none; + padding: 0; + margin: 10px; + line-height: 23px; +` + +const TickyBox = styled(props => )` + font-size: 16px; + color: var(--main-text-color); +` + +const separator = "," +const noop = a => a + +const toggleEntry = ( + entry, + tickedEntries, + setTickedEntries, + filterer +) => { + if (tickedEntries.includes(entry)) { + tickedEntries = tickedEntries.filter(item => item !== entry) + } else { + tickedEntries = [...tickedEntries, entry] // It's important to make a new array or nothing will be re-rendered + } + if (tickedEntries.length > 0) { + setTickedEntries(tickedEntries?.join(separator)) + } else { + // Clear this filter from the URL bar if there's nothing in it + setTickedEntries(undefined) + } + filterer && filterer(tickedEntries) +} + +const TickyFilter = ({ entries, filterer, prettify, label, queryKey }) => { + const key = queryKey || label.toLowerCase().replace(" ", "-") + + const [stringedTickedEntries, setTickedEntries, initialized] = useQueryParamString(key, undefined, true) + const realStringedTickedEntries = initialized ? stringedTickedEntries : getQueryParams() ? getQueryParams()[key] : undefined + + const tickedEntries = stringedTickedEntries ? stringedTickedEntries.split(separator) : [] + + prettify = prettify || noop + + const onClick = entry => () => + toggleEntry( + entry, + tickedEntries, + setTickedEntries, + filterer + ) + + useEffect(() => { // Make sure that even if the url is pasted in a browser, the list updates with the right value + if (realStringedTickedEntries && realStringedTickedEntries.length > 0) { + filterer(realStringedTickedEntries.split(separator)) + } + }, [realStringedTickedEntries, filterer], filterer) + + return ( + entries && + {label} + + {entries && + entries.map(entry => ( + + + {tickedEntries.includes(entry) ? ( + + ) : ( + + )} + + {prettify(entry)} + + ))} + + + ) +} + +export default TickyFilter diff --git a/src/components/filters/ticky-filter.test.js b/src/components/filters/ticky-filter.test.js new file mode 100644 index 000000000000..a4ee25068480 --- /dev/null +++ b/src/components/filters/ticky-filter.test.js @@ -0,0 +1,260 @@ +import React from "react" +import { render, screen } from "@testing-library/react" +import { useQueryParamString } from "react-use-query-param-string" +import userEvent from "@testing-library/user-event" +import TickyFilter from "./ticky-filter" + +let mockQueryParamSearchString = undefined +let rerender = undefined + +jest.mock("react-use-query-param-string", () => { + + const original = jest.requireActual("react-use-query-param-string") + const setQueryParam = jest.fn().mockImplementation((val) => { + mockQueryParamSearchString = val + }) + return { + ...original, + useQueryParamString: jest.fn().mockImplementation(() => [mockQueryParamSearchString, setQueryParam, true]), + } +}) + +describe("ticky filter", () => { + const entries = ["toad", "tadpole", "treefrog"] + + describe("when there is a prettifier", () => { + + let user + const filterer = jest.fn(() => { + // cheat, since normally the parent will force a rerender, and the child does not use usestate to avoid infinite loops + if (rerender) { + try { + rerender() + } catch (e) { + // This can happen if the component is already unmounted + } + } + }) + + beforeEach(() => { + user = userEvent.setup() + mockQueryParamSearchString = undefined + const products = render( a.toUpperCase()} />) + rerender = () => { + products.rerender() + } + }) + + it("renders a entries title", () => { + expect(screen.getByText("Arbitrary Title")).toBeTruthy() + }) + + it("renders prettified individual entry names", () => { + expect(screen.getByText("TOAD")).toBeTruthy() + expect(screen.getByText("TADPOLE")).toBeTruthy() + }) + + it("renders tickboxes", () => { + expect(screen.getAllByTitle("unticked")).toHaveLength(entries.length) + }) + describe("when clicking a ticky box", () => { + const entryName = "TREEFROG" + beforeEach(async () => { + await user.click(screen.getByText(entryName)) + }) + + it("passes through the original entry name to the listener", () => { + expect(filterer).toHaveBeenCalledWith([entryName.toLowerCase()]) + }) + + it("updates the ticky box icons", async () => { + expect(screen.getByTitle("ticked")).toBeTruthy() + expect(screen.getAllByTitle("unticked")).toHaveLength( + entries.length - 1 + ) + }) + + it("sets search parameters", async () => { + + const [, setQueryParam] = useQueryParamString() + + expect(setQueryParam).toHaveBeenCalledWith(entryName.toLowerCase()) + }) + }) + }) + + describe("when there is no prettifier", () => { + + + let user + const filterer = jest.fn(() => { + // cheat, since normally the parent will force a rerender, and the child does not use usestate to avoid infinite loops + if (rerender) { + try { + rerender() + } catch (e) { + // This can happen if the component is already unmounted + } + } + }) + + describe("when the query string starts blank", () => { + + beforeEach(() => { + user = userEvent.setup() + mockQueryParamSearchString = undefined + const products = render() + rerender = () => { + products.rerender() + } + }) + + it("renders a entries title", () => { + expect(screen.getByText("Arbitrary Title")).toBeTruthy() + }) + + it("renders individual entry names", () => { + expect(screen.getByText("toad")).toBeTruthy() + expect(screen.getByText("tadpole")).toBeTruthy() + }) + + it("renders tickboxes", () => { + expect(screen.getAllByTitle("unticked")).toHaveLength(entries.length) + }) + + + describe("when clicking a ticky box", () => { + const entryName = "treefrog" + beforeEach(async () => { + await user.click(screen.getByText(entryName)) + }) + + it("passes through the original entry name to the listener", () => { + expect(filterer).toHaveBeenCalledWith([entryName]) + }) + + it("updates the ticky box icons", async () => { + expect(screen.getByTitle("ticked")).toBeTruthy() + expect(screen.getAllByTitle("unticked")).toHaveLength( + entries.length - 1 + ) + }) + + it("sets search parameters", async () => { + + const [, setQueryParam] = useQueryParamString() + + expect(setQueryParam).toHaveBeenCalledWith(entryName.toLowerCase()) + }) + }) + + describe("when clicking several ticky boxes", () => { + const entryName = "treefrog" + const otherName = "toad" + beforeEach(async () => { + await user.click(screen.getByText(entryName)) + await user.click(screen.getByText(otherName)) + }) + + it("passes through the entry names to the listener", () => { + expect(filterer).toHaveBeenCalledWith([ + entryName.toLowerCase(), + otherName.toLowerCase(), + ]) + }) + + it("updates the ticky box icons", () => { + expect(screen.getAllByTitle("unticked")).toHaveLength( + entries.length - 2 + ) + expect(screen.getAllByTitle("ticked")).toHaveLength(2) + }) + + it("sets search parameters", async () => { + + const [, setQueryParam] = useQueryParamString() + + expect(setQueryParam).toHaveBeenCalledWith(entryName.toLowerCase() + "," + otherName.toLowerCase()) + }) + }) + + describe("when un-clicking a ticky box", () => { + const entryName = "treefrog" + beforeEach(async () => { + await user.click(screen.getByText(entryName)) + await user.click(screen.getByText(entryName)) + }) + + it("passes through the filter to the listener", () => { + expect(filterer).toHaveBeenCalledWith([entryName.toLowerCase()]) + expect(filterer).toHaveBeenCalledWith([]) + }) + + it("updates the ticky box icons to go back to unticked", () => { + expect(screen.queryAllByTitle("ticked")).toHaveLength(0) + expect(screen.getAllByTitle("unticked")).toHaveLength(entries.length) + }) + + it("unsets search parameters", async () => { + const [, setQueryParam] = useQueryParamString() + expect(setQueryParam).toHaveBeenCalledWith(undefined) + }) + }) + }) + describe("when the query string already has a entry", () => { + const entryName = "Treefrog" + + beforeEach(() => { + mockQueryParamSearchString = entryName.toLowerCase() + const products = render() + rerender = () => products.rerender() + }) + + it("passes through the original entry name to the listener", () => { + expect(filterer).toHaveBeenCalledWith([entryName.toLowerCase()]) + }) + + it("updates the ticky box icons", () => { + expect(screen.getAllByTitle("unticked")).toHaveLength( + entries.length - 1 + ) + expect(screen.getByTitle("ticked")).toBeTruthy() + }) + + it("sets search parameters", async () => { + + const [, setQueryParam] = useQueryParamString() + + expect(setQueryParam).toHaveBeenCalledWith(entryName.toLowerCase()) + }) + }) + + describe("when there is a prettifier", () => { + + beforeEach(() => { + user = userEvent.setup() + mockQueryParamSearchString = undefined + const products = render( a.toUpperCase()} />) + rerender = () => { + products.rerender() + } + }) + + it("renders a entries title", () => { + expect(screen.getByText("Arbitrary Title")).toBeTruthy() + }) + + it("renders prettified individual entry names", () => { + expect(screen.getByText("TOAD")).toBeTruthy() + expect(screen.getByText("TADPOLE")).toBeTruthy() + }) + + it("renders tickboxes", () => { + expect(screen.getAllByTitle("unticked")).toHaveLength(entries.length) + }) + }) + }) +})