stream-chat
Version:
JS SDK for the Stream Chat API
299 lines (240 loc) • 8.23 kB
text/typescript
import { StateStore } from '../store';
import { debounce, type DebouncedFunc } from '../utils';
import type {
QueryReturnValue,
SearchSourceOptions,
SearchSourceState,
SearchSourceType,
} from './types';
export type DebounceOptions = {
debounceMs: number;
};
type DebouncedExecQueryFunction = DebouncedFunc<(searchString?: string) => Promise<void>>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
interface ISearchSource<T = any> {
activate(): void;
canExecuteQuery(newSearchString?: string): boolean;
deactivate(): void;
readonly hasNext: boolean;
readonly hasResults: boolean;
readonly initialState: SearchSourceState<T>;
readonly isActive: boolean;
readonly isLoading: boolean;
readonly items: T[] | undefined;
readonly lastQueryError: Error | undefined;
readonly next: string | undefined | null;
readonly offset: number | undefined;
resetState(): void;
readonly searchQuery: string;
readonly state: StateStore<SearchSourceState<T>>;
readonly type: SearchSourceType;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface SearchSource<T = any> extends ISearchSource<T> {
cancelScheduledQuery(): void;
setDebounceOptions(options: DebounceOptions): void;
search(text?: string): Promise<void> | undefined;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface SearchSourceSync<T = any> extends ISearchSource<T> {
cancelScheduledQuery(): void;
setDebounceOptions(options: DebounceOptions): void;
search(text?: string): void;
}
const DEFAULT_SEARCH_SOURCE_OPTIONS: Required<SearchSourceOptions> = {
debounceMs: 300,
pageSize: 10,
} as const;
abstract class BaseSearchSourceBase<T> implements ISearchSource<T> {
state: StateStore<SearchSourceState<T>>;
protected pageSize: number;
abstract readonly type: SearchSourceType;
protected constructor(options?: SearchSourceOptions) {
const { pageSize } = { ...DEFAULT_SEARCH_SOURCE_OPTIONS, ...options };
this.pageSize = pageSize;
this.state = new StateStore<SearchSourceState<T>>(this.initialState);
}
get lastQueryError() {
return this.state.getLatestValue().lastQueryError;
}
get hasNext() {
return this.state.getLatestValue().hasNext;
}
get hasResults() {
return Array.isArray(this.state.getLatestValue().items);
}
get isActive() {
return this.state.getLatestValue().isActive;
}
get isLoading() {
return this.state.getLatestValue().isLoading;
}
get initialState() {
return {
hasNext: true,
isActive: false,
isLoading: false,
items: undefined,
lastQueryError: undefined,
next: undefined,
offset: 0,
searchQuery: '',
};
}
get items() {
return this.state.getLatestValue().items;
}
get next() {
return this.state.getLatestValue().next;
}
get offset() {
return this.state.getLatestValue().offset;
}
get searchQuery() {
return this.state.getLatestValue().searchQuery;
}
activate = () => {
if (this.isActive) return;
this.state.partialNext({ isActive: true });
};
deactivate = () => {
if (!this.isActive) return;
this.state.partialNext({ isActive: false });
};
canExecuteQuery = (newSearchString?: string) => {
const hasNewSearchQuery = typeof newSearchString !== 'undefined';
const searchString = newSearchString ?? this.searchQuery;
return !!(
this.isActive &&
!this.isLoading &&
(this.hasNext || hasNewSearchQuery) &&
searchString
);
};
protected getStateBeforeFirstQuery(newSearchString: string): SearchSourceState<T> {
return {
...this.initialState,
isActive: this.isActive,
isLoading: true,
searchQuery: newSearchString,
};
}
protected getStateAfterQuery(
stateUpdate: Partial<SearchSourceState<T>>,
isFirstPage: boolean,
): SearchSourceState<T> {
const current = this.state.getLatestValue();
return {
...current,
lastQueryError: undefined, // reset lastQueryError that can be overridden by the stateUpdate
...stateUpdate,
isLoading: false,
items: isFirstPage
? stateUpdate.items
: [...(this.items ?? []), ...(stateUpdate.items || [])],
};
}
protected prepareStateForQuery(newSearchString?: string) {
const hasNewSearchQuery = typeof newSearchString !== 'undefined';
const searchString = newSearchString ?? this.searchQuery;
if (hasNewSearchQuery) {
this.state.next(this.getStateBeforeFirstQuery(newSearchString ?? ''));
} else {
this.state.partialNext({ isLoading: true });
}
return { searchString, hasNewSearchQuery };
}
protected updatePaginationStateFromQuery(result: QueryReturnValue<T>) {
const { items, next } = result;
const stateUpdate: Partial<SearchSourceState<T>> = {};
if (next || next === null) {
stateUpdate.next = next;
stateUpdate.hasNext = !!next;
} else {
stateUpdate.offset = (this.offset ?? 0) + items.length;
stateUpdate.hasNext = items.length === this.pageSize;
}
return stateUpdate;
}
resetState() {
this.state.next(this.initialState);
}
resetStateAndActivate() {
this.resetState();
this.activate();
}
}
export abstract class BaseSearchSource<T>
extends BaseSearchSourceBase<T>
implements SearchSource<T>
{
protected searchDebounced!: DebouncedExecQueryFunction;
constructor(options?: SearchSourceOptions) {
const { debounceMs } = { ...DEFAULT_SEARCH_SOURCE_OPTIONS, ...options };
super(options);
this.setDebounceOptions({ debounceMs });
}
protected abstract query(searchQuery: string): Promise<QueryReturnValue<T>>;
protected abstract filterQueryResults(items: T[]): T[] | Promise<T[]>;
setDebounceOptions = ({ debounceMs }: DebounceOptions) => {
this.searchDebounced = debounce(this.executeQuery.bind(this), debounceMs);
};
async executeQuery(newSearchString?: string) {
if (!this.canExecuteQuery(newSearchString)) return;
const { hasNewSearchQuery, searchString } =
this.prepareStateForQuery(newSearchString);
let stateUpdate: Partial<SearchSourceState<T>> = {};
try {
const results = await this.query(searchString);
if (!results) return;
const { items } = results;
stateUpdate = this.updatePaginationStateFromQuery(results);
stateUpdate.items = await this.filterQueryResults(items);
} catch (e) {
stateUpdate.lastQueryError = e as Error;
} finally {
this.state.next(this.getStateAfterQuery(stateUpdate, hasNewSearchQuery));
}
}
search = (searchQuery?: string) => this.searchDebounced(searchQuery);
cancelScheduledQuery() {
this.searchDebounced.cancel();
}
}
export abstract class BaseSearchSourceSync<T>
extends BaseSearchSourceBase<T>
implements SearchSourceSync<T>
{
protected searchDebounced!: DebouncedExecQueryFunction;
constructor(options?: SearchSourceOptions) {
const { debounceMs } = { ...DEFAULT_SEARCH_SOURCE_OPTIONS, ...options };
super(options);
this.setDebounceOptions({ debounceMs });
}
protected abstract query(searchQuery: string): QueryReturnValue<T>;
protected abstract filterQueryResults(items: T[]): T[];
setDebounceOptions = ({ debounceMs }: DebounceOptions) => {
this.searchDebounced = debounce(this.executeQuery.bind(this), debounceMs);
};
executeQuery(newSearchString?: string) {
if (!this.canExecuteQuery(newSearchString)) return;
const { hasNewSearchQuery, searchString } =
this.prepareStateForQuery(newSearchString);
let stateUpdate: Partial<SearchSourceState<T>> = {};
try {
const results = this.query(searchString);
if (!results) return;
const { items } = results;
stateUpdate = this.updatePaginationStateFromQuery(results);
stateUpdate.items = this.filterQueryResults(items);
} catch (e) {
stateUpdate.lastQueryError = e as Error;
} finally {
this.state.next(this.getStateAfterQuery(stateUpdate, hasNewSearchQuery));
}
}
search = (searchQuery?: string) => this.searchDebounced(searchQuery);
cancelScheduledQuery() {
this.searchDebounced.cancel();
}
}