Skip to content

Commit

Permalink
Begin testing InstantSearch for profiles root
Browse files Browse the repository at this point in the history
  • Loading branch information
chadokruse committed Dec 1, 2024
1 parent f0ee0a4 commit 50b29ef
Show file tree
Hide file tree
Showing 13 changed files with 737 additions and 261 deletions.
3 changes: 2 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@
"@algolia/autocomplete-theme-classic": "^1.17.6",
"@fontsource/inter": "^5.0.16",
"@fontsource/open-sans": "^5.0.22",
"algoliasearch": "^5.12.0",
"algoliasearch": "^5.15.0",
"chart.js": "^4.4.5",
"instantsearch.js": "^4.75.5",
"isomorphic-dompurify": "^2.16.0",
"svelte-copy": "^2.0.0",
"svelte-french-toast": "^1.2.0",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/assets/images/algolia-mark-blue.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions apps/web/src/lib/components/profiles/ContentBoxWrapper.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { inview, type Options } from 'svelte-inview';
import { setActiveSection } from './sidenav/ActiveLink.svelte';
import { slugify } from '@repo/shared/functions/formatters/names';
import { navItems, type SideNavIds } from '$lib/utils/trustedConstants';
import { profileNavItems, type SideNavIds } from '$lib/utils/trustedConstants';
interface Props {
id: SideNavIds;
Expand All @@ -19,7 +19,7 @@
// Not all section headers have sidenav links
// Only activate scrollspy if the section is also a sidenav link
const isNavItem = navItems.some((item) => slugify(item.title).toLowerCase() === slugify(id).toLowerCase());
const isNavItem = profileNavItems.some((item) => slugify(item.title).toLowerCase() === slugify(id).toLowerCase());
</script>

<div
Expand Down
5 changes: 2 additions & 3 deletions apps/web/src/lib/components/profiles/root/ListItem.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
<script lang="ts">
/**
* https://tailwindui.com/components/application-ui/lists/stacked-lists
*/
interface Props {
name: string;
ein: string;
Expand Down Expand Up @@ -32,6 +29,8 @@
let url = $derived('/profiles/v0/' + ein);
</script>

<!-- https://tailwindui.com/components/application-ui/lists/stacked-lists -->

<li class="flex items-center justify-between gap-x-6 py-5 odd:bg-white even:bg-slate-50">
<div class="min-w-0">
<div class="flex items-start gap-x-3">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import algoliaLogo from '$lib/assets/images/Algolia-logo-blue.svg';
import { formatEin } from '@repo/shared/functions/formatters/ein';
import staticData from '@repo/shared/data/public/autocomplete-static-data.json';
import type { GrantmakersExtractedDataObj } from '@repo/shared/typings/grantmakers/all';
import type { BaseItem } from '@algolia/autocomplete-core';
import type { AutocompleteInstance } from '@repo/shared/typings/algolia/autocomplete';
import type { HTMLTemplate } from '@algolia/autocomplete-shared';
Expand All @@ -25,7 +26,7 @@
interface AlgoliaProfilesItem extends BaseItem, AlgoliaProfilesResponseLegacy {}
interface AlgoliaItemTemplateProps {
item: AlgoliaProfilesItem;
item: GrantmakersExtractedDataObj;
html: HTMLTemplate;
}
Expand Down Expand Up @@ -99,7 +100,7 @@
placeholder: 'Quick search...',
openOnFocus: true,
classNames: {
detachedFormContainer: '!rounded-t-2xl !border-b-0 !bg-slate-200',
detachedFormContainer: '!lg:rounded-t-2xl !border-b-0 !bg-slate-200',
detachedSearchButton: '!hidden', // Hide the navbar search box and use our SSR placeholder search box as the trigger.
detachedSearchButtonIcon: '!text-slate-500',
detachedSearchButtonPlaceholder: 'text-slate-500 text-sm',
Expand Down Expand Up @@ -170,6 +171,8 @@
});
</script>

<!-- https://tailwindui.com/components/application-ui/navigation/command-palettes -->

<!-- SSR Placeholder -->
<!-- This mimics the Autocomplete-generated search box -->
<div class="relative mt-0 rounded-md shadow-sm transition-opacity duration-200">
Expand Down
170 changes: 170 additions & 0 deletions apps/web/src/lib/components/search/FoundationSearch.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import instantsearch, { type InstantSearch } from 'instantsearch.js';
import { searchBox, hits, poweredBy, configure } from 'instantsearch.js/es/widgets';
import { formatEin } from '@repo/shared/functions/formatters/ein';
import type { FacetHits, SearchResponses } from '@algolia/client-search';
import type { LiteClient } from 'algoliasearch/lite';
import placeholderHits from '@repo/shared/data/public/autocomplete-static-data.json';
import { PUBLIC_ALGOLIA_APP_ID, PUBLIC_ALGOLIA_SEARCH_ONLY_KEY, PUBLIC_ALGOLIA_INDEX_NAME } from '$env/static/public';
import { normalizeCurrencyToMillions } from '@repo/shared/functions/formatters/numbers';
type AlgoliaInstance = InstantSearch;
interface CustomSearchRequest {
indexName: string;
params: {
highlightPostTag: string;
highlightPreTag: string;
hitsPerPage: number;
query: string;
};
}
type T = Record<string, unknown> | FacetHits;
interface Props {
onAlgoliaInit: (instance: AlgoliaInstance) => void;
}
type PlaceholderHit = (typeof placeholderHits)[number];
let { onAlgoliaInit }: Props = $props();
let searchClient: LiteClient;
const indexName = PUBLIC_ALGOLIA_INDEX_NAME;
let algoliaInstance: AlgoliaInstance;
// let percentile: number | 'N/A' = (rank, total): Rank => {
// return rank !== undefined ? ((total - rank) / total) * 100 : 'N/A';
// };
const getLabel = (pct: number | 'N/A') => {
if (pct === 'N/A') return 'N/A';
if (pct >= 99) return 'Top 1%';
if (pct >= 90) return 'Top 10%';
if (pct >= 75) return 'Top 25%';
if (pct >= 50) return 'Top 50%';
if (pct < 50) return '<50%';
return `Top ${(100 - pct).toFixed(0)}%`;
};
let getColorClasses = (pct: number | 'N/A') => {
if (pct === 'N/A') return 'bg-gray-50 text-gray-600 ring-gray-500/10';
if (pct >= 99) return 'bg-green-50 text-green-700 ring-green-600/20';
if (pct >= 90) return 'bg-green-50 text-green-700 ring-green-600/20'; // Green
if (pct >= 75) return 'bg-blue-50 text-blue-700 ring-blue-700/10'; // blue
if (pct >= 50) return 'bg-indigo-50 text-indigo-700 ring-indigo-700/10'; // Indigo
// if (pct >= 30) return 'bg-purple-50 text-purple-700 ring-purple-700/10'; // Purple
return 'bg-gray-50 text-gray-600 ring-gray-500/10';
};
onMount(async () => {
const { liteClient: algoliasearch } = await import('algoliasearch/lite');
// Start with a base search client - we'll extend this later
const baseSearchClient = algoliasearch(PUBLIC_ALGOLIA_APP_ID, PUBLIC_ALGOLIA_SEARCH_ONLY_KEY);
// Extend the base search client so we can capture empty queries and show placeholder results instead of making an initial round trip to Algolia
searchClient = {
...baseSearchClient,
// @ts-expect-error SearchResponse could be a facetHits response. There's no need to go into the underpinnings of the Algolia client to resolve
search(requests: CustomSearchRequest[]) {
console.log(JSON.stringify(requests));
if (requests.every(({ params }) => !params.query)) {
return Promise.resolve<SearchResponses<T>>({
results: requests.map(() => ({
hits: placeholderHits,
query: '',
params: '',
nbHits: placeholderHits.length,
processingTimeMS: 0,
})),
});
}
return baseSearchClient.search(requests);
},
};
algoliaInstance = instantsearch({
indexName: indexName,
searchClient,
future: {
preserveSharedStateOnUnmount: true,
},
initialUiState: {
[indexName]: {
query: '',
},
},
});
algoliaInstance.addWidgets([
configure({
hitsPerPage: 8,
}),
searchBox({
container: '#instantsearch',
searchAsYouType: true,
cssClasses: {
root: 'hidden',
},
}),
hits({
container: '#hits',
cssClasses: {
item: 'odd:bg-white even:bg-slate-50',
},
templates: {
item: (item: PlaceholderHit, { html }) => {
const url = `/profiles/v0/${item.ein}-${item.organization_name_slug}`;
let percentile: number | 'N/A' = item.rank !== undefined ? ((item.rank_total - item.rank) / item.rank_total) * 100 : 'N/A';
return html`<a href="${url}"
><div class="flex items-center justify-between gap-x-6 py-5">
<div class="w-full min-w-0 ">
<div class="flex items-start justify-between gap-x-3">
<div class="text-normal/6 font-semibold text-gray-900">${item.organization_name}</div>
<p
class="${getColorClasses(
percentile,
)} mt-0.5 whitespace-nowrap rounded-md px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset"
>
${getLabel(percentile)}
</p>
</div>
<div class="mt-1 flex w-full items-center justify-between gap-x-2 text-xs/5 text-gray-500">
<div class="flex items-center gap-x-2">
<p class="whitespace-nowrap">${item.city}, ${item.state}</p>
<svg viewBox="0 0 2 2" class="size-0.5 fill-current">
<circle cx="1" cy="1" r="1" />
</svg>
<p class="truncate">${formatEin(item.ein)}</p>
</div>
<div>${normalizeCurrencyToMillions(item.assets)}</div>
</div>
</div>
</div></a
>`;
},
empty(results, { html }) {
return html`<div class="mb-4">No results for <q>${results.query}</q></div>`;
},
},
}),
poweredBy({
container: '#powered-by',
theme: 'light',
}),
]);
algoliaInstance.start();
onAlgoliaInit(algoliaInstance);
});
onDestroy(() => {
if (algoliaInstance) {
algoliaInstance.dispose();
}
});
</script>
41 changes: 41 additions & 0 deletions apps/web/src/lib/components/search/menuState.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export const menuState = $state({
isMobileMenuOpen: false,
isProfileMenuOpen: false,
});

export const elementRefs = $state({
profileButton: null as HTMLButtonElement | null,
profileMenu: null as HTMLDivElement | null,
mobileMenuButton: null as HTMLButtonElement | null,
mobileMenu: null as HTMLDivElement | null,
});

export function toggleMobileMenu() {
menuState.isMobileMenuOpen = !menuState.isMobileMenuOpen;
}

export function toggleProfileMenu() {
menuState.isProfileMenuOpen = !menuState.isProfileMenuOpen;
}

export function handleClickOutside(event: MouseEvent) {
// Handle profile menu
if (
elementRefs.profileButton &&
elementRefs.profileMenu &&
!elementRefs.profileButton.contains(event.target as Node) &&
!elementRefs.profileMenu.contains(event.target as Node)
) {
menuState.isProfileMenuOpen = false;
}

// Handle mobile menu
if (
elementRefs.mobileMenuButton &&
elementRefs.mobileMenu &&
!elementRefs.mobileMenuButton.contains(event.target as Node) &&
!elementRefs.mobileMenu.contains(event.target as Node)
) {
menuState.isMobileMenuOpen = false;
}
}
47 changes: 47 additions & 0 deletions apps/web/src/lib/components/search/searchState.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { InstantSearch } from 'instantsearch.js';

export const searchState = $state({
algoliaInstance: null as InstantSearch | null,
query: '',
isSearchActive: false,
});

export function handleAlgoliaInit(instance: InstantSearch) {
searchState.algoliaInstance = instance;
if (searchState.query) {
restoreSearch();
}
}

export function handleSearchInput(e: Event) {
const value = (e.target as HTMLInputElement).value;
searchState.query = value;
searchState.isSearchActive = true;

performSearch(value);

if (searchState.algoliaInstance?.helper) {
searchState.algoliaInstance.helper.setQuery(value).search();
}
}

export function clearSearch() {
searchState.query = '';
searchState.isSearchActive = false;

if (searchState.algoliaInstance?.helper) {
searchState.algoliaInstance.helper.setQuery('').search();
}
}

export function restoreSearch() {
if (searchState.query && searchState.isSearchActive) {
performSearch(searchState.query);
}
}

function performSearch(query: string) {
if (searchState.algoliaInstance?.helper) {
searchState.algoliaInstance.helper.setQuery(query).search();
}
}
Loading

0 comments on commit 50b29ef

Please sign in to comment.