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

pagination #115

Merged
merged 1 commit into from
Jan 18, 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
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
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally not public, but needs re-set on re-render from the UI.


@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, {
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I invariably end up replacing reactions with imperative code. Reaction always feels more elegant, but @action and @computed and @observable alone are usually easier to reason about.

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);