Skip to content

Commit

Permalink
feat(supersearch): Add search request handling (#1178)
Browse files Browse the repository at this point in the history
* Add useSearchRequest for request handling

* Add debouncing

* Add optional transform function prop

* Add support for paginated data

* Cancel pending fetches when fetching new data

* Add snippet prop for result items

* Add test api endpoint

* Add tests

* Add basic supersearch request handling in lxl-web

* Update readme
  • Loading branch information
johanbissemattsson authored Nov 26, 2024
1 parent 77c83e1 commit 27272fb
Show file tree
Hide file tree
Showing 11 changed files with 395 additions and 12 deletions.
27 changes: 26 additions & 1 deletion lxl-web/src/lib/components/Search.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@
q = q.trim();
}
}
function handlePaginationQuery(searchParams: URLSearchParams, prevData: unknown) {
const paginatedSearchParams = new URLSearchParams(Array.from(searchParams.entries()));
const limit = parseInt(searchParams.get('_limit')!, 10);
const offset = limit + parseInt(searchParams.get('_offset') || '0', 10);
if (prevData && offset < prevData.totalItems) {
paginatedSearchParams.set('_offset', offset.toString());
return paginatedSearchParams;
}
return undefined;
}
</script>

<form class="relative w-full" action="find" on:submit={handleSubmit}>
Expand All @@ -51,7 +63,20 @@
bind:value={q}
language={lxlQuery}
placeholder={$page.data.t('search.search')}
/>
endpoint={'/api/supersearch'}
queryFn={(query) =>
new URLSearchParams({
_q: query,
_limit: '10'
})}
paginationQueryFn={handlePaginationQuery}
>
{#snippet resultItem(item)}
<button type="button">
<h2>{item?.heading}</h2>
</button>
{/snippet}
</SuperSearch>
{:else}
<!-- svelte-ignore a11y-autofocus -->
<input
Expand Down
25 changes: 25 additions & 0 deletions lxl-web/src/routes/api/[[lang=lang]]/supersearch/+server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { env } from '$env/dynamic/private';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.ts';
import { LxlLens } from '$lib/types/display';
import { getSupportedLocale } from '$lib/i18n/locales.js';
import { toString } from '$lib/utils/xl.js';

export const GET: RequestHandler = async ({ url, params, locals }) => {
const displayUtil = locals.display;
const locale = getSupportedLocale(params?.lang);

const findResponse = await fetch(`${env.API_URL}/find?${url.searchParams.toString()}`);
const data = await findResponse.json();

return json({
'@id': data['@id'],
items: data.items?.map((item) => ({
'@id': item['@id'],
'@type': item['@type'],
heading: toString(displayUtil.lensAndFormat(item, LxlLens.CardHeading, locale))
})),
totalItems: data.totalItems,
'@context': data['@context']
});
};
20 changes: 13 additions & 7 deletions packages/supersearch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,19 @@ To use `supersearch` in a non-Svelte project ...

## Properties

| Property | Type | Description | Default value |
| ------------- | ----------------- | ----------------------------------------------------------------- | ------------- |
| `name` | `string` | A string specifying a name for the form control. | `undefined` |
| `value` | `string` | The value that will be displayed and edited inside the component. | `""` |
| `form` | `string` | A string matching the `id` of a `<form>` element. | `undefined` |
| `language` | `LanguageSupport` | The language extension that will parse and highlight the value. | `undefined` |
| `placeholder` | `string` | A brief hint which is shown when value is empty. | `""` |
| Property | Type | Description | Default value |
| ------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------- | ------------- |
| `name` | `string` | A string specifying a name for the form control. | `undefined` |
| `value` | `string` | The value that will be displayed and edited inside the component. | `""` |
| `form` | `string` | A string matching the `id` of a `<form>` element. | `undefined` |
| `language` | `LanguageSupport` | The language extension that will parse and highlight the value. | `undefined` |
| `placeholder` | `string` | A brief hint which is shown when value is empty. | `""` |
| `endpoint` | `string` or `URL` | The endpoint from which the component should fetch data from (used together with `queryFn`). | `undefined` |
| `queryFn` | `QueryFunction` | A function that converts `value` to `URLSearchParams` (which will be appended to the endpoint). | `undefined` |
| `paginationQueryFn` | `PaginationQueryFunction` | A function which should return `URLSearchParams` used for querying more paginated data (if available) | `undefined` |
| `transformFn` | `TransformFunction` | A generic helper function which can be used to transform data fetched from the endpoint. | `undefined` |
| `resultItem` | `Snippet<[ResultItem]>` | A [Snippet](https://svelte.dev/docs/svelte/snippet) used for customized rendering of result items. | `undefined` |
| `debouncedWait` | `number` | The wait time, in milliseconds that debounce function should wait between invocated search queries. | `300` |

## Developing

Expand Down
21 changes: 21 additions & 0 deletions packages/supersearch/e2e/supersearch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,24 @@ test('syncs collapsed and expanded editor views', async ({ page }) => {
'text selection should be synced'
).toBe('Hello world');
});

test('fetches and displays paginated results', async ({ page }) => {
await page.locator('[data-test-id="test1"]').getByRole('textbox').locator('div').click();
await page
.locator('[data-test-id="test1"]')
.getByRole('dialog')
.getByRole('textbox')
.locator('div')
.fill('Hello');
await expect(page.locator('[data-test-id="result-item"]').first()).toContainText('Heading 1');
await expect(page.locator('[data-test-id="result-item"]')).toHaveCount(10);
await page.locator('.supersearch-show-more').click(); // show more button will probably be removed in favour of automatic fetching when the user scrolls to the end
await expect(page.locator('[data-test-id="result-item"]')).toHaveCount(20);
await page.locator('.supersearch-show-more').click();
await expect(page.locator('[data-test-id="result-item"]')).toHaveCount(30);
await expect(page.locator('.supersearch-show-more')).not.toBeAttached();
await expect(
page.locator('[data-test-id="result-item"]').first(),
'to tranform data using transformFn if available'
).toHaveText('Heading 1 for "Hello"');
});
73 changes: 72 additions & 1 deletion packages/supersearch/src/lib/components/SuperSearch.svelte
Original file line number Diff line number Diff line change
@@ -1,20 +1,44 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import CodeMirror, { type ChangeCodeMirrorEvent } from '$lib/components/CodeMirror.svelte';
import { EditorView, placeholder as placeholderExtension } from '@codemirror/view';
import { Compartment } from '@codemirror/state';
import { type LanguageSupport } from '@codemirror/language';
import submitFormOnEnterKey from '$lib/extensions/submitFormOnEnterKey.js';
import preventNewLine from '$lib/extensions/preventNewLine.js';
import useSearchRequest from '$lib/utils/useSearchRequest.svelte.js';
import type {
QueryFunction,
PaginationQueryFunction,
TransformFunction,
ResultItem
} from '$lib/types/superSearch.js';
interface Props {
name: string;
value?: string;
form?: string;
language?: LanguageSupport;
placeholder?: string;
endpoint: string | URL;
queryFn?: QueryFunction;
paginationQueryFn?: PaginationQueryFunction;
transformFn?: TransformFunction;
resultItem?: Snippet<[ResultItem]>;
}
let { name, value = $bindable(''), form, language, placeholder = '' }: Props = $props();
let {
name,
value = $bindable(''),
form,
language,
placeholder = '',
endpoint,
queryFn = (value) => new URLSearchParams({ q: value }),
paginationQueryFn,
transformFn,
resultItem = fallbackResultItem
}: Props = $props();
let collapsedEditorView: EditorView | undefined = $state();
let expandedEditorView: EditorView | undefined = $state();
Expand All @@ -23,6 +47,19 @@
let placeholderCompartment = new Compartment();
let prevPlaceholder = placeholder;
let search = useSearchRequest({
endpoint,
queryFn,
paginationQueryFn,
transformFn
});
$effect(() => {
if (value) {
search.debouncedFetchData(value);
}
});
const extensions = [
submitFormOnEnterKey(form),
preventNewLine({ replaceWithSpace: true }),
Expand Down Expand Up @@ -76,6 +113,10 @@
});
</script>

