Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

LWS-265: Add search request handling #1178

Merged
merged 20 commits into from
Nov 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading