From c4ae7b3f2723872eb3be8b502683ee5afd16f8e7 Mon Sep 17 00:00:00 2001 From: Vera Kahn Date: Mon, 30 Oct 2023 12:15:23 -0400 Subject: [PATCH 01/26] wip --- .../ItemFilters/FiltersContainer.tsx | 45 ++++++++++ src/components/ItemFilters/utils.ts | 0 src/models/itemFilterData.ts | 90 +++++++++++++++++++ src/types/filterTypes.ts | 25 ++++++ 4 files changed, 160 insertions(+) create mode 100644 src/components/ItemFilters/FiltersContainer.tsx create mode 100644 src/components/ItemFilters/utils.ts create mode 100644 src/models/itemFilterData.ts create mode 100644 src/types/filterTypes.ts diff --git a/src/components/ItemFilters/FiltersContainer.tsx b/src/components/ItemFilters/FiltersContainer.tsx new file mode 100644 index 000000000..e9317cf1d --- /dev/null +++ b/src/components/ItemFilters/FiltersContainer.tsx @@ -0,0 +1,45 @@ +import { + CheckboxGroup, + Checkbox, + Card, +} from "@nypl/design-system-react-components" + +import type { + ItemAggregation, + option as optionType, +} from "../../types/filterTypes" +import { ItemFilterData } from "../../models/itemFilterData" + +interface ItemFilterContainerProps { + itemAggs: ItemAggregation[] +} + +const ItemFilterContainer = ({ itemAggs }: ItemFilterContainerProps) => { + const filterData = itemAggs.map( + (agg: ItemAggregation) => new ItemFilterData(agg) + ) + console.log("filter") + return ( + + {filterData.map((field: ItemFilterData) => ( + + {field.options.map((option: optionType) => ( + + ))} + + ))} + + ) +} + +export default ItemFilterContainer diff --git a/src/components/ItemFilters/utils.ts b/src/components/ItemFilters/utils.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/models/itemFilterData.ts b/src/models/itemFilterData.ts new file mode 100644 index 000000000..a1331a270 --- /dev/null +++ b/src/models/itemFilterData.ts @@ -0,0 +1,90 @@ +import type { + ItemAggregation, + ItemAggregationOption, + option, +} from "../types/filterTypes" + +export class ItemFilterData { + options: option[] + field: string + constructor(agg: ItemAggregation) { + this.field = agg.field + if (this.field === "location") { + this.options = this._reduceLocations(agg.values) + } else { + this.options = agg.values + } + } + + // There are multiple rc location codes, but we only want to + // display a single Offsite option in the locations dropdown.This function + // combines separate offsite location options into one. + _reduceLocations(options: ItemAggregationOption[]) { + const reducedOptionsMap = {} + options + .filter((option: ItemAggregationOption) => option.label?.length) + .forEach((option: ItemAggregationOption) => { + let label = option.label + if (label.toLowerCase().replace(/[^\w]/g, "") === "offsite") { + label = "Offsite" + } + if (!reducedOptionsMap[label]) { + reducedOptionsMap[label] = new Set() + } + reducedOptionsMap[label].add(option.value) + }) + return Object.keys(reducedOptionsMap).map((label) => ({ + value: Array.from(reducedOptionsMap[label]).join(","), + label: label, + })) + } +} + +// export default class ItemAggregations { +// formats: ReducedItemAggregation +// statuses: ReducedItemAggregation +// locations: ReducedItemAggregation +// fieldToOptionsMap: { location: object; status: object; format: object } + +// constructor(aggregations: ItemAggregation[]) { +// const reducedAggs = this._reduceItemAggregations(aggregations) +// this.formats = this._extractField(reducedAggs, "format") +// this.statuses = this._extractField(reducedAggs, "status") +// this.locations = this._extractField(reducedAggs, "location") +// this.fieldToOptionsMap = this._buildFieldToOptionsMap(reducedAggs) +// } + +// getLabelsForValues(values: string[], field: string) { +// const getLabelForValue = (value: string) => { +// const labels = Object.keys(this.fieldToOptionsMap[field]) +// return labels.find((label) => +// this.fieldToOptionsMap[field][label].includes(value) +// ) +// } +// return values.map((val) => getLabelForValue(val)).filter((l) => l) +// } + +// _buildFieldToOptionsMap(aggs: ReducedItemAggregation[]) { +// return aggs.reduce((accc, aggregation) => { +// const filter = aggregation.field +// const mappedValues = aggregation.options.reduce((acc, option) => { +// // account for multiple values for offsite label +// let value = option.value +// if (acc[option.label]) value = acc[option.label] + "," + option.value +// return { +// ...acc, +// [option.label]: value, +// } +// }, {}) +// return { +// ...accc, +// [filter]: mappedValues, +// } +// }, {}) +// } + +// _extractField(aggs: ReducedItemAggregation[], field: string) { +// return aggs.find( +// (fieldObj: ReducedItemAggregation) => fieldObj.field === field +// ) +// } diff --git a/src/types/filterTypes.ts b/src/types/filterTypes.ts new file mode 100644 index 000000000..bd5009b55 --- /dev/null +++ b/src/types/filterTypes.ts @@ -0,0 +1,25 @@ +export type locations = string[] + +export interface ItemAggregationOption { + value: string + count: number + label: string +} + +export interface ItemAggregation { + // eslint-disable-next-line @typescript-eslint/naming-convention + "@type": string + // eslint-disable-next-line @typescript-eslint/naming-convention + "@id": string + id: string + field: string + values: ItemAggregationOption[] +} + +export type option = { value: string; label: string } + +export type ReducedItemAggregation = { + field: string + options: ItemAggregationOption[] + count: number +} From ffa9c99500bddb286b1039988f0b648c4fcffd80 Mon Sep 17 00:00:00 2001 From: Vera Kahn Date: Tue, 31 Oct 2023 12:31:17 -0400 Subject: [PATCH 02/26] offsite is sorted, checkboxgroup bug --- pages/search/advanced.tsx | 83 ++++++++++++++++++ spaghetti.test.ts | 80 +++++++++++++++++ .../ItemFilters/FiltersContainer.tsx | 60 +++++++------ src/components/ItemFilters/ItemFilter.tsx | 85 +++++++++++++++++++ src/components/ItemFilters/utils.ts | 8 ++ src/models/itemFilterData.ts | 52 +++++++++--- src/types/filterTypes.ts | 5 ++ 7 files changed, 331 insertions(+), 42 deletions(-) create mode 100644 spaghetti.test.ts create mode 100644 src/components/ItemFilters/ItemFilter.tsx diff --git a/pages/search/advanced.tsx b/pages/search/advanced.tsx index 5362c7168..bbd711c00 100644 --- a/pages/search/advanced.tsx +++ b/pages/search/advanced.tsx @@ -37,6 +37,88 @@ import type { SearchFormActionType, } from "../../src/types/searchTypes" import { getQueryString } from "../../src/utils/searchUtils" +import ItemFilterContainer from "../../src/components/ItemFilters/FiltersContainer" +const aggs = [ + { + "@type": "nypl:Aggregation", + "@id": "res:location", + id: "location", + field: "location", + values: [ + { + value: "loc:mal82", + count: 572, + label: "Schwarzman Building - Main Reading Room 315", + }, + { + value: "loc:makk3", + count: 133, + label: "Schwarzman Building - Dewitt Wallace Reference Desk Room 108", + }, + { + value: "loc:rc2ma", + count: 66, + label: "Offsite", + }, + { + value: "loc:rcma2", + count: 66, + label: "Offsite", + }, + ], + }, + { + "@type": "nypl:Aggregation", + "@id": "res:format", + id: "format", + field: "format", + values: [ + { + value: "Text", + count: 704, + label: "Text", + }, + { + value: "AUG. 23, 2021-CURRENT", + count: 109, + label: "AUG. 23, 2021-CURRENT", + }, + { + value: "FEB. 15/22, 2021 - AUG. 16, 2021", + count: 24, + label: "FEB. 15/22, 2021 - AUG. 16, 2021", + }, + ], + }, + { + "@type": "nypl:Aggregation", + "@id": "res:status", + id: "status", + field: "status", + values: [ + { + value: "status:a", + count: 826, + label: "Available", + }, + { + value: "status:na", + count: 6, + label: "Not available", + }, + { + value: "status:co", + count: 4, + label: "Loaned", + }, + { + value: "status:oh", + count: 1, + label: "On Holdshelf", + }, + ], + }, +] /** * The Advanced Search page is responsible for displaying the Advanced Search form fields and @@ -114,6 +196,7 @@ export default function AdvancedSearch() { Advanced Search | {SITE_NAME} + {alert && ( { + const aggs = [ + { + "@type": "nypl:Aggregation", + "@id": "res:location", + id: "location", + field: "location", + values: [ + { + value: "loc:mal82", + count: 572, + label: "Schwarzman Building - Main Reading Room 315", + }, + { + value: "loc:makk3", + count: 133, + label: "Schwarzman Building - Dewitt Wallace Reference Desk Room 108", + }, + { + value: "loc:rc2ma", + count: 66, + label: "Offsite", + }, + { + value: "loc:rcma2", + count: 66, + label: "Offsite", + }, + ], + }, + { + "@type": "nypl:Aggregation", + "@id": "res:format", + id: "format", + field: "format", + values: [ + { + value: "Text", + count: 704, + label: "Text", + }, + { + value: "AUG. 23, 2021-CURRENT", + count: 109, + label: "AUG. 23, 2021-CURRENT", + }, + { + value: "FEB. 15/22, 2021 - AUG. 16, 2021", + count: 24, + label: "FEB. 15/22, 2021 - AUG. 16, 2021", + }, + ], + }, + { + "@type": "nypl:Aggregation", + "@id": "res:status", + id: "status", + field: "status", + values: [ + { + value: "status:a", + count: 827, + label: "Available", + }, + { + value: "status:na", + count: 6, + label: "Not available", + }, + { + value: "status:co", + count: 4, + label: "Loaned", + }, + ], + }, + ] + const filter = new ItemFilterData(aggs[0]) +}) diff --git a/src/components/ItemFilters/FiltersContainer.tsx b/src/components/ItemFilters/FiltersContainer.tsx index e9317cf1d..e03cfb3a7 100644 --- a/src/components/ItemFilters/FiltersContainer.tsx +++ b/src/components/ItemFilters/FiltersContainer.tsx @@ -1,44 +1,42 @@ -import { - CheckboxGroup, - Checkbox, - Card, -} from "@nypl/design-system-react-components" +import { useState } from "react" -import type { - ItemAggregation, - option as optionType, -} from "../../types/filterTypes" -import { ItemFilterData } from "../../models/itemFilterData" +import type { ItemAggregation } from "../../types/filterTypes" +import { ItemFilterData, LocationFilterData } from "../../models/itemFilterData" +import ItemFilter from "./ItemFilter" +import React from "react" +import { combineRecapLocations } from "./utils" interface ItemFilterContainerProps { itemAggs: ItemAggregation[] } +const tempInitialFilters = { + location: ["loc:rc2ma"], + format: ["TEXT"], + status: ["status:a"], +} + const ItemFilterContainer = ({ itemAggs }: ItemFilterContainerProps) => { - const filterData = itemAggs.map( - (agg: ItemAggregation) => new ItemFilterData(agg) - ) - console.log("filter") + const filterData = itemAggs.map((agg: ItemAggregation) => { + if (agg.field === "location") return new LocationFilterData(agg) + else return new ItemFilterData(agg) + }) + const [selectedFilters, setSelectedFilters] = useState({ + ...tempInitialFilters, + location: combineRecapLocations(tempInitialFilters.location), + }) + return ( - +
{filterData.map((field: ItemFilterData) => ( - - {field.options.map((option: optionType) => ( - - ))} - + ))} - +
) } diff --git a/src/components/ItemFilters/ItemFilter.tsx b/src/components/ItemFilters/ItemFilter.tsx new file mode 100644 index 000000000..c8c344231 --- /dev/null +++ b/src/components/ItemFilters/ItemFilter.tsx @@ -0,0 +1,85 @@ +import { + CheckboxGroup, + Checkbox, + Button, +} from "@nypl/design-system-react-components" +import { useState } from "react" +import type { Dispatch } from "react" + +import type { ItemFilterData } from "../../models/itemFilterData" +import type { + option as optionType, + selectedFilters as selectedFiltersType, +} from "../../types/filterTypes" + +interface ItemFilterProps { + itemFilterData: ItemFilterData + setSelectedFilters: Dispatch> + selectedFilters: selectedFiltersType +} + +const ItemFilter = ({ + itemFilterData, + setSelectedFilters, + selectedFilters, +}: ItemFilterProps) => { + const field = itemFilterData.field() + const fieldFormatted = itemFilterData.field(true) + const [checkBoxGroupValue, setCheckBoxGroupValue] = useState( + selectedFilters[field] + ) + const clearFilter = () => + setSelectedFilters((prevFilters: selectedFiltersType) => { + return { ...prevFilters, [field]: [] } + }) + + const handleCheck = (selectedOptions: string[]) => { + setSelectedFilters((prevFilters: selectedFiltersType) => { + const newFilterSelection = { + ...prevFilters, + [field]: selectedOptions, + } + console.log({ checkBoxGroupValue, newFilterSelection }) + setCheckBoxGroupValue(newFilterSelection) + return newFilterSelection + }) + } + if (itemFilterData.field() === "location") console.log({ checkBoxGroupValue }) + console.log(field + "displayOptions", itemFilterData.displayOptions()) + return ( + <> + + {checkBoxGroupValue.map((val: string) => { + return

{val}

+ })} + {itemFilterData.displayOptions().map(({ value, label }: optionType) => { + return ( + + ) + })} +
+
+ + +
+ + ) +} + +export default ItemFilter diff --git a/src/components/ItemFilters/utils.ts b/src/components/ItemFilters/utils.ts index e69de29bb..7396f1e8f 100644 --- a/src/components/ItemFilters/utils.ts +++ b/src/components/ItemFilters/utils.ts @@ -0,0 +1,8 @@ +export const combineRecapLocations = (locations: string[]) => { + const isRecapLocation = (loc: string) => { + loc.split(":")[1].startsWith("rc") + } + if (locations.filter(isRecapLocation).length !== locations.length) { + return [...locations.filter(isRecapLocation), "offsite"] + } else return locations +} diff --git a/src/models/itemFilterData.ts b/src/models/itemFilterData.ts index a1331a270..12c5f8998 100644 --- a/src/models/itemFilterData.ts +++ b/src/models/itemFilterData.ts @@ -1,27 +1,55 @@ import type { ItemAggregation, ItemAggregationOption, - option, + option as optionType, } from "../types/filterTypes" export class ItemFilterData { - options: option[] - field: string + options: ItemAggregationOption[] + _field: string constructor(agg: ItemAggregation) { - this.field = agg.field - if (this.field === "location") { - this.options = this._reduceLocations(agg.values) - } else { - this.options = agg.values - } + this._field = agg.field + this.options = agg.values + } + + displayOptions(): optionType[] { + return this.options + } + + field(formatted = false) { + const f = this._field + const upperCased = f[0].toUpperCase() + f.substring(1) + return formatted ? upperCased : f + } +} + +export class LocationFilterData extends ItemFilterData { + constructor(aggs: ItemAggregation) { + super(aggs) + } + + displayOptions(): optionType[] { + return this.reducedLocations().map((loc) => { + if (loc.label === "Offsite") { + console.log("match") + return { ...loc, value: "offsite" } + } else return loc + }) + } + + recapLocations() { + return this.reducedLocations().filter(({ label }) => + label.split("loc:")[0].startsWith("rc") + ) } // There are multiple rc location codes, but we only want to // display a single Offsite option in the locations dropdown.This function // combines separate offsite location options into one. - _reduceLocations(options: ItemAggregationOption[]) { + reducedLocations() { const reducedOptionsMap = {} - options + let count = 0 + this.options .filter((option: ItemAggregationOption) => option.label?.length) .forEach((option: ItemAggregationOption) => { let label = option.label @@ -32,10 +60,12 @@ export class ItemFilterData { reducedOptionsMap[label] = new Set() } reducedOptionsMap[label].add(option.value) + count += option.count }) return Object.keys(reducedOptionsMap).map((label) => ({ value: Array.from(reducedOptionsMap[label]).join(","), label: label, + count, })) } } diff --git a/src/types/filterTypes.ts b/src/types/filterTypes.ts index bd5009b55..a3994257a 100644 --- a/src/types/filterTypes.ts +++ b/src/types/filterTypes.ts @@ -1,5 +1,10 @@ export type locations = string[] +export type selectedFilters = { + location: string[] + format: string[] + status: string[] +} export interface ItemAggregationOption { value: string count: number From 26fef8f8bb3766018e8ea290dcc51815a36fb9e5 Mon Sep 17 00:00:00 2001 From: Vera Kahn Date: Tue, 31 Oct 2023 12:34:11 -0400 Subject: [PATCH 03/26] fix checkboxgroup bug --- src/components/ItemFilters/ItemFilter.tsx | 5 ++--- src/models/itemFilterData.ts | 6 +++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/components/ItemFilters/ItemFilter.tsx b/src/components/ItemFilters/ItemFilter.tsx index c8c344231..93e043e9b 100644 --- a/src/components/ItemFilters/ItemFilter.tsx +++ b/src/components/ItemFilters/ItemFilter.tsx @@ -40,12 +40,11 @@ const ItemFilter = ({ [field]: selectedOptions, } console.log({ checkBoxGroupValue, newFilterSelection }) - setCheckBoxGroupValue(newFilterSelection) + setCheckBoxGroupValue(selectedOptions) return newFilterSelection }) } - if (itemFilterData.field() === "location") console.log({ checkBoxGroupValue }) - console.log(field + "displayOptions", itemFilterData.displayOptions()) + if (itemFilterData.field() === "text") console.log({ checkBoxGroupValue }) return ( <> { if (loc.label === "Offsite") { console.log("match") @@ -37,7 +37,7 @@ export class LocationFilterData extends ItemFilterData { }) } - recapLocations() { + recapLocations(): ItemAggregationOption[] { return this.reducedLocations().filter(({ label }) => label.split("loc:")[0].startsWith("rc") ) @@ -46,7 +46,7 @@ export class LocationFilterData extends ItemFilterData { // There are multiple rc location codes, but we only want to // display a single Offsite option in the locations dropdown.This function // combines separate offsite location options into one. - reducedLocations() { + reducedLocations(): ItemAggregationOption[] { const reducedOptionsMap = {} let count = 0 this.options From 0ae2baa90ca3aacb3f2f7f7ff31826f9e5ba7347 Mon Sep 17 00:00:00 2001 From: Vera Kahn Date: Tue, 31 Oct 2023 12:43:03 -0400 Subject: [PATCH 04/26] checkbox group value controlled by selected field state --- .../ItemFilters/FiltersContainer.tsx | 2 +- src/components/ItemFilters/ItemFilter.tsx | 20 ++++++++----------- src/models/itemFilterData.ts | 1 - 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/components/ItemFilters/FiltersContainer.tsx b/src/components/ItemFilters/FiltersContainer.tsx index e03cfb3a7..628df516f 100644 --- a/src/components/ItemFilters/FiltersContainer.tsx +++ b/src/components/ItemFilters/FiltersContainer.tsx @@ -12,7 +12,7 @@ interface ItemFilterContainerProps { const tempInitialFilters = { location: ["loc:rc2ma"], - format: ["TEXT"], + format: ["Text"], status: ["status:a"], } diff --git a/src/components/ItemFilters/ItemFilter.tsx b/src/components/ItemFilters/ItemFilter.tsx index 93e043e9b..fa2a51fb8 100644 --- a/src/components/ItemFilters/ItemFilter.tsx +++ b/src/components/ItemFilters/ItemFilter.tsx @@ -3,7 +3,7 @@ import { Checkbox, Button, } from "@nypl/design-system-react-components" -import { useState } from "react" +import { useEffect, useState } from "react" import type { Dispatch } from "react" import type { ItemFilterData } from "../../models/itemFilterData" @@ -25,13 +25,14 @@ const ItemFilter = ({ }: ItemFilterProps) => { const field = itemFilterData.field() const fieldFormatted = itemFilterData.field(true) - const [checkBoxGroupValue, setCheckBoxGroupValue] = useState( - selectedFilters[field] - ) - const clearFilter = () => + // const [checkBoxGroupValue, setCheckBoxGroupValue] = useState( + // selectedFilters[field] + // ) + const clearFilter = () => { setSelectedFilters((prevFilters: selectedFiltersType) => { return { ...prevFilters, [field]: [] } }) + } const handleCheck = (selectedOptions: string[]) => { setSelectedFilters((prevFilters: selectedFiltersType) => { @@ -39,12 +40,10 @@ const ItemFilter = ({ ...prevFilters, [field]: selectedOptions, } - console.log({ checkBoxGroupValue, newFilterSelection }) - setCheckBoxGroupValue(selectedOptions) return newFilterSelection }) } - if (itemFilterData.field() === "text") console.log({ checkBoxGroupValue }) + return ( <> - {checkBoxGroupValue.map((val: string) => { - return

{val}

- })} {itemFilterData.displayOptions().map(({ value, label }: optionType) => { return ( diff --git a/src/models/itemFilterData.ts b/src/models/itemFilterData.ts index 450274d75..11ff342fa 100644 --- a/src/models/itemFilterData.ts +++ b/src/models/itemFilterData.ts @@ -31,7 +31,6 @@ export class LocationFilterData extends ItemFilterData { displayOptions(): ItemAggregationOption[] { return this.reducedLocations().map((loc) => { if (loc.label === "Offsite") { - console.log("match") return { ...loc, value: "offsite" } } else return loc }) From 1dd53f16971c7952da20856f6bd09d508c5e4391 Mon Sep 17 00:00:00 2001 From: Vera Kahn Date: Tue, 31 Oct 2023 13:01:32 -0400 Subject: [PATCH 05/26] basic filter ui implemented --- spaghetti.test.ts | 80 ----------------------- src/components/ItemFilters/ItemFilter.tsx | 7 +- src/models/itemFilterData.ts | 49 -------------- 3 files changed, 3 insertions(+), 133 deletions(-) delete mode 100644 spaghetti.test.ts diff --git a/spaghetti.test.ts b/spaghetti.test.ts deleted file mode 100644 index e77256944..000000000 --- a/spaghetti.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { ItemFilterData } from "./src/models/itemFilterData" -describe("spaghetti", () => { - const aggs = [ - { - "@type": "nypl:Aggregation", - "@id": "res:location", - id: "location", - field: "location", - values: [ - { - value: "loc:mal82", - count: 572, - label: "Schwarzman Building - Main Reading Room 315", - }, - { - value: "loc:makk3", - count: 133, - label: "Schwarzman Building - Dewitt Wallace Reference Desk Room 108", - }, - { - value: "loc:rc2ma", - count: 66, - label: "Offsite", - }, - { - value: "loc:rcma2", - count: 66, - label: "Offsite", - }, - ], - }, - { - "@type": "nypl:Aggregation", - "@id": "res:format", - id: "format", - field: "format", - values: [ - { - value: "Text", - count: 704, - label: "Text", - }, - { - value: "AUG. 23, 2021-CURRENT", - count: 109, - label: "AUG. 23, 2021-CURRENT", - }, - { - value: "FEB. 15/22, 2021 - AUG. 16, 2021", - count: 24, - label: "FEB. 15/22, 2021 - AUG. 16, 2021", - }, - ], - }, - { - "@type": "nypl:Aggregation", - "@id": "res:status", - id: "status", - field: "status", - values: [ - { - value: "status:a", - count: 827, - label: "Available", - }, - { - value: "status:na", - count: 6, - label: "Not available", - }, - { - value: "status:co", - count: 4, - label: "Loaned", - }, - ], - }, - ] - const filter = new ItemFilterData(aggs[0]) -}) diff --git a/src/components/ItemFilters/ItemFilter.tsx b/src/components/ItemFilters/ItemFilter.tsx index fa2a51fb8..79f52aa21 100644 --- a/src/components/ItemFilters/ItemFilter.tsx +++ b/src/components/ItemFilters/ItemFilter.tsx @@ -3,7 +3,6 @@ import { Checkbox, Button, } from "@nypl/design-system-react-components" -import { useEffect, useState } from "react" import type { Dispatch } from "react" import type { ItemFilterData } from "../../models/itemFilterData" @@ -25,9 +24,6 @@ const ItemFilter = ({ }: ItemFilterProps) => { const field = itemFilterData.field() const fieldFormatted = itemFilterData.field(true) - // const [checkBoxGroupValue, setCheckBoxGroupValue] = useState( - // selectedFilters[field] - // ) const clearFilter = () => { setSelectedFilters((prevFilters: selectedFiltersType) => { return { ...prevFilters, [field]: [] } @@ -52,6 +48,9 @@ const ItemFilter = ({ name={field} id={field} onChange={handleCheck} + // isSelected of the children checkboxes is controlled by this value + // attribute. The options whose value attribute match those present in + // the CheckboxGroup value array are selected. value={selectedFilters[field]} > {itemFilterData.displayOptions().map(({ value, label }: optionType) => { diff --git a/src/models/itemFilterData.ts b/src/models/itemFilterData.ts index 11ff342fa..64b5cc930 100644 --- a/src/models/itemFilterData.ts +++ b/src/models/itemFilterData.ts @@ -68,52 +68,3 @@ export class LocationFilterData extends ItemFilterData { })) } } - -// export default class ItemAggregations { -// formats: ReducedItemAggregation -// statuses: ReducedItemAggregation -// locations: ReducedItemAggregation -// fieldToOptionsMap: { location: object; status: object; format: object } - -// constructor(aggregations: ItemAggregation[]) { -// const reducedAggs = this._reduceItemAggregations(aggregations) -// this.formats = this._extractField(reducedAggs, "format") -// this.statuses = this._extractField(reducedAggs, "status") -// this.locations = this._extractField(reducedAggs, "location") -// this.fieldToOptionsMap = this._buildFieldToOptionsMap(reducedAggs) -// } - -// getLabelsForValues(values: string[], field: string) { -// const getLabelForValue = (value: string) => { -// const labels = Object.keys(this.fieldToOptionsMap[field]) -// return labels.find((label) => -// this.fieldToOptionsMap[field][label].includes(value) -// ) -// } -// return values.map((val) => getLabelForValue(val)).filter((l) => l) -// } - -// _buildFieldToOptionsMap(aggs: ReducedItemAggregation[]) { -// return aggs.reduce((accc, aggregation) => { -// const filter = aggregation.field -// const mappedValues = aggregation.options.reduce((acc, option) => { -// // account for multiple values for offsite label -// let value = option.value -// if (acc[option.label]) value = acc[option.label] + "," + option.value -// return { -// ...acc, -// [option.label]: value, -// } -// }, {}) -// return { -// ...accc, -// [filter]: mappedValues, -// } -// }, {}) -// } - -// _extractField(aggs: ReducedItemAggregation[], field: string) { -// return aggs.find( -// (fieldObj: ReducedItemAggregation) => fieldObj.field === field -// ) -// } From 423bee1c6fcd278f5c430ac7b0aadb31d21ad5d0 Mon Sep 17 00:00:00 2001 From: Vera Kahn Date: Wed, 1 Nov 2023 12:03:54 -0400 Subject: [PATCH 06/26] filter skeleton and tests --- pages/search/advanced.tsx | 82 ------------- .../ItemFilters/FiltersContainer.test.tsx | 113 ++++++++++++++++++ .../ItemFilters/FiltersContainer.tsx | 34 ++++-- src/components/ItemFilters/ItemFilter.tsx | 10 +- src/components/ItemFilters/utils.ts | 52 +++++++- src/models/itemFilterData.ts | 12 +- 6 files changed, 199 insertions(+), 104 deletions(-) create mode 100644 src/components/ItemFilters/FiltersContainer.test.tsx diff --git a/pages/search/advanced.tsx b/pages/search/advanced.tsx index bbd711c00..42947a0cc 100644 --- a/pages/search/advanced.tsx +++ b/pages/search/advanced.tsx @@ -38,87 +38,6 @@ import type { } from "../../src/types/searchTypes" import { getQueryString } from "../../src/utils/searchUtils" import ItemFilterContainer from "../../src/components/ItemFilters/FiltersContainer" -const aggs = [ - { - "@type": "nypl:Aggregation", - "@id": "res:location", - id: "location", - field: "location", - values: [ - { - value: "loc:mal82", - count: 572, - label: "Schwarzman Building - Main Reading Room 315", - }, - { - value: "loc:makk3", - count: 133, - label: "Schwarzman Building - Dewitt Wallace Reference Desk Room 108", - }, - { - value: "loc:rc2ma", - count: 66, - label: "Offsite", - }, - { - value: "loc:rcma2", - count: 66, - label: "Offsite", - }, - ], - }, - { - "@type": "nypl:Aggregation", - "@id": "res:format", - id: "format", - field: "format", - values: [ - { - value: "Text", - count: 704, - label: "Text", - }, - { - value: "AUG. 23, 2021-CURRENT", - count: 109, - label: "AUG. 23, 2021-CURRENT", - }, - { - value: "FEB. 15/22, 2021 - AUG. 16, 2021", - count: 24, - label: "FEB. 15/22, 2021 - AUG. 16, 2021", - }, - ], - }, - { - "@type": "nypl:Aggregation", - "@id": "res:status", - id: "status", - field: "status", - values: [ - { - value: "status:a", - count: 826, - label: "Available", - }, - { - value: "status:na", - count: 6, - label: "Not available", - }, - { - value: "status:co", - count: 4, - label: "Loaned", - }, - { - value: "status:oh", - count: 1, - label: "On Holdshelf", - }, - ], - }, -] /** * The Advanced Search page is responsible for displaying the Advanced Search form fields and @@ -196,7 +115,6 @@ export default function AdvancedSearch() { Advanced Search | {SITE_NAME} - {alert && ( jest.requireActual("next-router-mock")) + +describe("Filters container", () => { + it("renders three filter boxes", () => { + render() + const filters = screen.getAllByTestId("item-filter") + expect(filters.length).toBe(3) + }) + it("loads the query into state", () => { + mockRouter.query = { + item_location: "loc:rc2ma", + item_format: "Text", + item_status: "status:a", + } + render() + + const checkboxes = screen.getAllByRole("checkbox", { checked: true }) + expect(checkboxes.length).toBe(3) + const selectedValues = ["Available", "Text", "Offsite"].map((label) => + screen.getByLabelText(label) + ) + selectedValues.forEach((checkbox) => expect(checkbox).toBeChecked()) + }) +}) + +const aggs = [ + { + "@type": "nypl:Aggregation", + "@id": "res:location", + id: "location", + field: "location", + values: [ + { + value: "loc:mal82", + count: 572, + label: "Schwarzman Building - Main Reading Room 315", + }, + { + value: "loc:makk3", + count: 133, + label: "Schwarzman Building - Dewitt Wallace Reference Desk Room 108", + }, + { + value: "loc:rc2ma", + count: 66, + label: "Offsite", + }, + { + value: "loc:rcma2", + count: 66, + label: "Offsite", + }, + ], + }, + { + "@type": "nypl:Aggregation", + "@id": "res:format", + id: "format", + field: "format", + values: [ + { + value: "Text", + count: 704, + label: "Text", + }, + { + value: "AUG. 23, 2021-CURRENT", + count: 109, + label: "AUG. 23, 2021-CURRENT", + }, + { + value: "FEB. 15/22, 2021 - AUG. 16, 2021", + count: 24, + label: "FEB. 15/22, 2021 - AUG. 16, 2021", + }, + ], + }, + { + "@type": "nypl:Aggregation", + "@id": "res:status", + id: "status", + field: "status", + values: [ + { + value: "status:a", + count: 826, + label: "Available", + }, + { + value: "status:na", + count: 6, + label: "Not available", + }, + { + value: "status:co", + count: 4, + label: "Loaned", + }, + { + value: "status:oh", + count: 1, + label: "On Holdshelf", + }, + ], + }, +] diff --git a/src/components/ItemFilters/FiltersContainer.tsx b/src/components/ItemFilters/FiltersContainer.tsx index 628df516f..07372cde6 100644 --- a/src/components/ItemFilters/FiltersContainer.tsx +++ b/src/components/ItemFilters/FiltersContainer.tsx @@ -1,39 +1,53 @@ -import { useState } from "react" +import { useEffect, useState } from "react" +import { useRouter } from "next/router" import type { ItemAggregation } from "../../types/filterTypes" import { ItemFilterData, LocationFilterData } from "../../models/itemFilterData" import ItemFilter from "./ItemFilter" import React from "react" -import { combineRecapLocations } from "./utils" +import { buildQueryParams, parseQueryParams } from "./utils" interface ItemFilterContainerProps { itemAggs: ItemAggregation[] } -const tempInitialFilters = { - location: ["loc:rc2ma"], - format: ["Text"], - status: ["status:a"], -} - const ItemFilterContainer = ({ itemAggs }: ItemFilterContainerProps) => { + const { query } = useRouter() const filterData = itemAggs.map((agg: ItemAggregation) => { if (agg.field === "location") return new LocationFilterData(agg) else return new ItemFilterData(agg) }) const [selectedFilters, setSelectedFilters] = useState({ - ...tempInitialFilters, - location: combineRecapLocations(tempInitialFilters.location), + location: [], + format: [], + status: [], }) + const [tempQueryDisplay, setTempQueryDisplay] = useState("") + + const tempSubmitFilters = () => { + const locationFilterData = filterData.find( + (filter) => filter.field() === "location" + ) as LocationFilterData + setTempQueryDisplay( + buildQueryParams(selectedFilters, locationFilterData.recapLocations()) + ) + } + + useEffect(() => { + setSelectedFilters(parseQueryParams(query)) + }, [query]) + return (
+

{tempQueryDisplay}

{filterData.map((field: ItemFilterData) => ( ))}
diff --git a/src/components/ItemFilters/ItemFilter.tsx b/src/components/ItemFilters/ItemFilter.tsx index 79f52aa21..50740db9a 100644 --- a/src/components/ItemFilters/ItemFilter.tsx +++ b/src/components/ItemFilters/ItemFilter.tsx @@ -15,12 +15,15 @@ interface ItemFilterProps { itemFilterData: ItemFilterData setSelectedFilters: Dispatch> selectedFilters: selectedFiltersType + // this type is temporary for dev use only. could end up being different. + submitFilters: Dispatch> } const ItemFilter = ({ itemFilterData, setSelectedFilters, selectedFilters, + submitFilters, }: ItemFilterProps) => { const field = itemFilterData.field() const fieldFormatted = itemFilterData.field(true) @@ -43,6 +46,7 @@ const ItemFilter = ({ return ( <> Clear - diff --git a/src/components/ItemFilters/utils.ts b/src/components/ItemFilters/utils.ts index 7396f1e8f..1ac15c538 100644 --- a/src/components/ItemFilters/utils.ts +++ b/src/components/ItemFilters/utils.ts @@ -1,8 +1,50 @@ +import type { selectedFilters as selectedFiltersType } from "../../types/filterTypes" + +const isRecapLocation = (loc: string) => { + return loc.split(":")[1].startsWith("rc") +} + +/* eslint-disable @typescript-eslint/naming-convention */ export const combineRecapLocations = (locations: string[]) => { - const isRecapLocation = (loc: string) => { - loc.split(":")[1].startsWith("rc") - } - if (locations.filter(isRecapLocation).length !== locations.length) { - return [...locations.filter(isRecapLocation), "offsite"] + if (locations.find(isRecapLocation)) { + return [...locations.filter(isRecapLocation), "Offsite"] } else return locations } + +type bibPageQueryParams = { + item_location?: string + item_format?: string + item_status?: string +} + +export const parseQueryParams = ({ + item_status, + item_format, + item_location, +}: bibPageQueryParams) => { + return { + location: item_location + ? combineRecapLocations(item_location.split(",")) + : [], + format: item_format?.split(",") || [], + status: item_status?.split(",") || [], + } +} + +export const buildQueryParams = ( + { location, format, status }: selectedFiltersType, + recapLocations: string[] +) => { + const locs = location.map((loc) => { + if (isRecapLocation(loc)) return recapLocations + else return loc + }) + const location_query = location.length + ? "item_location=" + locs.join(",") + : "" + const format_query = format.length ? "item_format=" + format.join(",") : "" + const status_query = status.length ? "item_status=" + status.join(",") : "" + + const query = encodeURI(`?${location_query}&${format_query}&${status_query}`) + return query.length > 3 ? query : "" +} diff --git a/src/models/itemFilterData.ts b/src/models/itemFilterData.ts index 64b5cc930..c85752dc7 100644 --- a/src/models/itemFilterData.ts +++ b/src/models/itemFilterData.ts @@ -31,20 +31,20 @@ export class LocationFilterData extends ItemFilterData { displayOptions(): ItemAggregationOption[] { return this.reducedLocations().map((loc) => { if (loc.label === "Offsite") { - return { ...loc, value: "offsite" } + return { ...loc, value: "Offsite" } } else return loc }) } - recapLocations(): ItemAggregationOption[] { - return this.reducedLocations().filter(({ label }) => - label.split("loc:")[0].startsWith("rc") - ) + recapLocations(): string[] { + return this.reducedLocations() + .filter(({ label }) => label.split("loc:")[0].startsWith("rc")) + .map((recapOption: ItemAggregationOption) => recapOption.value) } // There are multiple rc location codes, but we only want to // display a single Offsite option in the locations dropdown.This function - // combines separate offsite location options into one. + // combines separate Offsite location options into one. reducedLocations(): ItemAggregationOption[] { const reducedOptionsMap = {} let count = 0 From 490bb87c6b2bef1676a77d0831a48a6695bf1fc7 Mon Sep 17 00:00:00 2001 From: Vera Kahn Date: Wed, 1 Nov 2023 12:57:38 -0400 Subject: [PATCH 07/26] more tests --- src/components/ItemFilters/ItemFilter.tsx | 1 + ...ontainer.test.tsx => ItemFilters.test.tsx} | 36 +++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) rename src/components/ItemFilters/{FiltersContainer.test.tsx => ItemFilters.test.tsx} (65%) diff --git a/src/components/ItemFilters/ItemFilter.tsx b/src/components/ItemFilters/ItemFilter.tsx index 50740db9a..0b6af66c0 100644 --- a/src/components/ItemFilters/ItemFilter.tsx +++ b/src/components/ItemFilters/ItemFilter.tsx @@ -65,6 +65,7 @@ const ItemFilter = ({
+ { + // if (!mobile) manageFilterDisplay('none'); + // }, + returnFocusOnDeactivate: false, + }} + // active={isOpen} + className="item-filter" + > +
+ {isOpen && ( + <> + + {itemFilterData + .displayOptions() + .map(({ value, label }: optionType) => { + return ( + + ) + })} + + +
+ + +
+ + )}
- +
) } diff --git a/src/components/ItemFilters/utils.ts b/src/components/ItemFilters/utils.ts index 1ac15c538..2126ec23b 100644 --- a/src/components/ItemFilters/utils.ts +++ b/src/components/ItemFilters/utils.ts @@ -1,6 +1,6 @@ import type { selectedFilters as selectedFiltersType } from "../../types/filterTypes" -const isRecapLocation = (loc: string) => { +export const isRecapLocation = (loc: string) => { return loc.split(":")[1].startsWith("rc") } @@ -33,10 +33,12 @@ export const parseQueryParams = ({ export const buildQueryParams = ( { location, format, status }: selectedFiltersType, - recapLocations: string[] + recapLocations: string ) => { + console.log(location) const locs = location.map((loc) => { - if (isRecapLocation(loc)) return recapLocations + console.log(recapLocations) + if (loc === "Offsite") return recapLocations else return loc }) const location_query = location.length diff --git a/src/models/itemFilterData.ts b/src/models/itemFilterData.ts index c85752dc7..c56a1582b 100644 --- a/src/models/itemFilterData.ts +++ b/src/models/itemFilterData.ts @@ -4,6 +4,8 @@ import type { option as optionType, } from "../types/filterTypes" +import { isRecapLocation } from "../components/ItemFilters/utils" + export class ItemFilterData { options: ItemAggregationOption[] _field: string @@ -29,42 +31,28 @@ export class LocationFilterData extends ItemFilterData { } displayOptions(): ItemAggregationOption[] { - return this.reducedLocations().map((loc) => { - if (loc.label === "Offsite") { - return { ...loc, value: "Offsite" } - } else return loc + let offsiteCount = 0 + const optionsWithoutRecap = this.options.filter(({ value, count }) => { + if (isRecapLocation(value)) { + offsiteCount += count + return false + } else return true }) + if (offsiteCount) { + return [ + ...optionsWithoutRecap, + { label: "Offsite", value: "Offsite", count: offsiteCount }, + ] + } else return optionsWithoutRecap } - recapLocations(): string[] { - return this.reducedLocations() - .filter(({ label }) => label.split("loc:")[0].startsWith("rc")) - .map((recapOption: ItemAggregationOption) => recapOption.value) - } - - // There are multiple rc location codes, but we only want to - // display a single Offsite option in the locations dropdown.This function - // combines separate Offsite location options into one. - reducedLocations(): ItemAggregationOption[] { - const reducedOptionsMap = {} - let count = 0 - this.options - .filter((option: ItemAggregationOption) => option.label?.length) - .forEach((option: ItemAggregationOption) => { - let label = option.label - if (label.toLowerCase().replace(/[^\w]/g, "") === "offsite") { - label = "Offsite" - } - if (!reducedOptionsMap[label]) { - reducedOptionsMap[label] = new Set() + recapLocations(): string { + return this.options + .map(({ value }) => { + if (isRecapLocation(value)) { + return value } - reducedOptionsMap[label].add(option.value) - count += option.count }) - return Object.keys(reducedOptionsMap).map((label) => ({ - value: Array.from(reducedOptionsMap[label]).join(","), - label: label, - count, - })) + .join(",") } } From b8c2aac98d3dbd7319e065e8fba1f45a988238de Mon Sep 17 00:00:00 2001 From: Vera Kahn Date: Thu, 2 Nov 2023 15:26:11 -0400 Subject: [PATCH 10/26] show/hide works --- .../ItemFilters/FiltersContainer.tsx | 6 +++++ src/components/ItemFilters/ItemFilter.tsx | 27 ++++++++++--------- .../ItemFilters/testAggregations.js | 2 +- src/components/ItemFilters/utils.ts | 14 +++++----- src/models/itemFilterData.ts | 18 ++++++++----- 5 files changed, 41 insertions(+), 26 deletions(-) diff --git a/src/components/ItemFilters/FiltersContainer.tsx b/src/components/ItemFilters/FiltersContainer.tsx index 05f27c2fc..85e617ff8 100644 --- a/src/components/ItemFilters/FiltersContainer.tsx +++ b/src/components/ItemFilters/FiltersContainer.tsx @@ -23,6 +23,8 @@ const ItemFilterContainer = ({ itemAggs }: ItemFilterContainerProps) => { status: [], }) + const [whichFilterIsOpen, setWhichFilterIsOpen] = useState("") + const [tempQueryDisplay, setTempQueryDisplay] = useState("") const tempSubmitFilters = () => { @@ -38,12 +40,16 @@ const ItemFilterContainer = ({ itemAggs }: ItemFilterContainerProps) => { setSelectedFilters(parseQueryParams(query)) }, [query]) + useEffect(() => tempSubmitFilters(), [selectedFilters]) + useEffect(() => console.log({ tempQueryDisplay }), [tempQueryDisplay]) return (

{tempQueryDisplay}

{filterData.map((field: ItemFilterData) => ( > + isOpen: boolean + toggleFilterDisplay: Dispatch> } const ItemFilter = ({ itemFilterData, setSelectedFilters, selectedFilters, - submitFilters, + isOpen, + toggleFilterDisplay, }: ItemFilterProps) => { const field = itemFilterData.field() const fieldFormatted = itemFilterData.field(true) - const [isOpen, setIsOpen] = useState(false) + // const [isOpen, setIsOpen] = useState(false) const [selectedOptions, setSelectedOptions] = useState(selectedFilters[field]) const resetToAppliedOptions = () => { - handleCheck(selectedFilters[field]) + updateCheckboxGroupValue(selectedFilters[field]) } const clearFilter = () => { + setSelectedOptions([]) setSelectedFilters((prevFilters: selectedFiltersType) => { return { ...prevFilters, [field]: [] } }) } const applyFilter = () => { + let newFilterSelection setSelectedFilters((prevFilters: selectedFiltersType) => { - const newFilterSelection = { + newFilterSelection = { ...prevFilters, [field]: selectedOptions, } - submitFilters(newFilterSelection) return newFilterSelection }) } - const handleCheck = (data: string[]) => { + const updateCheckboxGroupValue = (data: string[]) => { setSelectedOptions(data) } const openCloseHandler = () => { - setIsOpen((prevIsOpen) => { - resetToAppliedOptions() - return !prevIsOpen - }) + resetToAppliedOptions() + if (isOpen) toggleFilterDisplay("") + else toggleFilterDisplay(field) } return ( @@ -74,7 +77,7 @@ const ItemFilter = ({ // }, returnFocusOnDeactivate: false, }} - // active={isOpen} + active={isOpen} className="item-filter" >
@@ -98,7 +101,7 @@ const ItemFilter = ({ key={field} name={field} id={field} - onChange={handleCheck} + onChange={updateCheckboxGroupValue} // isSelected of the children checkboxes is controlled by this value // attribute. The options whose value attribute match those present in // the CheckboxGroup value array are selected. diff --git a/src/components/ItemFilters/testAggregations.js b/src/components/ItemFilters/testAggregations.js index 6f988b473..7051400c7 100644 --- a/src/components/ItemFilters/testAggregations.js +++ b/src/components/ItemFilters/testAggregations.js @@ -80,4 +80,4 @@ const aggs = [ }, ] -export default aggs \ No newline at end of file +export default aggs diff --git a/src/components/ItemFilters/utils.ts b/src/components/ItemFilters/utils.ts index 2126ec23b..333e5a5ec 100644 --- a/src/components/ItemFilters/utils.ts +++ b/src/components/ItemFilters/utils.ts @@ -35,18 +35,20 @@ export const buildQueryParams = ( { location, format, status }: selectedFiltersType, recapLocations: string ) => { - console.log(location) const locs = location.map((loc) => { - console.log(recapLocations) if (loc === "Offsite") return recapLocations else return loc }) const location_query = location.length ? "item_location=" + locs.join(",") : "" - const format_query = format.length ? "item_format=" + format.join(",") : "" - const status_query = status.length ? "item_status=" + status.join(",") : "" + const format_query = format.length + ? "item_format=" + format.join(",") + "&" + : "" + const status_query = status.length + ? "item_status=" + status.join(",") + "&" + : "" - const query = encodeURI(`?${location_query}&${format_query}&${status_query}`) - return query.length > 3 ? query : "" + const query = encodeURI(`?${location_query}${format_query}${status_query}`) + return query.length > 1 ? query : "" } diff --git a/src/models/itemFilterData.ts b/src/models/itemFilterData.ts index c56a1582b..fea86b431 100644 --- a/src/models/itemFilterData.ts +++ b/src/models/itemFilterData.ts @@ -47,12 +47,16 @@ export class LocationFilterData extends ItemFilterData { } recapLocations(): string { - return this.options - .map(({ value }) => { - if (isRecapLocation(value)) { - return value - } - }) - .join(",") + return ( + this.options + .map(({ value }) => { + if (isRecapLocation(value)) { + return value + } + }) + // remove null values + .filter((loc) => loc) + .join(",") + ) } } From 64cf7268508bbcb84fa2d80cc480eab28e9eb939 Mon Sep 17 00:00:00 2001 From: Vera Kahn Date: Thu, 2 Nov 2023 17:11:33 -0400 Subject: [PATCH 11/26] add comments from PR --- .../fixtures}/testAggregations.js | 2 +- pages/search/advanced.tsx | 4 ++-- src/components/ItemFilters/ItemFilter.tsx | 17 +++++++---------- src/components/ItemFilters/ItemFilters.test.tsx | 5 +++++ src/components/ItemFilters/utils.ts | 8 ++++---- src/models/itemFilterData.ts | 10 +++++----- src/types/filterTypes.ts | 6 +++--- 7 files changed, 27 insertions(+), 25 deletions(-) rename {src/components/ItemFilters => __test__/fixtures}/testAggregations.js (98%) diff --git a/src/components/ItemFilters/testAggregations.js b/__test__/fixtures/testAggregations.js similarity index 98% rename from src/components/ItemFilters/testAggregations.js rename to __test__/fixtures/testAggregations.js index 6f988b473..7051400c7 100644 --- a/src/components/ItemFilters/testAggregations.js +++ b/__test__/fixtures/testAggregations.js @@ -80,4 +80,4 @@ const aggs = [ }, ] -export default aggs \ No newline at end of file +export default aggs diff --git a/pages/search/advanced.tsx b/pages/search/advanced.tsx index be2385800..a0b396999 100644 --- a/pages/search/advanced.tsx +++ b/pages/search/advanced.tsx @@ -39,7 +39,7 @@ import type { import { getQueryString } from "../../src/utils/searchUtils" import ItemFilterContainer from "../../src/components/ItemFilters/FiltersContainer" -import testItemAggs from "../../src/components/ItemFilters/testAggregations" +import testItemAggs from "../../__test__/fixtures/testAggregations" /** * The Advanced Search page is responsible for displaying the Advanced Search form fields and @@ -125,7 +125,7 @@ export default function AdvancedSearch() { } /> )} - + Advanced Search > - selectedFilters: selectedFiltersType + setSelectedFilters: Dispatch> + selectedFilters: SelectedFilters // this type is temporary for dev use only. could end up being different. - submitFilters: Dispatch> + submitFilters: Dispatch> } const ItemFilter = ({ @@ -28,13 +25,13 @@ const ItemFilter = ({ const field = itemFilterData.field() const fieldFormatted = itemFilterData.field(true) const clearFilter = () => { - setSelectedFilters((prevFilters: selectedFiltersType) => { + setSelectedFilters((prevFilters: SelectedFilters) => { return { ...prevFilters, [field]: [] } }) } const handleCheck = (selectedOptions: string[]) => { - setSelectedFilters((prevFilters: selectedFiltersType) => { + setSelectedFilters((prevFilters: SelectedFilters) => { const newFilterSelection = { ...prevFilters, [field]: selectedOptions, @@ -57,7 +54,7 @@ const ItemFilter = ({ // the CheckboxGroup value array are selected. value={selectedFilters[field]} > - {itemFilterData.displayOptions().map(({ value, label }: optionType) => { + {itemFilterData.displayOptions().map(({ value, label }: Option) => { return ( ) diff --git a/src/components/ItemFilters/ItemFilters.test.tsx b/src/components/ItemFilters/ItemFilters.test.tsx index 5021b8927..b1ca741cf 100644 --- a/src/components/ItemFilters/ItemFilters.test.tsx +++ b/src/components/ItemFilters/ItemFilters.test.tsx @@ -9,6 +9,11 @@ import userEvent from "@testing-library/user-event" jest.mock("next/router", () => jest.requireActual("next-router-mock")) describe("Filters container", () => { + it("renders a single filter", () => { + render() + const filters = screen.getAllByTestId("item-filter") + expect(filters.length).toBe(1) + }) it("renders three filter boxes", () => { render() const filters = screen.getAllByTestId("item-filter") diff --git a/src/components/ItemFilters/utils.ts b/src/components/ItemFilters/utils.ts index 1ac15c538..3aae9225c 100644 --- a/src/components/ItemFilters/utils.ts +++ b/src/components/ItemFilters/utils.ts @@ -1,4 +1,4 @@ -import type { selectedFilters as selectedFiltersType } from "../../types/filterTypes" +import type { SelectedFilters } from "../../types/filterTypes" const isRecapLocation = (loc: string) => { return loc.split(":")[1].startsWith("rc") @@ -11,7 +11,7 @@ export const combineRecapLocations = (locations: string[]) => { } else return locations } -type bibPageQueryParams = { +type BibPageQueryParams = { item_location?: string item_format?: string item_status?: string @@ -21,7 +21,7 @@ export const parseQueryParams = ({ item_status, item_format, item_location, -}: bibPageQueryParams) => { +}: BibPageQueryParams) => { return { location: item_location ? combineRecapLocations(item_location.split(",")) @@ -32,7 +32,7 @@ export const parseQueryParams = ({ } export const buildQueryParams = ( - { location, format, status }: selectedFiltersType, + { location, format, status }: SelectedFilters, recapLocations: string[] ) => { const locs = location.map((loc) => { diff --git a/src/models/itemFilterData.ts b/src/models/itemFilterData.ts index c85752dc7..dd7106989 100644 --- a/src/models/itemFilterData.ts +++ b/src/models/itemFilterData.ts @@ -1,23 +1,23 @@ import type { ItemAggregation, ItemAggregationOption, - option as optionType, + Option, } from "../types/filterTypes" export class ItemFilterData { options: ItemAggregationOption[] - _field: string + agg: ItemAggregation constructor(agg: ItemAggregation) { - this._field = agg.field + this.agg = agg this.options = agg.values } - displayOptions(): optionType[] { + displayOptions(): Option[] { return this.options } field(formatted = false) { - const f = this._field + const f = this.agg.field const upperCased = f[0].toUpperCase() + f.substring(1) return formatted ? upperCased : f } diff --git a/src/types/filterTypes.ts b/src/types/filterTypes.ts index a3994257a..2773f8bec 100644 --- a/src/types/filterTypes.ts +++ b/src/types/filterTypes.ts @@ -1,6 +1,6 @@ -export type locations = string[] +export type Locations = string[] -export type selectedFilters = { +export type SelectedFilters = { location: string[] format: string[] status: string[] @@ -21,7 +21,7 @@ export interface ItemAggregation { values: ItemAggregationOption[] } -export type option = { value: string; label: string } +export type Option = { value: string; label: string } export type ReducedItemAggregation = { field: string From e7f84b01b7794098c0c7579eac6ce67c3d7e2e55 Mon Sep 17 00:00:00 2001 From: Vera Kahn Date: Fri, 3 Nov 2023 11:55:06 -0400 Subject: [PATCH 12/26] start utils tests --- src/utils/itemFilterUtils.ts | 51 +++++++++++++++++++ src/utils/utilsTests/itemFilterUtils.test.tsx | 23 +++++++++ styles/components/ItemFilters.module.scss | 5 ++ 3 files changed, 79 insertions(+) create mode 100644 src/utils/itemFilterUtils.ts create mode 100644 src/utils/utilsTests/itemFilterUtils.test.tsx create mode 100644 styles/components/ItemFilters.module.scss diff --git a/src/utils/itemFilterUtils.ts b/src/utils/itemFilterUtils.ts new file mode 100644 index 000000000..bf0cc1bcb --- /dev/null +++ b/src/utils/itemFilterUtils.ts @@ -0,0 +1,51 @@ +import type { SelectedFilters } from "../types/filterTypes" + +export const isRecapLocation = (loc: string) => { + console.log(loc) + return loc.split(":")[1].startsWith("rc") +} + +/* eslint-disable @typescript-eslint/naming-convention */ +export const combineRecapLocations = (locations: string[]) => { + if (locations.find(isRecapLocation)) { + return [...locations.filter((loc) => !isRecapLocation(loc)), "Offsite"] + } else return locations +} + +type BibPageQueryParams = { + item_location?: string + item_format?: string + item_status?: string +} + +export const parseItemFilterQueryParams = ({ + item_status, + item_format, + item_location, +}: BibPageQueryParams) => { + return { + location: item_location + ? combineRecapLocations(item_location.split(",")) + : [], + format: item_format?.split(",") || [], + status: item_status?.split(",") || [], + } +} + +export const buildItemFilterQueryParams = ( + { location, format, status }: SelectedFilters, + recapLocations: string[] +) => { + const locs = location.map((loc) => { + if (isRecapLocation(loc)) return recapLocations + else return loc + }) + const location_query = location.length + ? "item_location=" + locs.join(",") + : "" + const format_query = format.length ? "item_format=" + format.join(",") : "" + const status_query = status.length ? "item_status=" + status.join(",") : "" + + const query = encodeURI(`?${location_query}&${format_query}&${status_query}`) + return query.length > 3 ? query : "" +} diff --git a/src/utils/utilsTests/itemFilterUtils.test.tsx b/src/utils/utilsTests/itemFilterUtils.test.tsx new file mode 100644 index 000000000..3ce82a852 --- /dev/null +++ b/src/utils/utilsTests/itemFilterUtils.test.tsx @@ -0,0 +1,23 @@ +import { + isRecapLocation, + combineRecapLocations, + parseItemFilterQueryParams, + buildItemFilterQueryParams, +} from "../itemFilterUtils" + +describe("Item Filter Utils", () => { + describe("isRecapLocation", () => { + it("returns true for a recap location", () => { + expect(isRecapLocation("loc:rc2ma")).toBe(true) + }) + it("returns false for a recap location", () => { + expect(isRecapLocation("loc:xc")).toBe(false) + }) + }) + describe("combineRecapLocations", () => { + it("replaces all offsite location codes with single Offsite string", () => { + const locations = ["loc:mab", "loc:rc2ma", "loc:rcma2", "loc:rcrc"] + expect(combineRecapLocations(locations)).toEqual(["loc:mab", "Offsite"]) + }) + }) +}) diff --git a/styles/components/ItemFilters.module.scss b/styles/components/ItemFilters.module.scss new file mode 100644 index 000000000..57237d822 --- /dev/null +++ b/styles/components/ItemFilters.module.scss @@ -0,0 +1,5 @@ +@import "../utils/mixins"; + +.filterOption { + background-color: tomato; +} From 415bf4297752c58412258beab0923e2755dc478a Mon Sep 17 00:00:00 2001 From: Vera Kahn Date: Fri, 3 Nov 2023 12:22:26 -0400 Subject: [PATCH 13/26] wip --- pages/search/advanced.tsx | 2 +- src/components/ItemFilters/FiltersContainer.tsx | 2 +- src/components/ItemFilters/ItemFilter.tsx | 8 ++++++-- styles/components/ItemFilters.module.scss | 3 +++ 4 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 styles/components/ItemFilters.module.scss diff --git a/pages/search/advanced.tsx b/pages/search/advanced.tsx index a0b396999..f5f2e8dd6 100644 --- a/pages/search/advanced.tsx +++ b/pages/search/advanced.tsx @@ -125,7 +125,7 @@ export default function AdvancedSearch() { } /> )} - + Advanced Search { setSelectedFilters(parseQueryParams(query)) }, [query]) - useEffect(() => tempSubmitFilters(), [selectedFilters]) + useEffect(tempSubmitFilters, [selectedFilters, tempSubmitFilters]) useEffect(() => console.log({ tempQueryDisplay }), [tempQueryDisplay]) return ( diff --git a/src/components/ItemFilters/ItemFilter.tsx b/src/components/ItemFilters/ItemFilter.tsx index 65dee7af8..3ba8046d8 100644 --- a/src/components/ItemFilters/ItemFilter.tsx +++ b/src/components/ItemFilters/ItemFilter.tsx @@ -3,6 +3,7 @@ import { Checkbox, Button, Icon, + Spacer, } from "@nypl/design-system-react-components" import type { Dispatch } from "react" import FocusTrap from "focus-trap-react" @@ -10,6 +11,7 @@ import { useState } from "react" import type { ItemFilterData } from "../../models/itemFilterData" import type { Option, SelectedFilters } from "../../types/filterTypes" +import styles from "../../../styles/components/ItemFilters.module.scss" interface ItemFilterProps { itemFilterData: ItemFilterData @@ -45,7 +47,7 @@ const ItemFilter = ({ } const applyFilter = () => { - let newFilterSelection + let newFilterSelection: SelectedFilters setSelectedFilters((prevFilters: SelectedFilters) => { newFilterSelection = { ...prevFilters, @@ -86,7 +88,8 @@ const ItemFilter = ({ type="button" > {fieldFormatted} - {/*{numOfSelections}*/} + + {`(${selectedOptions.length})`} {isOpen && ( @@ -109,6 +112,7 @@ const ItemFilter = ({ .map(({ value, label }: Option) => { return ( Date: Fri, 3 Nov 2023 14:40:32 -0400 Subject: [PATCH 14/26] update tests and add button disabled --- .../ItemFilters/FiltersContainer.tsx | 1 - src/components/ItemFilters/ItemFilter.tsx | 139 ++++++++---------- .../ItemFilters/ItemFilters.test.tsx | 93 ++++++++---- src/utils/itemFilterUtils.ts | 5 +- src/utils/utilsTests/itemFilterUtils.test.tsx | 16 ++ 5 files changed, 149 insertions(+), 105 deletions(-) diff --git a/src/components/ItemFilters/FiltersContainer.tsx b/src/components/ItemFilters/FiltersContainer.tsx index 122cb1465..b54cd3646 100644 --- a/src/components/ItemFilters/FiltersContainer.tsx +++ b/src/components/ItemFilters/FiltersContainer.tsx @@ -48,7 +48,6 @@ const ItemFilterContainer = ({ itemAggs }: ItemFilterContainerProps) => { useEffect(tempSubmitFilters, [selectedFilters, tempSubmitFilters]) - useEffect(() => console.log({ tempQueryDisplay }), [tempQueryDisplay]) return (

{tempQueryDisplay}

diff --git a/src/components/ItemFilters/ItemFilter.tsx b/src/components/ItemFilters/ItemFilter.tsx index afc24fb80..ac85bd56e 100644 --- a/src/components/ItemFilters/ItemFilter.tsx +++ b/src/components/ItemFilters/ItemFilter.tsx @@ -4,6 +4,7 @@ import { Button, Icon, Spacer, + ButtonGroup, } from "@nypl/design-system-react-components" import type { Dispatch } from "react" import FocusTrap from "focus-trap-react" @@ -32,7 +33,6 @@ const ItemFilter = ({ }: ItemFilterProps) => { const field = itemFilterData.field() const fieldFormatted = itemFilterData.field(true) - // const [isOpen, setIsOpen] = useState(false) const [selectedOptions, setSelectedOptions] = useState(selectedFilters[field]) const resetToAppliedOptions = () => { @@ -68,81 +68,70 @@ const ItemFilter = ({ } return ( - { - // if (!mobile) manageFilterDisplay('none'); - // }, - returnFocusOnDeactivate: false, - }} - active={isOpen} - className="item-filter" - > -
- - {isOpen && ( - <> - - {itemFilterData - .displayOptions() - .map(({ value, label }: Option) => { - return ( - - ) - })} - +
+ + {isOpen && ( + <> + + {itemFilterData.displayOptions().map(({ value, label }: Option) => { + return ( + + ) + })} + -
- - -
- - )} -
- + + + + + + )} +
) } diff --git a/src/components/ItemFilters/ItemFilters.test.tsx b/src/components/ItemFilters/ItemFilters.test.tsx index b1ca741cf..19d274a9a 100644 --- a/src/components/ItemFilters/ItemFilters.test.tsx +++ b/src/components/ItemFilters/ItemFilters.test.tsx @@ -8,31 +8,67 @@ import userEvent from "@testing-library/user-event" // Mock next router jest.mock("next/router", () => jest.requireActual("next-router-mock")) +const filterHasSelected = async (checkboxGroupButton, values: string[]) => { + await act(async () => { + await userEvent.click(checkboxGroupButton) + const selectedValues = values.map((label) => screen.getByLabelText(label)) + const checkboxes = screen.getAllByRole("checkbox", { checked: true }) + expect(checkboxes.length).toBe(values.length) + selectedValues.forEach((checkbox) => { + expect(checkbox).toBeChecked() + }) + }) +} + +const filterButtons = () => { + const locationCheckboxGroupButton = screen.getByTestId("location-item-filter") + const statusCheckboxGroupButton = screen.getByTestId("status-item-filter") + const formatCheckboxGroupButton = screen.getByTestId("format-item-filter") + + return [ + locationCheckboxGroupButton, + statusCheckboxGroupButton, + formatCheckboxGroupButton, + ] +} + +const filterNotSelected = async (checkboxGroupButton, values: string[]) => { + await act(async () => { + await userEvent.click(checkboxGroupButton) + const selectedValues = values.map((label) => screen.getByLabelText(label)) + selectedValues.forEach((checkbox) => { + expect(checkbox).not.toBeChecked() + }) + }) +} + describe("Filters container", () => { it("renders a single filter", () => { render() - const filters = screen.getAllByTestId("item-filter") + const filters = screen.getAllByTestId(/item-filter/) expect(filters.length).toBe(1) }) it("renders three filter boxes", () => { render() - const filters = screen.getAllByTestId("item-filter") + const filters = screen.getAllByTestId(/item-filter/) expect(filters.length).toBe(3) }) - it("loads the query into state", () => { + + it("loads the query into state", async () => { mockRouter.query = { item_location: "loc:rc2ma", item_format: "Text", item_status: "status:a", } render() - - const checkboxes = screen.getAllByRole("checkbox", { checked: true }) - expect(checkboxes.length).toBe(3) - const selectedValues = ["Available", "Text", "Offsite"].map((label) => - screen.getByLabelText(label) - ) - selectedValues.forEach((checkbox) => expect(checkbox).toBeChecked()) + const [ + locationCheckboxGroupButton, + statusCheckboxGroupButton, + formatCheckboxGroupButton, + ] = filterButtons() + await filterHasSelected(locationCheckboxGroupButton, ["Offsite"]) + await filterHasSelected(formatCheckboxGroupButton, ["Text"]) + await filterHasSelected(statusCheckboxGroupButton, ["Available"]) }) it("clears selection per filter", async () => { @@ -42,27 +78,32 @@ describe("Filters container", () => { item_status: "status:a,status:na", } render() - const clearStatusButton = screen.getByTestId("clear-status-button") - const selectedValues = [ + const [ + locationCheckboxGroupButton, + statusCheckboxGroupButton, + formatCheckboxGroupButton, + ] = filterButtons() + + // the values start selected + await filterHasSelected(locationCheckboxGroupButton, ["Offsite"]) + await filterHasSelected(formatCheckboxGroupButton, ["Text"]) + await filterHasSelected(statusCheckboxGroupButton, [ "Available", "Not available", - "Text", - "Offsite", - ].map((label) => screen.getByLabelText(label)) - // the values start selected - selectedValues.forEach((checkbox) => expect(checkbox).toBeChecked()) + ]) + const clearStatusButton = screen.getByTestId("clear-status-button") + await act(async () => { await userEvent.click(clearStatusButton) - const selectedValues = ["Text", "Offsite"].map((label) => - screen.getByLabelText(label) - ) - const deselectedValues = ["Available", "Not available"].map((label) => - screen.getByLabelText(label) - ) - // Format and location values should remain checked - selectedValues.forEach((checkbox) => expect(checkbox).toBeChecked()) + // these filters should be unchanged + await filterHasSelected(locationCheckboxGroupButton, ["Offsite"]) + await filterHasSelected(formatCheckboxGroupButton, ["Text"]) + // Status values should be unchecked - deselectedValues.forEach((checkbox) => expect(checkbox).not.toBeChecked()) + await filterNotSelected(statusCheckboxGroupButton, [ + "Available", + "Not available", + ]) }) }) }) diff --git a/src/utils/itemFilterUtils.ts b/src/utils/itemFilterUtils.ts index 4341bf944..d754b6424 100644 --- a/src/utils/itemFilterUtils.ts +++ b/src/utils/itemFilterUtils.ts @@ -1,11 +1,10 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import type { SelectedFilters } from "../types/filterTypes" export const isRecapLocation = (loc: string) => { - console.log(loc) return loc.split(":")[1].startsWith("rc") } -/* eslint-disable @typescript-eslint/naming-convention */ export const combineRecapLocations = (locations: string[]) => { if (locations.find(isRecapLocation)) { return [...locations.filter((loc) => !isRecapLocation(loc)), "Offsite"] @@ -37,7 +36,7 @@ export const buildItemFilterQueryParams = ( recapLocations: string ) => { const locs = location.map((loc) => { - if (isRecapLocation(loc)) return recapLocations + if (loc === "Offsite") return recapLocations else return loc }) const location_query = location.length diff --git a/src/utils/utilsTests/itemFilterUtils.test.tsx b/src/utils/utilsTests/itemFilterUtils.test.tsx index 3ce82a852..0b7203b0a 100644 --- a/src/utils/utilsTests/itemFilterUtils.test.tsx +++ b/src/utils/utilsTests/itemFilterUtils.test.tsx @@ -19,5 +19,21 @@ describe("Item Filter Utils", () => { const locations = ["loc:mab", "loc:rc2ma", "loc:rcma2", "loc:rcrc"] expect(combineRecapLocations(locations)).toEqual(["loc:mab", "Offsite"]) }) + it("does nothing if there are no recap locations", () => { + const locations = ["loc:mab", "loc:mac", "loc:spaghetti"] + expect(combineRecapLocations(locations)).toEqual([ + "loc:mab", + "loc:mac", + "loc:spaghetti", + ]) + }) + it("replaces offsite locations with no other locations", () => { + const locations = ["loc:rc2ma", "loc:rcma2", "loc:rcrc"] + expect(combineRecapLocations(locations)).toEqual(["Offsite"]) + }) + it("can handle an empty array", () => { + const locations = [] + expect(combineRecapLocations(locations)).toEqual([]) + }) }) }) From 88ca49cfb21ca929190259a1c4892e42dcccc49b Mon Sep 17 00:00:00 2001 From: Vera Kahn Date: Fri, 3 Nov 2023 15:53:13 -0400 Subject: [PATCH 15/26] clear local state on close --- src/components/ItemFilters/ItemFilter.tsx | 13 +- .../ItemFilters/ItemFilters.test.tsx | 170 ++++++------------ 2 files changed, 64 insertions(+), 119 deletions(-) diff --git a/src/components/ItemFilters/ItemFilter.tsx b/src/components/ItemFilters/ItemFilter.tsx index ac85bd56e..5c6ad510d 100644 --- a/src/components/ItemFilters/ItemFilter.tsx +++ b/src/components/ItemFilters/ItemFilter.tsx @@ -8,7 +8,7 @@ import { } from "@nypl/design-system-react-components" import type { Dispatch } from "react" import FocusTrap from "focus-trap-react" -import { useState } from "react" +import { useCallback, useEffect, useState } from "react" import type { ItemFilterData } from "../../models/itemFilterData" import type { Option, SelectedFilters } from "../../types/filterTypes" @@ -35,9 +35,9 @@ const ItemFilter = ({ const fieldFormatted = itemFilterData.field(true) const [selectedOptions, setSelectedOptions] = useState(selectedFilters[field]) - const resetToAppliedOptions = () => { + const resetToAppliedOptions = useCallback(() => { updateCheckboxGroupValue(selectedFilters[field]) - } + }, [selectedFilters, field]) const clearFilter = () => { setSelectedOptions([]) @@ -62,11 +62,14 @@ const ItemFilter = ({ } const openCloseHandler = () => { - resetToAppliedOptions() if (isOpen) toggleFilterDisplay("") else toggleFilterDisplay(field) } + useEffect(() => { + if (!isOpen) resetToAppliedOptions() + }, [isOpen, resetToAppliedOptions]) + return (
{isOpen && ( - <> +
- +
)}
) diff --git a/styles/components/ItemFilters.module.scss b/styles/components/ItemFilters.module.scss index 57237d822..1ac6e4425 100644 --- a/styles/components/ItemFilters.module.scss +++ b/styles/components/ItemFilters.module.scss @@ -1,5 +1,29 @@ @import "../utils/mixins"; -.filterOption { - background-color: tomato; +.itemFilterOptionsContainer { + position: absolute; + word-wrap: break-word; + z-index: 1; + background-color: white; + width: 230px; + padding: 10px; + border: 1px solid var(--nypl-colors-ui-gray-medium); +} + +.filtersContainer { + background-color: var(--nypl-colors-ui-gray-x-light-cool); + padding: 16px; + min-height: 128px; + z-index: 0; +} + +.filterGroup { + display: flex; +} + +.itemFilter { + background-color: white; + min-width: 125px; + display: inline-block; + margin-right: var(--nypl-space-xs); } From 4482dd0295a96763f5404e5d2e5a3ae07e1a76af Mon Sep 17 00:00:00 2001 From: Vera Kahn Date: Tue, 7 Nov 2023 11:10:19 -0500 Subject: [PATCH 19/26] out of focus closes filters --- .../ItemFilters/FiltersContainer.tsx | 7 +++++-- src/components/ItemFilters/ItemFilter.tsx | 20 ++++++------------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/components/ItemFilters/FiltersContainer.tsx b/src/components/ItemFilters/FiltersContainer.tsx index 76d98f64c..c68182653 100644 --- a/src/components/ItemFilters/FiltersContainer.tsx +++ b/src/components/ItemFilters/FiltersContainer.tsx @@ -28,6 +28,9 @@ const ItemFilterContainer = ({ itemAggs }: ItemFilterContainerProps) => { status: [], }) + const ref = useRef(null) + useCloseDropDown(() => setWhichFilterIsOpen(""), ref) + const [whichFilterIsOpen, setWhichFilterIsOpen] = useState("") const [tempQueryDisplay, setTempQueryDisplay] = useState("") @@ -55,11 +58,11 @@ const ItemFilterContainer = ({ itemAggs }: ItemFilterContainerProps) => { Filter by -
+
{filterData.map((field: ItemFilterData) => ( > isOpen: boolean - toggleFilterDisplay: Dispatch> + setWhichFilterIsOpen: Dispatch> } const ItemFilter = ({ @@ -29,15 +28,12 @@ const ItemFilter = ({ setSelectedFilters, selectedFilters, isOpen, - toggleFilterDisplay, + setWhichFilterIsOpen, }: ItemFilterProps) => { const field = itemFilterData.field() const fieldFormatted = itemFilterData.field(true) const [selectedOptions, setSelectedOptions] = useState(selectedFilters[field]) - // const ref = useRef(null) - // useCloseDropDown(() => toggleFilterDisplay(""), ref) - const resetToAppliedOptions = useCallback(() => { updateCheckboxGroupValue(selectedFilters[field]) }, [selectedFilters, field]) @@ -64,18 +60,14 @@ const ItemFilter = ({ setSelectedOptions(data) } - const openCloseHandler = () => { - if (isOpen) toggleFilterDisplay("") - else toggleFilterDisplay(field) - } - + // When the filter is close with unapplied options, those options are not + // persisted. Instead, reset to the options that were last queried for. useEffect(() => { if (!isOpen) resetToAppliedOptions() }, [isOpen, resetToAppliedOptions]) return (
- {/*
*/} + {isOpen && (
- - - - - +
)}
diff --git a/src/components/ItemFilters/ItemFilterButtons.tsx b/src/components/ItemFilters/ItemFilterButtons.tsx new file mode 100644 index 000000000..51a487497 --- /dev/null +++ b/src/components/ItemFilters/ItemFilterButtons.tsx @@ -0,0 +1,61 @@ +import { ButtonGroup, Button } from "@nypl/design-system-react-components" +import type { Dispatch } from "react" + +import type { AppliedFilters, Option } from "../../../src/types/filterTypes" + +interface ItemFilterButtonProps { + selectedOptions: string[] + field: string + setSelectedOptions: Dispatch> + setAppliedFilters: Dispatch> +} + +const ItemFilterButtons = ({ + selectedOptions, + field, + setSelectedOptions, + setAppliedFilters, +}: ItemFilterButtonProps) => { + const clearFilter = () => { + setSelectedOptions([]) + setAppliedFilters((prevFilters: AppliedFilters) => { + return { ...prevFilters, [field]: [] } + }) + } + + const applyFilter = () => { + let newFilterSelection: AppliedFilters + setAppliedFilters((prevFilters: AppliedFilters) => { + newFilterSelection = { + ...prevFilters, + [field]: selectedOptions, + } + return newFilterSelection + }) + } + return ( + + + + + ) +} + +export default ItemFilterButtons diff --git a/src/components/ItemFilters/ItemFilterLabel.tsx b/src/components/ItemFilters/ItemFilterLabel.tsx new file mode 100644 index 000000000..be63ffcc4 --- /dev/null +++ b/src/components/ItemFilters/ItemFilterLabel.tsx @@ -0,0 +1,43 @@ +import { Button, Icon, Spacer } from "@nypl/design-system-react-components" +import type { Dispatch } from "react" + +import styles from "../../../styles/components/ItemFilters.module.scss" +import type { Option } from "../../types/filterTypes" + +interface ItemFilterLabelProps { + setWhichFilterIsOpen: Dispatch> + field: string + selectedOptions: Option[] + isOpen: boolean +} + +const ItemFilterLabel = ({ + field, + selectedOptions, + setWhichFilterIsOpen, + isOpen, +}: ItemFilterLabelProps) => { + const fieldFormatted = field[0].toUpperCase() + field.substring(1) + return ( + + ) +} + +export default ItemFilterLabel diff --git a/src/components/ItemFilters/ItemFilters.test.tsx b/src/components/ItemFilters/ItemFilters.test.tsx index 36698b76f..d2df3a38c 100644 --- a/src/components/ItemFilters/ItemFilters.test.tsx +++ b/src/components/ItemFilters/ItemFilters.test.tsx @@ -96,7 +96,7 @@ describe("Filters container", () => { }) }) - it("persists previously applied selection after closing filter without applying", async () => { + it.only("persists previously applied selection after closing filter without applying", async () => { mockRouter.query = { item_location: "loc:rc2ma", item_format: "Text", @@ -125,7 +125,20 @@ describe("Filters container", () => { ]) }) }) + it("closes open filters when user clicks outside of the filter", async () => { + render() + const [locationFilterButton] = filterButtons() + const outsideOfTheFilter = screen.getByTestId("filter-text") + await act(async () => { + await userEvent.click(locationFilterButton) + const offsiteCheckbox = screen.getByText("Offsite") + await userEvent.click(offsiteCheckbox) + await userEvent.click(outsideOfTheFilter) + expect(offsiteCheckbox).not.toBeInTheDocument() + }) + }) }) + const filterHasSelected = async (checkboxGroupButton, values: string[]) => { await act(async () => { await userEvent.click(checkboxGroupButton) diff --git a/src/components/ItemFilters/README.md b/src/components/ItemFilters/README.md new file mode 100644 index 000000000..ed31a1490 --- /dev/null +++ b/src/components/ItemFilters/README.md @@ -0,0 +1,21 @@ +# Item Filters + +## Variable name conventions +- `option`: a combination of label and value. An option for the user to select for the filter. Such as { value: "loc:mal", label: "SASB Location"}. +- `value`: api readable string, used to create the search query +- `label`: human readable gloss for the value +- `selectedOptions`: locally selected options per filter that have not been applied +- `activeFilters`: the filters most recently sent to the discovery api and used to filter the items currently rendered +- `field`: the category of options. The three supported fields are `location`, `format`, and `status` + +## Filter state +DS `CheckboxGroup` is used to maintain `selectedOptions` per filter. The DS `Checkboxes` are controlled by the `CheckboxGroup`. +Clicking the `Apply` and `Clear` buttons dispatch the `selectedOptions` to the `FiltersContainer` component. Their `onClick`s dispatch new selections or an empty array in the case of `Clear`ing. The parent component is listening for changes to activeFilters. When those change, they are used to generate a new URL that triggers a discovery-api query using the new filters. +Closing a filter, whether by clicking the filter's label element, or by clicking outside of the filter, clears the local state's `selectedOptions`. + +## Open/Close state +Only one `ItemFilter` can be open at a time, so the `FiltersContainer` component only needs the field of the open filter (or an empty string). Each filter's `isOpen` state is then determined by whether `whichFilterIsOpen` corresponds to that filter. If `whichFilterIsOpen` is an empty string, no filter's field will match on it, so they will all be closed. + +While there is only one place where the open/close state is maintained, there are two different mechanisms that trigger a change in that state: +- Clicking on the `ItemFilter` dispatches either that filter's field or an empty string. +- Clicking outside of the `ItemFilter` or hitting the escape key triggers the `useCloseDropDown` hook. This hook is provided a callback which sets `whichFilterIsOpen` to an empty string. diff --git a/src/models/itemFilterData.ts b/src/models/itemFilterData.ts index 2a521b5d2..a283ffc08 100644 --- a/src/models/itemFilterData.ts +++ b/src/models/itemFilterData.ts @@ -9,20 +9,16 @@ import { isRecapLocation } from "../utils/itemFilterUtils" export class ItemFilterData { options: ItemAggregationOption[] agg: ItemAggregation + field: string constructor(agg: ItemAggregation) { this.agg = agg this.options = agg.values + this.field = agg.field } displayOptions(): Option[] { return this.options } - - field(formatted = false) { - const f = this.agg.field - const upperCased = f[0].toUpperCase() + f.substring(1) - return formatted ? upperCased : f - } } export class LocationFilterData extends ItemFilterData { diff --git a/src/types/filterTypes.ts b/src/types/filterTypes.ts index 2773f8bec..344488c49 100644 --- a/src/types/filterTypes.ts +++ b/src/types/filterTypes.ts @@ -1,6 +1,6 @@ export type Locations = string[] -export type SelectedFilters = { +export type AppliedFilters = { location: string[] format: string[] status: string[] diff --git a/src/utils/itemFilterUtils.ts b/src/utils/itemFilterUtils.ts index cc5654282..5a308809e 100644 --- a/src/utils/itemFilterUtils.ts +++ b/src/utils/itemFilterUtils.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import type { SelectedFilters } from "../types/filterTypes" +import type { AppliedFilters } from "../types/filterTypes" export const isRecapLocation = (loc: string) => { return loc.split(":")[1].startsWith("rc") @@ -32,7 +32,7 @@ export const parseItemFilterQueryParams = ({ } export const buildItemFilterQueryParams = ( - { location, format, status }: SelectedFilters, + { location, format, status }: AppliedFilters, recapLocations: string ) => { const locs = location.map((loc) => { diff --git a/styles/components/ItemFilters.module.scss b/styles/components/ItemFilters.module.scss index 1ac6e4425..26f2a48cb 100644 --- a/styles/components/ItemFilters.module.scss +++ b/styles/components/ItemFilters.module.scss @@ -19,11 +19,12 @@ .filterGroup { display: flex; + width: fit-content; } .itemFilter { background-color: white; - min-width: 125px; + width: 125px; display: inline-block; margin-right: var(--nypl-space-xs); } From 42bb6084d3c3b01b05a400ba7743111e1370c937 Mon Sep 17 00:00:00 2001 From: Vera Kahn Date: Wed, 8 Nov 2023 12:52:18 -0500 Subject: [PATCH 21/26] add applied filter string func --- src/models/itemFilterData.ts | 14 ++++++ src/utils/itemFilterUtils.ts | 26 ++++++++++ src/utils/utilsTests/itemFilterUtils.test.tsx | 49 +++++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/src/models/itemFilterData.ts b/src/models/itemFilterData.ts index a283ffc08..f2103842d 100644 --- a/src/models/itemFilterData.ts +++ b/src/models/itemFilterData.ts @@ -19,6 +19,20 @@ export class ItemFilterData { displayOptions(): Option[] { return this.options } + + labelForValue(value: string) { + return this.displayOptions().find((opt: Option) => { + return opt.value === value + })?.label + } + + labelsForConcatenatedValues(values: string) { + return Array.from( + new Set( + values.split(",").map((val: string) => `'${this.labelForValue(val)}'`) + ) + ).join(", ") + } } export class LocationFilterData extends ItemFilterData { diff --git a/src/utils/itemFilterUtils.ts b/src/utils/itemFilterUtils.ts index 5a308809e..33b61d4b4 100644 --- a/src/utils/itemFilterUtils.ts +++ b/src/utils/itemFilterUtils.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import type { ItemFilterData } from "../models/itemFilterData" import type { AppliedFilters } from "../types/filterTypes" export const isRecapLocation = (loc: string) => { @@ -48,3 +49,28 @@ export const buildItemFilterQueryParams = ( const query = encodeURI(`?${location_query}${format_query}${status_query}`) return query.length > 3 ? query : "" } + +export const buildAppliedFiltersString = ( + query: BibPageQueryParams, + numItems = 20, + itemAggs: ItemFilterData[] +) => { + const items = `Item${numItems === 1 ? "" : "s"}` + if (Object.keys(query).length === 0) return `${numItems} ${items}` + const num = numItems === 0 ? "No" : numItems + const numMatchingItems = `${num} ${items} Matching ` + const filters = Object.keys(query) + .map((field: string) => { + const queryPerField = query[field] + if (queryPerField) { + const fieldAggregations = itemAggs.find( + (agg: ItemFilterData) => agg.field === field.substring(5) + ) + const labels = + fieldAggregations.labelsForConcatenatedValues(queryPerField) + return field.substring(5) + ": " + labels + } + }) + .filter((filter) => filter) + return numMatchingItems + "Filtered by " + filters.join(", ") +} diff --git a/src/utils/utilsTests/itemFilterUtils.test.tsx b/src/utils/utilsTests/itemFilterUtils.test.tsx index 4ae418de1..c4b6152ce 100644 --- a/src/utils/utilsTests/itemFilterUtils.test.tsx +++ b/src/utils/utilsTests/itemFilterUtils.test.tsx @@ -3,7 +3,10 @@ import { combineRecapLocations, parseItemFilterQueryParams, buildItemFilterQueryParams, + buildAppliedFiltersString, } from "../itemFilterUtils" +import testAggregations from "../../../__test__/fixtures/testAggregations" +import { ItemFilterData } from "../../models/itemFilterData" describe("Item Filter Utils", () => { describe("isRecapLocation", () => { @@ -86,4 +89,50 @@ describe("Item Filter Utils", () => { ) }) }) + + describe("buildAppliedFiltersString", () => { + const query = { + item_location: "loc:rc2ma,loc:rcma2", + item_status: "status:a", + item_format: "Text", + } + const aggs = testAggregations.map((agg) => new ItemFilterData(agg)) + it("can handle no filters", () => { + expect(buildAppliedFiltersString({}, 30, [])).toBe("30 Items") + }) + it("no items with filters", () => { + const query = { + item_location: "loc:rc2ma,loc:rcma2", + item_status: "status:a", + item_format: "Text", + } + expect(buildAppliedFiltersString(query, 0, aggs)).toBe( + "No Items Matching Filtered by location: 'Offsite', status: 'Available', format: 'Text'" + ) + }) + it("some items no filters", () => { + expect(buildAppliedFiltersString({}, 5, aggs)).toBe("5 Items") + }) + it("some items with filters", () => { + expect(buildAppliedFiltersString(query, 5, aggs)).toBe( + "5 Items Matching Filtered by location: 'Offsite', status: 'Available', format: 'Text'" + ) + }) + it("one item with filters", () => { + expect(buildAppliedFiltersString(query, 1, aggs)).toBe( + "1 Item Matching Filtered by location: 'Offsite', status: 'Available', format: 'Text'" + ) + }) + it("some items one filter", () => { + expect( + buildAppliedFiltersString( + { item_status: "status:a,status:na" }, + 5, + aggs + ) + ).toBe( + "5 Items Matching Filtered by status: 'Available', 'Not available'" + ) + }) + }) }) From e2f18ccc1f86b3cf260d23bc60bc2d04c8e0fac4 Mon Sep 17 00:00:00 2001 From: Vera Kahn Date: Wed, 8 Nov 2023 15:33:28 -0500 Subject: [PATCH 22/26] style fixes --- __test__/fixtures/testAggregations.js | 129 +++++++++++++++++- pages/search/advanced.tsx | 4 +- .../ItemFilters/ItemFilterButtons.tsx | 4 +- .../ItemFilters/ItemFilterLabel.tsx | 4 +- .../ItemFilters/ItemFilters.test.tsx | 95 ++++++------- src/utils/itemFilterUtils.ts | 9 +- src/utils/utilsTests/itemFilterUtils.test.tsx | 8 +- styles/components/ItemFilters.module.scss | 4 +- 8 files changed, 184 insertions(+), 73 deletions(-) diff --git a/__test__/fixtures/testAggregations.js b/__test__/fixtures/testAggregations.js index 7051400c7..f76108e37 100644 --- a/__test__/fixtures/testAggregations.js +++ b/__test__/fixtures/testAggregations.js @@ -1,4 +1,4 @@ -const aggs = [ +export const normalAggs = [ { "@type": "nypl:Aggregation", "@id": "res:location", @@ -80,4 +80,129 @@ const aggs = [ }, ] -export default aggs +export const aggsWithRepeatedValues = [ + { + "@type": "nypl:Aggregation", + "@id": "res:location", + id: "location", + field: "location", + values: [ + { + value: "loc:mym32", + count: 8, + label: "Performing Arts Research Collections - Music", + }, + ], + }, + { + "@type": "nypl:Aggregation", + "@id": "res:format", + id: "format", + field: "format", + values: [], + }, + { + "@type": "nypl:Aggregation", + "@id": "res:status", + id: "status", + field: "status", + values: [ + { + value: "status:a", + count: 4, + label: "Available", + }, + { + value: "status:a", + count: 4, + label: "Available ", + }, + ], + }, +] + +export const aggsWithMissingProperties = [ + { + "@id": "res:location", + "@type": "nypl:Aggregation", + field: "location", + id: "location", + values: [ + { + count: 4, + value: "loc:maj03", + label: "SASB M1 - General Research - Room 315", + }, + { + count: 12, + label: "Offsite", + value: "loc:rc2ma", + }, + { + count: 12, + label: "Off site", + value: "loc:rc2ma", + }, + { + count: 12, + label: "Off-site", + value: "loc:rc2ma", + }, + { + count: 12, + label: "off-site", + value: "loc:rc2ma", + }, + { + count: 12, + label: "off site", + value: "loc:rc2ma", + }, + { + count: 2, + value: "offsite", + label: "Offsite", + }, + { + count: 2, + value: "blank", + label: "", + }, + { + count: 2, + value: "blaaaank", + }, + ], + }, + { + "@id": "res:format", + "@type": "nypl:Aggregation", + field: "format", + id: "format", + values: [ + { + count: 12, + label: "Text", + value: "Text", + }, + ], + }, + { + "@id": "res:status", + "@type": "nypl:Aggregation", + field: "status", + id: "status", + values: [ + { + count: 12, + label: "Available", + value: "status:a", + }, + { + count: 12, + label: "Not Available (ReCAP", + value: "status:na", + }, + ], + }, +] diff --git a/pages/search/advanced.tsx b/pages/search/advanced.tsx index f5f2e8dd6..aebb66317 100644 --- a/pages/search/advanced.tsx +++ b/pages/search/advanced.tsx @@ -39,7 +39,7 @@ import type { import { getQueryString } from "../../src/utils/searchUtils" import ItemFilterContainer from "../../src/components/ItemFilters/FiltersContainer" -import testItemAggs from "../../__test__/fixtures/testAggregations" +import { normalAggs } from "../../__test__/fixtures/testAggregations" /** * The Advanced Search page is responsible for displaying the Advanced Search form fields and @@ -125,7 +125,7 @@ export default function AdvancedSearch() { } /> )} - + Advanced Search Clear