Skip to content

Commit

Permalink
cursor based pagination
Browse files Browse the repository at this point in the history
- support before:<uuid> to page off of note uuids (which sort by time)
- add next and back buttons utilizing before:<uuid>
- some refactoring to support this
  • Loading branch information
cloverich committed Jan 18, 2024
1 parent 8bf947e commit 1383f1a
Show file tree
Hide file tree
Showing 4 changed files with 202 additions and 39 deletions.
29 changes: 26 additions & 3 deletions src/container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,37 @@ import {
} from "./hooks/useJournalsLoader";
import { Alert, Pane } from "evergreen-ui";
import { Routes, Route, Navigate } from 'react-router-dom';
import { useSearchParams } from 'react-router-dom';
import useClient from "./hooks/useClient";
import { SearchV2Store } from "./views/documents/SearchStore";

export default observer(function Container() {
const { journalsStore, loading, loadingErr } = useJournalsLoader();
const client = useClient();
const [params, setParams] = useSearchParams();
const [searchStore, setSearchStore] = useState<null | SearchV2Store>(null);

if (loading) {
// This is more like an effect. This smells. Maybe just roll this all up into
// a hook.
if (journalsStore && !loading && !searchStore) {
const store = new SearchV2Store(client, journalsStore, setParams, params.getAll('search'));
store.search();
setSearchStore(store);
}

// The identity of this function changes on every render
// The store is not re-created, so needs updated.
// This is a bit of a hack, but it works.
useEffect(() => {
if (searchStore) {
searchStore.setTokensUrl = setParams;
}
}, [setParams])

if (loading || !searchStore) {
return (
<LayoutDummy>
<h1>TODO LOADING STATE</h1>
<h1>Loading Journals...</h1>
</LayoutDummy>
)
}
Expand All @@ -41,7 +64,7 @@ export default observer(function Container() {
<Route element={<Preferences />} path="preferences" />
<Route element={<Editor />} path="edit/new" />
<Route element={<Editor />} path="edit/:document" />
<Route element={<Documents />} path="documents" />
<Route element={<Documents store={searchStore}/>} path="documents" />
<Route
path="*"
element={<Navigate to="documents" replace />}
Expand Down
35 changes: 34 additions & 1 deletion src/preload/client/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export interface SearchRequest {
*/
texts?: string[];

limit?: number;

nodeMatch?: {
/**
* Type of node
Expand Down Expand Up @@ -137,8 +139,17 @@ export class DocumentsClient {
}
}

// todo: test id, date, and unknown formats
if (q?.before) {
query.andWhere('createdAt', '<', q.before);
if (this.beforeTokenFormat(q.before) === 'date') {
query = query.andWhere('createdAt', '<', q.before);
} else {
query = query.andWhere('id', '<', q.before);
}
}

if (q?.limit) {
query = query.limit(q.limit);
}

query.orderBy('createdAt', 'desc')
Expand Down Expand Up @@ -214,4 +225,26 @@ export class DocumentsClient {
.get({ id });
}
};

/**
* For a given before: token, determine if the value is a date, an ID, or
* unknown. This allows paginating / ordering off of before using either
* createdAt or ID.
*
* @param input - The value of the before: token
*/
beforeTokenFormat = (input: string): 'date' | 'id' | 'unknown' => {
// Regular expression for ISO date formats: full, year-month, year only
const dateRegex = /^(?:\d{4}(?:-\d{2}(?:-\d{2})?)?)$/;
// Regular expression for the specific ID format
const idRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

if (dateRegex.test(input)) {
return 'date';
} else if (idRegex.test(input)) {
return 'id';
} else {
return 'unknown';
}
}
}
113 changes: 98 additions & 15 deletions src/views/documents/SearchStore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { IClient } from "../../hooks/useClient";
import { observable, IObservableArray, reaction, computed, action } from "mobx";
import { observable, IObservableArray, computed, action } from "mobx";
import { JournalsStore } from "../../hooks/stores/journals";
import { SearchToken } from "./search/tokens";
import { TagSearchStore } from "./TagSearchStore";
Expand All @@ -11,25 +11,29 @@ export interface SearchItem {
journalId: string;
}

interface SearchQuery {
journals: string[];
titles?: string[];
before?: string;
texts?: string[];
limit?: number;
}

export class SearchV2Store {
@observable docs: SearchItem[] = [];
@observable loading = true;
@observable error: string | null = null;
private journals: JournalsStore;
private tagSeachStore: TagSearchStore;
private setTokensUrl: any; // todo: This is react-router-dom's setUrl; type it
setTokensUrl: any; // todo: This is react-router-dom's setUrl; type it

@observable private _tokens: IObservableArray<SearchToken> = observable([]);

constructor(private client: IClient, journals: JournalsStore, setTokensUrl: any) {
constructor(private client: IClient, journals: JournalsStore, setTokensUrl: any, tokens: string[]) {
this.journals = journals;
this.tagSeachStore = new TagSearchStore(this);
this.setTokensUrl = setTokensUrl;

// Re-run the search query anytime the tokens change.
reaction(() => this._tokens.slice(), this.search, {
fireImmediately: false,
});
this.tagSeachStore.addTokens(tokens);
}

@action
Expand All @@ -50,7 +54,7 @@ export class SearchV2Store {
}

// todo: this might be better as a @computed get
private tokensToQuery = () => {
private tokensToQuery = (): SearchQuery => {
const journals = this.tokens
.filter((t) => t.type === "in")
.map((token) => this.journals.idForName(token.value as string))
Expand All @@ -70,15 +74,39 @@ export class SearchV2Store {
return { journals, titles, texts, before }
};

search = async () => {
/**
* Execute a search with the current tokens.
*
* @param limit
* @param resetPagination - By default execute a fresh search. When paginating,
* we don't want to reset the pagination state.
*/
search = async (limit=100, resetPagination=true) => {
this.loading = true;
this.error = null;

const query = this.tokensToQuery();
const q = this.tokensToQuery();

// For determining if there is a next, add one to the limit
// and see if we get it back.
q.limit = limit + 1;

try {
const res = this.client.documents.search(query);
this.docs = (await res).data;
const res = this.client.documents.search(q);
const docs = (await res).data;

if (docs.length > limit) {
this.nextId = docs[docs.length - 1].id;
docs.pop();
} else {
this.nextId = null;
}

if (resetPagination) {
this.lastIds = [];
}

this.docs = docs;
} catch (err) {
console.error("Error with documents.search results", err);
this.error = err instanceof Error ? err.message : JSON.stringify(err);
Expand All @@ -91,20 +119,75 @@ export class SearchV2Store {
// do a full refactor pass after the key search features are working.
addTokens = (searchStr: string[]) => {
this.tagSeachStore.addTokens(searchStr);
this.search();
}

addToken = (searchStr: string) => {
addToken = (searchStr: string, resetPagination = true) => {
this.tagSeachStore.addToken(searchStr);

// TODO: I think updating the url should be a reaction to the tokens changing,
// perhaps TagSearchStore does this as part of refactor above?
this.setTokensUrl({ search: this.searchTokens }, { replace: true });
this.search(100, resetPagination);
}

removeToken = (token: string) => {
removeToken = (token: string, resetPagination = true) => {
this.tagSeachStore.removeToken(token);
this.setTokensUrl({ search: this.searchTokens }, { replace: true });
this.search(100, resetPagination);
}

@computed
get searchTokens() {
return this.tagSeachStore.searchTokens;
}

// TODO:Test cases, sigh
// When < limit, there is no next
// When click next, next doc is correct, lastId works as expected
// When next to last page, there is no next
// When back to prior page, next and last id are correct
// When back to first page, there is no last Id
// New searches clear pagination data
@observable nextId: string | null = null;
@observable lastIds: (string|undefined)[] = [];
@computed get hasNext() { return !!this.nextId }
@computed get hasPrev() { return !!this.lastIds.length }


@action
next = () => {
if (!this.nextId) return;

const lastBefore = this._tokens.find(t => t.type === 'before')?.value;

// This doesn't infer that lastBefore will be a token with a string value;
// it thinks NodeMatch is possible here. Undefined indicates no prior page,
// and logic above handles that.
this.lastIds.push(lastBefore as string | undefined);
this.addToken(`before:${this.nextId}`, false)
}

@action
prev = () => {
if (!this.hasPrev) return;

const lastId = this.lastIds.pop();

if (lastId) {
this.addToken(`before:${lastId}`, false)
} else {

// Indicates this is the first next page, and clickign prev
// takes us to the first page, i.e. no before: token
const lastBefore = this._tokens.find(t => t.type === 'before');

if (lastBefore) {
this.removeToken(`before:${lastBefore.value}`, false);
} else {
// Didn't come up in testing, but this is a good sanity check
console.error('Called prev but no before: token found?');
}
}
}
}
64 changes: 44 additions & 20 deletions src/views/documents/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React, { useEffect, useContext, useState } from "react";
import useClient from "../../hooks/useClient";
import React, { useContext } from "react";
import { observer } from "mobx-react-lite";
import { Heading, Paragraph } from "evergreen-ui";
import { Heading, Paragraph, Pane} from "evergreen-ui";
import { JournalsStoreContext } from "../../hooks/useJournalsLoader";

import { SearchV2Store } from "./SearchStore";
Expand All @@ -10,31 +9,26 @@ import { useNavigate } from 'react-router-dom';
import { Layout } from "./Layout";
import { useSearchParams } from 'react-router-dom';

function DocumentsContainer() {
function DocumentsContainer(props: { store: SearchV2Store}) {
const journalsStore = useContext(JournalsStoreContext);
const client = useClient();
const [params, setParams] = useSearchParams();
const [searchStore] = useState(new SearchV2Store(client, journalsStore, setParams));
const [params,] = useSearchParams();

const searchStore = props.store
const navigate = useNavigate();

function edit(docId: string) {
navigate(`/edit/${docId}`)
}


// NOTE: If user (can) manipulate URL, or once saved
// searches are implemented, this will need to be extended
// todo: All input tests should also test via the URL
React.useEffect(() => {
console.log('Documents.index.useEffect')
const tokens = params.getAll('search');

// does not trigger initial search reaction because there are no
// tokens and the change is based on length, and there fireImmediately is false
// Make this more elegant.
if (tokens.length) {
searchStore.addTokens(tokens);
} else {
// When hitting "back" from an edit note, the search state is maintained.
// When navigating to other pages (preferences) and back, the search
// state needs reset. This resets the state in that case. This is
// not the correct place to do this.
if (!tokens.length) {
searchStore.setTokens([]);
searchStore.search();
}
}, [])
Expand Down Expand Up @@ -89,16 +83,46 @@ function DocumentsContainer() {
return jrnl.name;
}

// .slice(0, 100) until pagination and persistent search state are implemented
const docs = searchStore.docs.slice(0, 100).map((doc) => {
const docs = searchStore.docs.map((doc) => {
return <DocumentItem key={doc.id} doc={doc} getName={getName} edit={edit} />;
});


return (
<Layout store={searchStore}>
{docs}
<Pagination store={searchStore} />
</Layout>
);
}

function Pagination(props: {store: SearchV2Store}) {
const nextButton = (() => {
if (props.store.hasNext) {
return <a style={{marginLeft: '8px'}} href="" onClick={() => {
props.store.next()
window.scrollTo(0, 0);
return false;
}}>Next</a>
}
})();

const prevButton = (() => {
if (props.store.hasPrev) {
return <a style={{marginLeft: '8px'}} href="" onClick={() => {
props.store.prev()
window.scrollTo(0, 0);
return false;
}}>Prev</a>
}
})();

return (
<Pane display='flex' justifyContent='flex-end' marginTop='24px'>
{prevButton}
{nextButton}
</Pane>
);
}

export default observer(DocumentsContainer);

0 comments on commit 1383f1a

Please sign in to comment.