{#snippet fallbackResultItem(item: ResultItem)}
{JSON.stringify(item)}
{/snippet}

<CodeMirror
{value}
{extensions}
Expand All @@ -93,4 +134,34 @@
bind:editorView={expandedEditorView}
syncedEditorView={collapsedEditorView}
/>
<nav>
{#if search.data}
{@const resultItems =
(Array.isArray(search.paginatedData) &&
search.paginatedData.map((page) => page.items).flat()) ||
search.data?.items}
<ul>
{#each resultItems as item}
<li>
{@render resultItem?.(item)}
</li>
{/each}
</ul>
{/if}
{#if search.isLoading}
Loading...
{:else if search.hasMorePaginatedData}
<button type="button" class="supersearch-show-more" onclick={search.fetchMoreData}>
Load more
</button>
{/if}
</nav>
</dialog>

<style>
ul {
margin: 0;
padding: 0;
list-style-type: none;
}
</style>
8 changes: 8 additions & 0 deletions packages/supersearch/src/lib/types/json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
type JSONPrimitive = string | number | boolean | null | undefined;

export type JSONValue =
| JSONPrimitive
| JSONValue[]
| {
[key: string]: JSONValue;
};
13 changes: 13 additions & 0 deletions packages/supersearch/src/lib/types/superSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { JSONValue } from './json.js';

export type QueryFunction = (value: string) => URLSearchParams;
export type PaginationQueryFunction = (
searchParams: URLSearchParams,
data: JSONValue
) => URLSearchParams | undefined;
export type TransformFunction = (data: JSONValue) => JSONValue;

export interface ResultItem {
'@id'?: string;
heading: string;
}
10 changes: 10 additions & 0 deletions packages/supersearch/src/lib/utils/debounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
function debounce(callback: Function, wait = 300) {
let timeout: ReturnType<typeof setTimeout>;
return (...args: unknown[]) => {
clearTimeout(timeout);
timeout = setTimeout(() => callback(...args), wait);
};
}

export default debounce;
97 changes: 97 additions & 0 deletions packages/supersearch/src/lib/utils/useSearchRequest.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type {
QueryFunction,
PaginationQueryFunction,
TransformFunction
} from '$lib/types/superSearch.js';
import type { JSONValue } from '$lib/types/json.js';
import debounce from '$lib/utils/debounce.js';

export function useSearchRequest({
endpoint,
queryFn,
paginationQueryFn,
transformFn,
debouncedWait
}: {
endpoint: string | URL;
queryFn: QueryFunction;
paginationQueryFn?: PaginationQueryFunction;
transformFn?: TransformFunction;
debouncedWait?: number;
}) {
let isLoading = $state(false);
let error: string | undefined = $state();
let data = $state();
let paginatedData = $state();
let moreSearchParams: URLSearchParams | undefined = $state();
const hasMorePaginatedData = $derived(!!moreSearchParams);

let controller: AbortController;

async function _fetchData(searchParams: URLSearchParams) {
try {
isLoading = true;
error = undefined;

controller?.abort();
controller = new AbortController();

const response = await fetch(`${endpoint}?${searchParams.toString()}`, {
signal: controller.signal
});
const jsonResponse = (await response.json()) as JSONValue;

const _data = transformFn?.(jsonResponse) || jsonResponse;
moreSearchParams = paginationQueryFn?.(searchParams, _data);

return _data;
} catch (err) {
if (err instanceof Error) {
error = 'Failed to fetch data: ' + err.message;
} else {
error = 'Failed to fetch data';
}
} finally {
isLoading = false;
}
}

async function fetchData(query: string) {
data = await _fetchData(queryFn(query));
if (paginationQueryFn) {
paginatedData = [data];
}
}

const debouncedFetchData = debounce((query: string) => fetchData(query), debouncedWait);

async function fetchMoreData() {
if (moreSearchParams) {
const moreData = await _fetchData(moreSearchParams);
paginatedData = [...((Array.isArray(paginatedData) && paginatedData) || []), moreData];
}
}

return {
fetchData,
debouncedFetchData,
fetchMoreData,
get isLoading() {
return isLoading;
},
get error() {
return error;
},
get data() {
return data;
},
get paginatedData() {
return paginatedData;
},
get hasMorePaginatedData() {
return hasMorePaginatedData;
}
};
}

export default useSearchRequest;
Loading

0 comments on commit 27272fb

Please sign in to comment.