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

Infinite scroll for search results #494

Merged
merged 8 commits into from
Apr 2, 2024
Merged
Show file tree
Hide file tree
Changes from 6 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
22 changes: 17 additions & 5 deletions src/lib/api/documents.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** API helpers related to documents.
* Lots of duplicated code here that should get consolidated at some point.
*/
import type { Document, sizes } from "./types";
import type { Document, DocumentResults, SearchOptions, sizes } from "./types";

import { error } from "@sveltejs/kit";

Expand All @@ -10,17 +10,29 @@ import { isOrg } from "@/api/types/orgAndUser";
import { APP_URL, BASE_API_URL } from "@/config/config.js";
import { isErrorCode } from "../utils";

/** Search documents */
/**
* Search documents
* https://www.documentcloud.org/help/search/
*
* */
export async function search(
query = "",
highlight = false,
options: SearchOptions = {
hl: Boolean(query),
per_page: 25,
cursor: "",
version: "2.0",
},
fetch = globalThis.fetch,
) {
): Promise<DocumentResults> {
const endpoint = new URL("documents/search/", BASE_API_URL);

endpoint.searchParams.set("expand", DEFAULT_EXPAND);
endpoint.searchParams.set("q", query);
endpoint.searchParams.set("hl", String(highlight));

for (const [k, v] of Object.entries(options)) {
endpoint.searchParams.set(k, String(v));
}

const resp = await fetch(endpoint, { credentials: "include" });

Expand Down
9 changes: 8 additions & 1 deletion src/lib/api/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,6 @@ export interface Document {
note_highlights?: Record<string, Highlight[]>;
}

// export type DocumentResults = Page<Document>;
export interface DocumentResults extends Page<Document> {}
eyeseast marked this conversation as resolved.
Show resolved Hide resolved

export interface Note {
Expand Down Expand Up @@ -135,6 +134,14 @@ export interface Section {

export type SectionResults = Page<Section>;

export interface SearchOptions {
hl?: boolean;
per_page?: number;
cursor?: string;
expand?: string;
version?: number | string;
}

export interface OEmbed {
version: "1.0";
provider_name: "DocumentCloud";
Expand Down
12 changes: 7 additions & 5 deletions src/lib/components/Search.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
<script lang="ts">
import { Search24, XCircleFill24 } from "svelte-octicons";
import Button from "./common/Button.svelte";

export let query: string = "";
let input: HTMLInputElement;
Expand All @@ -11,16 +10,19 @@
}
</script>

<form class="container" on:submit|preventDefault>
<form class="container" on:submit>
<label for="query" title="Search"><Search24 /></label>
<input
type="search"
id="query"
name="query"
name="q"
autocomplete="off"
placeholder="Search…"
bind:value={query}
bind:this={input}
on:change
autocomplete="off"
placeholder="Search…"
on:input
on:reset
/>
<button
title="Clear Search"
Expand Down
115 changes: 100 additions & 15 deletions src/lib/components/documents/ResultsList.svelte
Original file line number Diff line number Diff line change
@@ -1,37 +1,100 @@
<script context="module" lang="ts">
import { writable, type Writable } from "svelte/store";
import Button from "../common/Button.svelte";
eyeseast marked this conversation as resolved.
Show resolved Hide resolved

export const selected: Writable<(number | string)[]> = writable([]);
// IDs might be strings or numbers, depending on the API endpoint
// enforce type consistency here to avoid comparison bugs later
export const selected: Writable<string[]> = writable([]);
export let visible: Writable<Set<string>> = writable(new Set());

export let total: Writable<number> = writable(0);
</script>

<script lang="ts">
import type { Document, DocumentResults } from "$lib/api/types";

import { onMount } from "svelte";
import { _ } from "svelte-i18n";
import type { DocumentResults } from "$lib/api/types";

import DocumentListItem from "./DocumentListItem.svelte";
import Flex from "../common/Flex.svelte";
import Checkbox from "../common/Checkbox.svelte";
import { Search24 } from "svelte-octicons";
import Empty from "../common/Empty.svelte";

export let results: DocumentResults = undefined;
export let results: Document[] = [];
export let count: number = undefined;
export let next: string | null = null;
export let auto = false;

let loading = false;
let end: HTMLElement;
let observer: IntersectionObserver;

// track what's visible so we can compare to $selected
$: $visible = new Set(results.map((d) => String(d.id)));

function updateSelection(event: Event, id: string | number) {
const { checked } = event.target as HTMLInputElement;
if (checked) {
selected.set([...$selected, id]);
} else {
selected.set([...$selected.filter((d) => d !== id)]);
// load the next set of results
async function load(url: URL) {
loading = true;
const res = await fetch(url, { credentials: "include" });

if (!res.ok) {
// todo: better error handling
eyeseast marked this conversation as resolved.
Show resolved Hide resolved
console.error(res.statusText);
loading = false;
}

const r: DocumentResults = await res.json();

results = [...results, ...r.results];
$total = r.count;
next = r.next;
loading = false;
if (auto) watch(end);
eyeseast marked this conversation as resolved.
Show resolved Hide resolved
}

function watch(el: HTMLElement) {
const io = new IntersectionObserver((entries, observer) => {
entries.forEach(async (entry) => {
if (entry.isIntersecting && next) {
await load(new URL(next));
observer.unobserve(el);
}
});
});

io.observe(el);
return io;
}

function unwatch(io: IntersectionObserver, el: HTMLElement) {
io.unobserve(el);
}
eyeseast marked this conversation as resolved.
Show resolved Hide resolved

onMount(() => {
// set initial total, update later
$total = count;
if (auto) {
observer = watch(end);
}

return () => {
unwatch(observer, end);
};
});
</script>

<div class="container">
{#each results.results as document (document.id)}
{#each results as document (document.id)}
<Flex gap={0.625} align="center">
<Checkbox
checked={$selected.includes(document.id)}
on:change={(event) => updateSelection(event, document.id)}
/>
<label>
<span class="sr-only">Select</span>
<input
type="checkbox"
bind:group={$selected}
value={String(document.id)}
/>
</label>
<DocumentListItem {document} />
</Flex>
{:else}
Expand All @@ -40,10 +103,32 @@
<p>{$_("noDocuments.queryNoResults")}</p>
</Empty>
{/each}
<div class="end" bind:this={end}>
{#if next}
<Button disabled={loading} on:click={(e) => load(new URL(next))}>
{#if loading}
Loading ...
{:else}
Load more
{/if}
</Button>
{/if}
</div>
eyeseast marked this conversation as resolved.
Show resolved Hide resolved
</div>

<style>
.container {
padding: 0 2rem;
}

label {
display: flex;
align-items: center;
gap: 0.5rem;
}

input[type="checkbox"] {
height: 1.25rem;
width: 1.25rem;
}
</style>
22 changes: 10 additions & 12 deletions src/lib/components/documents/stories/ResultsList.stories.svelte
Original file line number Diff line number Diff line change
@@ -1,32 +1,30 @@
<script context="module" lang="ts">
import type { DocumentResults } from "@/lib/api/types";
import type { Document, DocumentResults } from "@/lib/api/types";
import { Story } from "@storybook/addon-svelte-csf";
import ResultsList from "../ResultsList.svelte";

// typescript complains without the type assertion
import searchResults from "../../../api/fixtures/documents/search-highlight.json";
const results = searchResults as DocumentResults;
const results = searchResults.results as Document[];
const count = searchResults.count;
const next = searchResults.next;

export const meta = {
title: "Components / Documents / Results list",
component: ResultsList,
tags: ["autodocs"],
parameters: { layout: "centered" },
};

const empty = {
results: [],
count: 0,
next: null,
previous: null,
escaped: false,
};
</script>

<Story name="With Results">
<div style="width: 36rem"><ResultsList {results} /></div>
<div style="width: 36rem"><ResultsList {results} {count} {next} /></div>
</Story>

<Story name="Empty">
<div style="width: 36rem"><ResultsList results={empty} /></div>
<div style="width: 36rem"><ResultsList /></div>
</Story>

<Story name="Infinite">
<div style="width: 36rem"><ResultsList {results} {count} {next} auto /></div>
</Story>
Loading
Loading