diff --git a/client/www/components/dash/explorer/Explorer.tsx b/client/www/components/dash/explorer/Explorer.tsx index 8f2d3474e..9cc70e17b 100644 --- a/client/www/components/dash/explorer/Explorer.tsx +++ b/client/www/components/dash/explorer/Explorer.tsx @@ -1,7 +1,7 @@ import { id, tx } from '@instantdb/core'; import { InstantReactWeb } from '@instantdb/react'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { isObject } from 'lodash'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { isObject, debounce } from 'lodash'; import produce from 'immer'; import Fuse from 'fuse.js'; import clsx from 'clsx'; @@ -36,13 +36,65 @@ import { import { DBAttr, SchemaAttr, SchemaNamespace } from '@/lib/types'; import { useIsOverflow } from '@/lib/hooks/useIsOverflow'; import { useClickOutside } from '@/lib/hooks/useClickOutside'; -import { makeAttrComparator } from '@/lib/makeAttrComparator'; import { isTouchDevice } from '@/lib/config'; import { useSchemaQuery, useNamespacesQuery } from '@/lib/hooks/explorer'; import { EditNamespaceDialog } from '@/components/dash/explorer/EditNamespaceDialog'; import { EditRowDialog } from '@/components/dash/explorer/EditRowDialog'; import { useRouter } from 'next/router'; +function searchWhereFilters( + attrs: Array, + query: string, +): [string, string, string][] { + if (!query) { + return []; + } + const q = `%${query}%`; + // Use case-insensitive if the query is all lower-case + const op = query.toLowerCase() === query ? '$ilike' : '$like'; + const wheres = []; + for (const attr of attrs) { + if (attr.sortable && attr.checkedDataType === 'string') { + const filter: [string, string, string] = [attr.name, op, q]; + wheres.push(filter); + } + } + return wheres; +} + +function SearchInput({ + initialValue, + onSearchChange, + attrs, +}: { + initialValue: string; + onSearchChange: (filters: [string, string, string][]) => void; + attrs?: SchemaAttr[]; +}) { + const [value, setValue] = useState(initialValue); + + const searchDebounce = useCallback( + debounce((search) => { + if (attrs) { + onSearchChange(searchWhereFilters(attrs, search)); + } + }, 80), + [attrs], + ); + + return ( + { + setValue(v); + searchDebounce(v); + }} + /> + ); +} + export function Explorer({ db, appId, @@ -128,49 +180,25 @@ export function Explorer({ const offset = offsets[selectedNamespace?.name ?? ''] || 0; + const sortAttr = currentNav?.sortAttr || 'serverCreatedAt'; + const sortAsc = currentNav?.sortAsc ?? true; + + const [searchFilters, setSearchFilters] = useState< + [string, string, string][] + >([]); + const { itemsRes, allCount } = useNamespacesQuery( db, selectedNamespace, currentNav?.where, + searchFilters, limit, offset, + sortAttr, + sortAsc, ); - const { allItems, fuse } = useMemo(() => { - const allItems: Record[] = - itemsRes.data?.[selectedNamespace?.name ?? '']?.slice() ?? []; - - const fuse = new Fuse(allItems, { - threshold: 0.15, - shouldSort: false, - keys: - selectedNamespace?.attrs.map((a) => - a.type === 'ref' ? `${a.name}.id` : a.name, - ) ?? [], - }); - - return { allItems, fuse }; - }, [itemsRes.data, selectedNamespace]); - - const filteredSortedItems = useMemo(() => { - const _items = currentNav?.search - ? fuse.search(currentNav.search).map((r) => r.item) - : [...allItems]; - - const { sortAttr, sortAsc } = currentNav ?? {}; - - if (sortAttr) { - _items.sort(makeAttrComparator(sortAttr, sortAsc)); - } - - return _items; - }, [ - allItems, - fuse, - currentNav?.search, - currentNav?.sortAsc, - currentNav?.sortAttr, - ]); + const allItems = itemsRes.data?.[selectedNamespace?.name ?? ''] ?? []; const numPages = allCount ? Math.ceil(allCount / limit) : 1; const currentPage = offset / limit + 1; @@ -367,10 +395,14 @@ export function Explorer({ {selectedNamespace && currentNav && allItems ? ( -
-
+
+
-
+
{showBackButton ? ( ) : null} -
+
{selectedNamespace.name}{' '} {currentNav.where ? ( <> @@ -400,6 +432,22 @@ export function Explorer({ ) : null} + {searchFilters?.length ? ( + `${attr} ${op} ${search}`) + .join(' || ')} + > + {searchFilters.map(([attr, op, search], i) => ( + + + {attr} {op} {search} + + {i < searchFilters.length - 1 ? ' || ' : null} + + ))} + + ) : null}
@@ -412,15 +460,10 @@ export function Explorer({ > Edit Schema - { - replaceNavStackTop({ - search: v ?? undefined, - }); - }} + setSearchFilters(filters)} + attrs={selectedNamespace?.attrs} />
@@ -560,21 +603,25 @@ export function Explorer({ Delete {rowText}
- +
))} - {filteredSortedItems.map((item) => ( + {allItems.map((item) => ( void; diff --git a/client/www/lib/hooks/explorer.tsx b/client/www/lib/hooks/explorer.tsx index e4bd7ea8d..94813aa21 100644 --- a/client/www/lib/hooks/explorer.tsx +++ b/client/www/lib/hooks/explorer.tsx @@ -3,14 +3,37 @@ import { useEffect, useState } from 'react'; import { DBAttr, SchemaNamespace } from '@/lib/types'; import { dbAttrsToExplorerSchema } from '@/lib/schema'; +function makeWhere( + navWhere: null | undefined | [string, any], + searchFilters: null | undefined | [string, string, string][], +) { + const where: { [key: string]: any } = {}; + if (navWhere) { + where[navWhere[0]] = navWhere[1]; + } + if (searchFilters?.length) { + where.or = searchFilters.map(([attr, op, val]) => { + return { [attr]: { [op]: val } }; + }); + } + return where; +} + // HOOKS export function useNamespacesQuery( db: InstantReactWeb, selectedNs?: SchemaNamespace, - where?: [string, any], + navWhere?: [string, any], + searchFilters?: [string, string, string][], limit?: number, offset?: number, + sortAttr?: string, + sortAsc?: boolean, ) { + const direction: 'asc' | 'desc' = sortAsc ? 'asc' : 'desc'; + + const where = makeWhere(navWhere, searchFilters); + const iql = selectedNs ? { [selectedNs.name]: { @@ -20,9 +43,10 @@ export function useNamespacesQuery( .map((a) => [a.name, {}]), ), $: { - ...(where ? { where: { [where[0]]: where[1] } } : {}), + ...(where ? { where: where } : {}), ...(limit ? { limit } : {}), ...(offset ? { offset } : {}), + ...(sortAttr ? { order: { [sortAttr]: direction } } : {}), }, }, } @@ -36,7 +60,7 @@ export function useNamespacesQuery( [selectedNs.name]: { $: { aggregate: 'count', - ...(where ? { where: { [where[0]]: where[1] } } : {}), + ...(where ? { where: where } : {}), }, }, } diff --git a/client/www/lib/schema.tsx b/client/www/lib/schema.tsx index ffa135c3c..514d8ab03 100644 --- a/client/www/lib/schema.tsx +++ b/client/www/lib/schema.tsx @@ -63,6 +63,7 @@ export function dbAttrsToExplorerSchema( inferredTypes: attrDesc['inferred-types'], catalog: attrDesc.catalog, checkedDataType: attrDesc['checked-data-type'], + sortable: attrDesc['index?'] && !!attrDesc['checked-data-type'], }; } } @@ -86,6 +87,7 @@ export function dbAttrsToExplorerSchema( isUniq: attrDesc['unique?'], cardinality: attrDesc.cardinality, linkConfig, + sortable: attrDesc['index?'] && !!attrDesc['checked-data-type'], }; } } diff --git a/client/www/lib/types.ts b/client/www/lib/types.ts index 832d453b7..10fb9647b 100644 --- a/client/www/lib/types.ts +++ b/client/www/lib/types.ts @@ -183,6 +183,7 @@ export interface SchemaAttr { inferredTypes?: Array<'string' | 'number' | 'boolean' | 'json'>; catalog?: 'user' | 'system'; checkedDataType?: CheckedDataType; + sortable: boolean; } export type InstantError = {
0 && - Object.keys(checkedIds).length === - filteredSortedItems.length + allItems.length > 0 && + Object.keys(checkedIds).length === allItems.length } onChange={(checked) => { if (checked) { setCheckedIds( Object.fromEntries( - filteredSortedItems.map((i) => [i.id, true]), + allItems.map((i) => [i.id, true]), ), ); } else { @@ -587,41 +634,64 @@ export function Explorer({ { - replaceNavStackTop({ - sortAttr: attr.name, - sortAsc: - currentNav.sortAttr !== attr.name - ? true - : !currentNav.sortAsc, - }); - }} + onClick={ + attr.sortable + ? () => { + replaceNavStackTop({ + sortAttr: attr.name, + sortAsc: + sortAttr !== attr.name ? true : !sortAsc, + }); + } + : attr.name === 'id' + ? () => { + replaceNavStackTop({ + sortAttr: 'serverCreatedAt', + sortAsc: + sortAttr !== 'serverCreatedAt' + ? true + : !sortAsc, + }); + } + : undefined + } >
{attr.name} - - {currentNav.sortAttr === attr.name ? ( - currentNav.sortAsc ? ( - '↓' + {attr.sortable || attr.name === 'id' ? ( + + {sortAttr === attr.name || + (sortAttr === 'serverCreatedAt' && + attr.name === 'id') ? ( + sortAsc ? ( + '↓' + ) : ( + '↑' + ) ) : ( - '↑' - ) - ) : ( - - )} - + + )} + + ) : null}