From 14cc8bd17d54b66c13e779811e8d4bf6892d594e Mon Sep 17 00:00:00 2001 From: James Date: Thu, 14 Nov 2024 15:51:00 -0500 Subject: [PATCH] [ALS-7696] Nested Facets (#296) --- src/lib/assets/dash.svg | 1 + .../components/explorer/FacetCategory.svelte | 124 ++++++++++----- src/lib/components/explorer/FacetItem.svelte | 144 ++++++++++++++++++ src/lib/models/Search.ts | 1 + src/lib/stores/Search.ts | 43 ++---- tests/mock-data.ts | 90 +++++++++++ tests/routes/explorer/facets/test.ts | 42 +++++ 7 files changed, 377 insertions(+), 68 deletions(-) create mode 100644 src/lib/assets/dash.svg create mode 100644 src/lib/components/explorer/FacetItem.svelte diff --git a/src/lib/assets/dash.svg b/src/lib/assets/dash.svg new file mode 100644 index 00000000..c571a11b --- /dev/null +++ b/src/lib/assets/dash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/components/explorer/FacetCategory.svelte b/src/lib/components/explorer/FacetCategory.svelte index e93fb371..0d59dd73 100644 --- a/src/lib/components/explorer/FacetCategory.svelte +++ b/src/lib/components/explorer/FacetCategory.svelte @@ -2,18 +2,19 @@ import type { DictionaryFacetResult } from '$lib/models/api/DictionaryResponses'; import { AccordionItem } from '@skeletonlabs/skeleton'; import SearchStore from '$lib/stores/Search'; - import type { Facet } from '$lib/models/Search'; import { hiddenFacets } from '$lib/services/dictionary'; - let { updateFacet, selectedFacets } = SearchStore; + import FacetItem from './FacetItem.svelte'; + import type { Facet } from '$lib/models/Search'; + let { updateFacets, selectedFacets } = SearchStore; export let facetCategory: DictionaryFacetResult; export let facets = facetCategory.facets; export let numFacetsToShow: number = 5; - export let shouldShowSearchBar: boolean = facets.length > numFacetsToShow; + export let shouldShowSearchBar: boolean = facets?.length > numFacetsToShow; - const anyFacetNot0 = facets.some((facet) => facet.count > 0); + const anyFacetNot0 = facets?.some((facet) => facet.count > 0); let textFilterValue: string; - let moreThanTenFacets = facets.length > numFacetsToShow; + let moreThanTenFacets = facets?.length > numFacetsToShow; $: facetsToDisplay = (facets || textFilterValue || moreThanTenFacets || $selectedFacets || facetCategory) && @@ -23,37 +24,100 @@ (facet) => facet?.categoryRef?.name === facetCategory?.name, ); - $: isChecked = (facetToCheck: string) => { - return $selectedFacets.some((facet: Facet) => { - return facet.name === facetToCheck; + function isIndeterminate(facet: Facet): boolean { + const atLeastOneChildSelected = + facet.children?.some((child) => $selectedFacets.some((f) => f.name === child.name)) ?? false; + const isEveryChildSelected = facet.children?.length + ? facet.children.every((child) => $selectedFacets.some((f) => f.name === child.name)) + : false; + return atLeastOneChildSelected && !isEveryChildSelected; + } + + function isParentFullySelected(facetName: string): boolean { + const result = facets.some((parent) => { + if (!parent.children || parent?.children?.length === 0) return false; + return parent.children.every( + (child) => child.name === facetName || $selectedFacets.some((f) => f.name === child.name), + ); }); - }; + return result; + } function getFacetsToDisplay() { const hiddenFacetsForCategory = $hiddenFacets[facetCategory.name] || []; let facetsToDisplay = facets.filter((f) => !hiddenFacetsForCategory.includes(f.name)); - //Put selected facets at the top const selectedFacetsMap = new Map($selectedFacets.map((facet) => [facet.name, facet])); - facetsToDisplay = facetsToDisplay.filter((f) => !selectedFacetsMap.has(f.name)); + const indeterminateFacets = facetsToDisplay.filter(isIndeterminate); + const indeterminateMap = new Map(indeterminateFacets.map((facet) => [facet.name, facet])); + const isChildOfIndeterminate = (facetName: string) => { + return indeterminateFacets.some((parent) => + parent.children?.some((child) => child.name === facetName), + ); + }; + + // Remove facets that will be added to the top or are children of fully selected parents + facetsToDisplay = facetsToDisplay.filter( + (f) => + !selectedFacetsMap.has(f.name) && + !isChildOfIndeterminate(f.name) && + !indeterminateMap.has(f.name) && + !isParentFullySelected(f.name), + ); + + // Add selected facets at the top (excluding children of indeterminate parents and fully selected parents) const selectedFacetsForCategory = $selectedFacets.filter( - (facet) => facet.category === facetCategory.name, + (facet) => + facet.category === facetCategory.name && + !isChildOfIndeterminate(facet.name) && + !isParentFullySelected(facet.name), ); selectedFacetsForCategory.forEach((facet) => { facet.count = facets.find((f) => f.name === facet.name)?.count || 0; }); - facetsToDisplay.unshift(...selectedFacetsForCategory); + //Add parents with all children selected + const parentsWithAllChildrenSelected = facets.filter( + (f) => + f.children?.length && + f.children.every((child) => $selectedFacets.some((f) => f.name === child.name)), + ); + parentsWithAllChildrenSelected.forEach((facet) => { + facet.count = facets.find((f) => f.name === facet.name)?.count || 0; + }); + + // Add indeterminate facets at the top + const indeterminateFacetsForCategory = indeterminateFacets.filter( + (facet) => facet.category === facetCategory.name, + ); + indeterminateFacetsForCategory.forEach((facet) => { + facet.count = facets.find((f) => f.name === facet.name)?.count || 0; + }); + + facetsToDisplay.unshift( + ...selectedFacetsForCategory, + ...parentsWithAllChildrenSelected, + ...indeterminateFacetsForCategory, + ); + if (textFilterValue) { - //Filter Facets by searched text const lowerFilterValue = textFilterValue.toLowerCase(); - facetsToDisplay = facetsToDisplay.filter( - (facet) => + facetsToDisplay = facetsToDisplay.filter((facet) => { + const facetMatches = facet.display.toLowerCase().includes(lowerFilterValue) || facet.name.toLowerCase().includes(lowerFilterValue) || - facet.description?.toLowerCase().includes(lowerFilterValue), - ); + facet.description?.toLowerCase().includes(lowerFilterValue); + + const childrenMatch = facet.children?.some( + (child) => + child.display.toLowerCase().includes(lowerFilterValue) || + child.name.toLowerCase().includes(lowerFilterValue) || + child.description?.toLowerCase().includes(lowerFilterValue), + ); + + return facetMatches || childrenMatch; + }); } else if (moreThanTenFacets) { // Only show the first n facets facetsToDisplay = facetsToDisplay.slice(0, numFacetsToShow); @@ -77,24 +141,9 @@ /> {/if} {#each facetsToDisplay as facet} - + {/each} - {#if facets.length > numFacetsToShow && !textFilterValue} + {#if facets?.length > numFacetsToShow && !textFilterValue} diff --git a/src/lib/components/explorer/FacetItem.svelte b/src/lib/components/explorer/FacetItem.svelte new file mode 100644 index 00000000..49326206 --- /dev/null +++ b/src/lib/components/explorer/FacetItem.svelte @@ -0,0 +1,144 @@ + + + +{#if open && facetsToDisplay !== undefined && facetsToDisplay?.length > 0} +
+ {#each facetsToDisplay as child} + + {/each} +
+{/if} + + diff --git a/src/lib/models/Search.ts b/src/lib/models/Search.ts index e234fa6e..bb8c7793 100644 --- a/src/lib/models/Search.ts +++ b/src/lib/models/Search.ts @@ -8,6 +8,7 @@ export type Facet = Indexable & { children?: Facet[]; category: string; categoryRef?: ShallowFacetCategory; + parentRef?: ShallowFacetCategory; }; export type ShallowFacetCategory = Pick; diff --git a/src/lib/stores/Search.ts b/src/lib/stores/Search.ts index 9f19dda5..1819f49a 100644 --- a/src/lib/stores/Search.ts +++ b/src/lib/stores/Search.ts @@ -1,9 +1,6 @@ import { get, writable, type Writable } from 'svelte/store'; import { type Facet, type SearchResult } from '$lib/models/Search'; -import type { - DictionaryConceptResult, - DictionaryFacetResult, -} from '$lib/models/api/DictionaryResponses'; +import type { DictionaryConceptResult } from '$lib/models/api/DictionaryResponses'; import type { State } from '@vincjo/datatables/remote'; import { searchDictionary } from '$lib/services/dictionary'; @@ -38,30 +35,18 @@ async function search(searchTerm: string, facets: Facet[], state?: State): Promi } } -async function updateFacet(newFacet: Facet, facetCategory: DictionaryFacetResult | undefined) { - if (facetCategory) { - newFacet.categoryRef = { - display: facetCategory.display, - name: facetCategory.name, - description: facetCategory.description, - }; - } - try { - selectedFacets.update((facets) => { - const index = facets.findIndex((facet) => facet.name === newFacet.name); - if (index === -1) { - facets.push(newFacet); - } else { - facets.splice(index, 1); - } - //For reactivity and sorting - selectedFacets.set(get(selectedFacets).sort((a, b) => b.count - a.count)); - return facets; - }); - } catch (e) { - console.error(e); - return; - } +async function updateFacets(facetsToUpdate: Facet[]) { + const currentFacets = get(selectedFacets); + facetsToUpdate.forEach((facet) => { + const facetIndex = currentFacets.findIndex((f) => f.name === facet.name); + if (facetIndex !== -1) { + currentFacets.splice(facetIndex, 1); + } else { + currentFacets.push(facet); + } + }); + + selectedFacets.set(currentFacets.sort((a, b) => b.count - a.count)); } export function resetSearch() { @@ -75,6 +60,6 @@ export default { searchTerm, error, search, - updateFacet, + updateFacets, resetSearch, }; diff --git a/tests/mock-data.ts b/tests/mock-data.ts index bddaa840..b0851440 100644 --- a/tests/mock-data.ts +++ b/tests/mock-data.ts @@ -697,6 +697,96 @@ export const facetsResponse = [ }, ]; +export const nestedFacetsResponse = [ + ...facetsResponse, + { + name: 'nested_category', + display: 'Nested Category', + description: 'Nested Category Description', + facets: [ + { + name: 'nested_facet', + display: 'Nested Facet', + description: 'Nested Facet Description', + count: 8, + children: [ + { + name: 'nested_facet_child', + display: 'Nested Facet Child', + description: 'Nested Facet Child Description', + count: 2, + children: null, + }, + { + name: 'nested_facet_child_2', + display: 'Nested Facet Child 2', + description: 'Nested Facet Child 2 Description', + count: 5, + children: null, + }, + { + name: 'nested_facet_child_3', + display: 'Nested Facet Child 3', + description: 'Nested Facet Child 3 Description', + count: 1, + children: null, + }, + ], + }, + { + name: 'nested_facet_2', + display: 'Nested Facet 2', + description: 'Nested Facet 2 Description', + count: 1, + children: null, + }, + { + name: 'nested_facet_3', + display: 'Nested Facet 3', + description: 'Nested Facet 3 Description', + count: 10, + children: [ + { + name: 'nested_facet_child_4', + display: 'Nested Facet Child 4', + description: 'Nested Facet Child 4 Description', + count: 1, + children: null, + }, + { + name: 'nested_facet_child_5', + display: 'Nested Facet Child 5', + description: 'Nested Facet Child 5 Description', + count: 1, + children: null, + }, + { + name: 'nested_facet_child_6', + display: 'Nested Facet Child 6', + description: 'Nested Facet Child 6 Description', + count: 5, + children: null, + }, + { + name: 'nested_facet_child_7', + display: 'Nested Facet Child 7', + description: 'Nested Facet Child 7 Description', + count: 3, + children: null, + }, + ], + }, + { + name: 'nested_facet_4', + display: 'Nested Facet 4', + description: 'Nested Facet 4 Description', + count: 300, + children: null, + }, + ], + }, +]; + const _application = { app1: { uuid: 'a1234', diff --git a/tests/routes/explorer/facets/test.ts b/tests/routes/explorer/facets/test.ts index bb2529b1..e64c51b6 100644 --- a/tests/routes/explorer/facets/test.ts +++ b/tests/routes/explorer/facets/test.ts @@ -5,6 +5,7 @@ import { facetsResponse, searchResultPath, facetResultPath, + nestedFacetsResponse, } from '../../../mock-data'; const MAX_FACETS_TO_SHOW = 5; @@ -496,3 +497,44 @@ test.describe('Facet & search', () => { await expect(spanInInput).toContainText(facetsResponse[0].facets[0].count.toString()); }); }); + +test.describe('Nested Facets', () => { + test('Nested facets are displayed', async ({ page }) => { + // Given + await page.route(searchResultPath, async (route: Route) => + route.fulfill({ json: searchResults }), + ); + await page.route(facetResultPath, async (route: Route) => + route.fulfill({ json: nestedFacetsResponse }), + ); + await page.goto('/explorer?search=age'); + + // When + const nestedCategory = page.getByText('Nested Category'); + const nestedFacetArrow = page.getByTestId('facet-nested_facet-arrow'); + + // Then + await expect(nestedCategory).toBeVisible(); + await expect(nestedFacetArrow).toBeVisible(); + }); + test('Nested facets are toggleable', async ({ page }) => { + // Given + await page.route(searchResultPath, async (route: Route) => + route.fulfill({ json: searchResults }), + ); + await page.route(facetResultPath, async (route: Route) => + route.fulfill({ json: nestedFacetsResponse }), + ); + await page.goto('/explorer?search=age'); + + // When + const nestedFacetArrow = page.getByTestId('facet-nested_facet-arrow'); + await nestedFacetArrow.click(); + + // Then + const nestedFacetChildren = page.getByTestId('facet-nested_facet-children'); + await expect(nestedFacetChildren).toBeVisible(); + await nestedFacetArrow.click(); + await expect(nestedFacetChildren).not.toBeVisible(); + }); +});