Skip to content

Commit

Permalink
[ALS-7696] Nested Facets (#296)
Browse files Browse the repository at this point in the history
  • Loading branch information
JamesPeck authored Nov 14, 2024
1 parent e09f982 commit 14cc8bd
Show file tree
Hide file tree
Showing 7 changed files with 377 additions and 68 deletions.
1 change: 1 addition & 0 deletions src/lib/assets/dash.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
124 changes: 85 additions & 39 deletions src/lib/components/explorer/FacetCategory.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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) &&
Expand All @@ -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);
Expand All @@ -77,24 +141,9 @@
/>
{/if}
{#each facetsToDisplay as facet}
<label data-testId={`facet-${facet.name}-label`} for={facet.name} class="m-1">
<input
type="checkbox"
class="&[aria-disabled=“true”]:opacity-75"
id={facet.name}
name={facet.name}
value={facet}
checked={isChecked(facet.name)}
disabled={facet.count === 0}
aria-checked={isChecked(facet.name)}
on:click={() => updateFacet(facet, facetCategory)}
/>
<span class:opacity-75={facet.count === 0}
>{`${facet.display} (${facet.count?.toLocaleString()})`}</span
>
</label>
<FacetItem {facet} {facetCategory} facetParent={undefined} {textFilterValue} />
{/each}
{#if facets.length > numFacetsToShow && !textFilterValue}
{#if facets?.length > numFacetsToShow && !textFilterValue}
<button
data-testId="show-more-facets"
class="show-more w-fit mx-auto my-1"
Expand All @@ -116,10 +165,7 @@
<span class="overflow-hidden text-ellipsis whitespace-nowrap min-w-0">
{facet.display}
</span>
<button
class="chip-close ml-1 flex-shrink-0"
on:click={() => updateFacet(facet, facetCategory)}
>
<button class="chip-close ml-1 flex-shrink-0" on:click={() => updateFacets([facet])}>
<i class="fa-solid fa-times hover:text-secondary-500"></i>
</button>
</span>
Expand Down
144 changes: 144 additions & 0 deletions src/lib/components/explorer/FacetItem.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<script lang="ts">
import type { DictionaryFacetResult } from '$lib/models/api/DictionaryResponses';
import type { Facet } from '$lib/models/Search';
import SearchStore from '$lib/stores/Search';
let { updateFacets, selectedFacets } = SearchStore;
export let facet: Facet;
export let facetCategory: DictionaryFacetResult;
export let facetParent: Facet | undefined;
export let textFilterValue: string | undefined;
if (facetParent) {
facet.parentRef = {
name: facetParent.name,
display: facetParent.display,
description: facetParent.description,
};
}
if (facetCategory) {
facet.categoryRef = {
name: facetCategory.name,
display: facetCategory.display,
description: facetCategory.description,
};
}
let open = false;
function toggleOpen() {
open = !open;
}
function getCurrentlySelectedChildren() {
return (
facet.children?.filter((child) => $selectedFacets.some((f) => f.name === child.name)) ?? []
);
}
function onClick() {
if (facet?.children && facet?.children?.length > 0) {
if (checkedState(facet.name) === 'indeterminate') {
updateFacets(getCurrentlySelectedChildren());
} else {
updateFacets(facet.children);
}
} else {
updateFacets([facet]);
}
}
$: checkedState = (facetToCheck: string) => {
const atLeastOneChildSelected =
facet.children &&
facet.children.some((child) => $selectedFacets.some((f) => f.name === child.name));
let isEveryChildSelected = false;
if (atLeastOneChildSelected) {
isEveryChildSelected =
facet.children?.every((child) => $selectedFacets.some((f) => f.name === child.name)) ??
false;
}
const isIndeterminate = atLeastOneChildSelected && !isEveryChildSelected;
const isSelected =
$selectedFacets.some((facet) => facet.name === facetToCheck) || isEveryChildSelected;
if (isSelected) return 'true';
if (isIndeterminate) return 'indeterminate';
return 'false';
};
$: checked = checkedState(facet.name) === 'true';
$: facetsToDisplay = facet.children
? [
...facet.children
.filter((child) => {
const matchesFilter =
!textFilterValue ||
child.display.toLowerCase().includes(textFilterValue.toLowerCase()) ||
child.name.toLowerCase().includes(textFilterValue.toLowerCase()) ||
child.description?.toLowerCase().includes(textFilterValue.toLowerCase());
return matchesFilter && $selectedFacets.some((f) => f.name === child.name);
})
.sort((a, b) => (b.count || 0) - (a.count || 0)),
...facet.children
.filter((child) => {
const matchesFilter =
!textFilterValue ||
child.display.toLowerCase().includes(textFilterValue.toLowerCase()) ||
child.name.toLowerCase().includes(textFilterValue.toLowerCase()) ||
child.description?.toLowerCase().includes(textFilterValue.toLowerCase());
return matchesFilter && !$selectedFacets.some((f) => f.name === child.name);
})
.sort((a, b) => (b.count || 0) - (a.count || 0)),
]
: [];
</script>

<label data-testId={`facet-${facet.name}-label`} for={facet.name} class="m-1">
{#if facet?.children !== undefined && facet?.children?.length > 0}
<button
type="button"
class="arrow-button"
data-testId={`facet-${facet.name}-arrow`}
on:click={toggleOpen}
>
<i class="fa-solid {open ? 'fa-angle-down' : 'fa-angle-right'}"></i>
</button>
{/if}
<input
type="checkbox"
class={`&[aria-disabled=“true”]:opacity-75 ${checkedState(facet.name)}`}
id={facet.name}
name={facet.name}
value={facet}
{checked}
disabled={facet.count === 0}
aria-checked={checked}
on:click={onClick}
/>
<span class:opacity-75={facet.count === 0}
>{`${facet.display} (${facet.count?.toLocaleString()})`}</span
>
</label>
{#if open && facetsToDisplay !== undefined && facetsToDisplay?.length > 0}
<div class="flex flex-col ml-4" data-testId={`facet-${facet.name}-children`}>
{#each facetsToDisplay as child}
<svelte:self facet={child} {facetCategory} facetParent={facet} {textFilterValue} />
{/each}
</div>
{/if}

<style lang="postcss">
input.indeterminate {
background-image: url('$lib/assets/dash.svg');
background-size: 100% 100%;
background-repeat: no-repeat;
background-position: center;
background-color: rgb(var(--color-primary-500));
}
.arrow-button {
background-color: transparent;
}
</style>
1 change: 1 addition & 0 deletions src/lib/models/Search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type Facet = Indexable & {
children?: Facet[];
category: string;
categoryRef?: ShallowFacetCategory;
parentRef?: ShallowFacetCategory;
};

export type ShallowFacetCategory = Pick<Facet, 'name' | 'display' | 'description'>;
Expand Down
43 changes: 14 additions & 29 deletions src/lib/stores/Search.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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() {
Expand All @@ -75,6 +60,6 @@ export default {
searchTerm,
error,
search,
updateFacet,
updateFacets,
resetSearch,
};
Loading

0 comments on commit 14cc8bd

Please sign in to comment.