diff --git a/lxl-web/src/lib/components/Search.svelte b/lxl-web/src/lib/components/Search.svelte index 38625cbd5..8d4ebf161 100644 --- a/lxl-web/src/lib/components/Search.svelte +++ b/lxl-web/src/lib/components/Search.svelte @@ -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; + }
@@ -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)} + + {/snippet} + {:else} { + 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'] + }); +}; diff --git a/packages/supersearch/README.md b/packages/supersearch/README.md index ec134e847..5f43ea3ec 100644 --- a/packages/supersearch/README.md +++ b/packages/supersearch/README.md @@ -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 `` 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 `` 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 diff --git a/packages/supersearch/e2e/supersearch.spec.ts b/packages/supersearch/e2e/supersearch.spec.ts index d83eb2a6b..9a67f12c7 100644 --- a/packages/supersearch/e2e/supersearch.spec.ts +++ b/packages/supersearch/e2e/supersearch.spec.ts @@ -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"'); +}); diff --git a/packages/supersearch/src/lib/components/SuperSearch.svelte b/packages/supersearch/src/lib/components/SuperSearch.svelte index 997d30f65..be8729343 100644 --- a/packages/supersearch/src/lib/components/SuperSearch.svelte +++ b/packages/supersearch/src/lib/components/SuperSearch.svelte @@ -1,10 +1,18 @@ +{#snippet fallbackResultItem(item: ResultItem)} + {JSON.stringify(item)} +{/snippet} + + + + diff --git a/packages/supersearch/src/lib/types/json.ts b/packages/supersearch/src/lib/types/json.ts new file mode 100644 index 000000000..467d6c263 --- /dev/null +++ b/packages/supersearch/src/lib/types/json.ts @@ -0,0 +1,8 @@ +type JSONPrimitive = string | number | boolean | null | undefined; + +export type JSONValue = + | JSONPrimitive + | JSONValue[] + | { + [key: string]: JSONValue; + }; diff --git a/packages/supersearch/src/lib/types/superSearch.ts b/packages/supersearch/src/lib/types/superSearch.ts new file mode 100644 index 000000000..06cc88bfe --- /dev/null +++ b/packages/supersearch/src/lib/types/superSearch.ts @@ -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; +} diff --git a/packages/supersearch/src/lib/utils/debounce.ts b/packages/supersearch/src/lib/utils/debounce.ts new file mode 100644 index 000000000..87a353a44 --- /dev/null +++ b/packages/supersearch/src/lib/utils/debounce.ts @@ -0,0 +1,10 @@ +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +function debounce(callback: Function, wait = 300) { + let timeout: ReturnType; + return (...args: unknown[]) => { + clearTimeout(timeout); + timeout = setTimeout(() => callback(...args), wait); + }; +} + +export default debounce; diff --git a/packages/supersearch/src/lib/utils/useSearchRequest.svelte.ts b/packages/supersearch/src/lib/utils/useSearchRequest.svelte.ts new file mode 100644 index 000000000..ec88ab085 --- /dev/null +++ b/packages/supersearch/src/lib/utils/useSearchRequest.svelte.ts @@ -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; diff --git a/packages/supersearch/src/routes/+page.svelte b/packages/supersearch/src/routes/+page.svelte index 27799f04f..3810d859a 100644 --- a/packages/supersearch/src/routes/+page.svelte +++ b/packages/supersearch/src/routes/+page.svelte @@ -1,24 +1,97 @@
Supersearch inside <form> element - + + new URLSearchParams({ + _q: query, + _limit: '10' + })} + paginationQueryFn={handlePaginationQuery} + transformFn={handleTransform} + > + {#snippet resultItem(item)} + + {/snippet} +
Supersearch using form attribute - + + new URLSearchParams({ + _q: query, + _limit: '10' + })} + paginationQueryFn={handlePaginationQuery} + > + {#snippet resultItem(item)} + + {/snippet} +
+ + diff --git a/packages/supersearch/src/routes/api/find/+server.ts b/packages/supersearch/src/routes/api/find/+server.ts new file mode 100644 index 000000000..064d5d662 --- /dev/null +++ b/packages/supersearch/src/routes/api/find/+server.ts @@ -0,0 +1,34 @@ +import type { RequestHandler } from './$types.ts'; +import { json } from '@sveltejs/kit'; + +const MAX_ITEMS = 30; + +const MOCK_ITEMS_DATA = Array.from({ length: MAX_ITEMS }, (_, i) => ({ + id: i, + heading: `Heading ${i + 1}` +})); + +export const GET: RequestHandler = async ({ url }) => { + const limit = parseInt(url.searchParams.get('_limit')!, 10); + const offset = parseInt(url.searchParams.get('_offset') || '0', 10); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + return json({ + '@id': `/api/find?${url.searchParams.toString()}`, + items: MOCK_ITEMS_DATA.slice(offset, offset + limit).map((item) => ({ + '@id': item.id, + heading: item.heading + })), + totalItems: MAX_ITEMS + }); +}; + +export interface MockQueryResponse { + '@id'?: string; + items: { + '@id'?: string; + heading: string; + }[]; + totalItems: number; +}